Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow customization of AvatarStack hover behavior and size #3466

Merged
merged 23 commits into from
Jul 7, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
af5de05
adds a prop to prevent avatars from spreading on hover
mperrotti Jun 29, 2023
0f1a410
enables AvatarStack to render any size avatar
mperrotti Jun 29, 2023
b56fb75
updates tests and stories
mperrotti Jun 30, 2023
51c6798
Update generated/components.json
mperrotti Jun 30, 2023
dc66fd8
Create eighty-islands-join.md
mperrotti Jun 30, 2023
aaa91bc
Merge branch 'main' into mp/avatarstack-disable-expand
mperrotti Jun 30, 2023
e9ac0b5
Merge branch 'main' of github.com:primer/react into mp/avatarstack-di…
mperrotti Jul 5, 2023
1c6d74c
replace custom function to find smallest number with Math.min
mperrotti Jul 5, 2023
b791f1c
makes Avatar and AvatarStack 'size' prop responsive
mperrotti Jul 6, 2023
538585e
updates stories and tests, fixes bugs
mperrotti Jul 6, 2023
9aecf10
Merge branch 'main' into mp/avatarstack-disable-expand
mperrotti Jul 6, 2023
6d837f0
updates changeset
mperrotti Jul 6, 2023
68cb177
Merge branch 'mp/avatarstack-disable-expand' of github.com:primer/rea…
mperrotti Jul 6, 2023
0fe694a
updates changeset again
mperrotti Jul 6, 2023
df1b665
changeset update AGAIN
mperrotti Jul 6, 2023
46bd2d5
fixes linter errors
mperrotti Jul 6, 2023
b1aec60
updates snapshots after updating dependencies
mperrotti Jul 6, 2023
03e0f7d
Merge branch 'main' of github.com:primer/react into mp/avatarstack-di…
mperrotti Jul 6, 2023
ffd7719
test(vrt): update snapshots
mperrotti Jul 6, 2023
efbea09
typescript improvements
mperrotti Jul 7, 2023
0ff5a97
Merge branch 'main' of github.com:primer/react into mp/avatarstack-di…
mperrotti Jul 7, 2023
1fb8e97
Merge branch 'mp/avatarstack-disable-expand' of github.com:primer/rea…
mperrotti Jul 7, 2023
356bf65
convert clasnames to clsx
mperrotti Jul 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eighty-islands-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Allows consumers to disable the behavior where the stack expands to show all avatars on hover. Also allows users to customize the size of the avatars in the stack.
102 changes: 102 additions & 0 deletions e2e/components/AvatarStack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,106 @@ test.describe('AvatarStack', () => {
})
}
})

test.describe('Disable Expand On Hover', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--disable-expand-on-hover',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`AvatarStack.Disable Expand On Hover.${theme}.png`)
})

test('axe @aat', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--disable-expand-on-hover',
globals: {
colorScheme: theme,
},
})
await expect(page).toHaveNoViolations({
rules: {
'color-contrast': {
enabled: theme !== 'dark_dimmed',
},
},
})
})
})
}
})

test.describe('Custom Size On Parent', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--custom-size-on-parent',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`AvatarStack.Custom Size On Parent.${theme}.png`)
})

test('axe @aat', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--custom-size-on-parent',
globals: {
colorScheme: theme,
},
})
await expect(page).toHaveNoViolations({
rules: {
'color-contrast': {
enabled: theme !== 'dark_dimmed',
},
},
})
})
})
}
})

test.describe('Custom Size On Children', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--custom-size-on-children',
globals: {
colorScheme: theme,
},
})

// Default state
expect(await page.screenshot()).toMatchSnapshot(`AvatarStack.Custom Size On Children.${theme}.png`)
})

test('axe @aat', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--custom-size-on-children',
globals: {
colorScheme: theme,
},
})
await expect(page).toHaveNoViolations({
rules: {
'color-contrast': {
enabled: theme !== 'dark_dimmed',
},
},
})
})
})
}
})
})
12 changes: 12 additions & 0 deletions generated/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,18 @@
"defaultValue": "false",
"description": "Align the avatars to the right"
},
{
"name": "disableExpand",
"type": "boolean",
"defaultValue": "false",
"description": "Do not spread the avatars on hover"
},
{
"name": "size",
"type": "number",
"defaultValue": "20",
"description": "The size of the avatar children in pixels."
},
{
"name": "sx",
"type": "SystemStyleObject"
Expand Down
4 changes: 3 additions & 1 deletion src/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {get} from '../constants'
import sx, {SxProp} from '../sx'
import {ComponentProps} from '../utils/types'

export const DEFAULT_AVATAR_SIZE = 20

type StyledAvatarProps = {
/** Sets the width and height of the avatar. */
size?: number
Expand Down Expand Up @@ -39,7 +41,7 @@ const StyledAvatar = styled.img.attrs<StyledAvatarProps>(props => ({
export type AvatarProps = ComponentProps<typeof StyledAvatar>

const Avatar = React.forwardRef<HTMLImageElement, AvatarProps>(function Avatar(
{alt = '', size = 20, square = false, ...rest},
{alt = '', size = DEFAULT_AVATAR_SIZE, square = false, ...rest},
ref,
) {
return <StyledAvatar ref={ref} alt={alt} size={size} square={square} {...rest} />
Expand Down
12 changes: 12 additions & 0 deletions src/AvatarStack/AvatarStack.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
"defaultValue": "false",
"description": "Align the avatars to the right"
},
{
"name": "disableExpand",
"type": "boolean",
"defaultValue": "false",
"description": "Do not spread the avatars on hover"
},
{
"name": "size",
"type": "number",
"defaultValue": "20",
"description": "The size of the avatar children in pixels."
},
{
"name": "sx",
"type": "SystemStyleObject"
Expand Down
27 changes: 27 additions & 0 deletions src/AvatarStack/AvatarStack.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,30 @@ export const AlignRight = () => (
<Avatar alt="GitHub Desktop logo" src="https://avatars.githubusercontent.com/desktop" />
</AvatarStack>
)

export const DisableExpandOnHover = () => (
<AvatarStack disableExpand>
<Avatar alt="Primer logo" src="https://avatars.githubusercontent.com/primer" />
<Avatar alt="GitHub logo" src="https://avatars.githubusercontent.com/github" />
<Avatar alt="Atom logo" src="https://avatars.githubusercontent.com/atom" />
<Avatar alt="GitHub Desktop logo" src="https://avatars.githubusercontent.com/desktop" />
</AvatarStack>
)

export const CustomSizeOnParent = () => (
<AvatarStack size={32}>
<Avatar alt="Primer logo" src="https://avatars.githubusercontent.com/primer" />
<Avatar alt="GitHub logo" src="https://avatars.githubusercontent.com/github" />
<Avatar alt="Atom logo" src="https://avatars.githubusercontent.com/atom" />
<Avatar alt="GitHub Desktop logo" src="https://avatars.githubusercontent.com/desktop" />
</AvatarStack>
)

export const CustomSizeOnChildren = () => (
<AvatarStack>
<Avatar size={32} alt="Primer logo" src="https://avatars.githubusercontent.com/primer" />
<Avatar size={32} alt="GitHub logo" src="https://avatars.githubusercontent.com/github" />
<Avatar size={32} alt="Atom logo" src="https://avatars.githubusercontent.com/atom" />
<Avatar size={32} alt="GitHub Desktop logo" src="https://avatars.githubusercontent.com/desktop" />
</AvatarStack>
)
91 changes: 74 additions & 17 deletions src/AvatarStack/AvatarStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,53 @@ import styled from 'styled-components'
import {get} from '../constants'
import Box from '../Box'
import sx, {SxProp} from '../sx'
import {AvatarProps, DEFAULT_AVATAR_SIZE} from '../Avatar/Avatar'

type StyledAvatarStackWrapperProps = {
count?: number
} & SxProp

const findSmallestNumber = (numbers: number[]): number => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would Math.min(...numbers) work here? 🤔 Just an idea - not a blocker at all.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! Soooo much easier.

let smallestNumber = numbers[0]
for (let i = 1; i < numbers.length; i++) {
if (numbers[i] < smallestNumber) {
smallestNumber = numbers[i]
}
}
return smallestNumber
}

const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
--avatar-border-width: 1px;
--avatar-two-margin: calc(var(--avatar-size) * -0.55);
--avatar-three-margin: calc(var(--avatar-size) * -0.85);

// this calc explained:
// 1. avatar size + the non-overlapping part of the second avatar
// 2. + the non-overlapping part of the second and third avatar
// 3. + the border widths of all previous avatars
--avatar-stack-three-plus-min-width: calc(
var(--avatar-size) +
calc(
calc(var(--avatar-size) + var(--avatar-two-margin)) + calc(var(--avatar-size) + var(--avatar-three-margin)) * 2
) + calc(var(--avatar-border-width) * 3)
);
display: flex;
position: relative;
height: 20px;
min-width: ${props => (props.count === 1 ? '20px' : props.count === 2 ? '30px' : '38px')};
height: var(--avatar-size);
min-width: ${props => (props.count === 1 ? 'var(--avatar-size)' : props.count === 2 ? '30px' : '38px')};

.pc-AvatarStackBody {
display: flex;
position: absolute;
width: var(--avatar-stack-three-plus-min-width);
}

.pc-AvatarItem {
flex-shrink: 0;
height: 20px;
width: 20px;
box-shadow: 0 0 0 1px ${get('colors.canvas.default')};
height: var(--avatar-size);
width: var(--avatar-size);
box-shadow: 0 0 0 var(--avatar-border-width) ${get('colors.canvas.default')};
position: relative;
overflow: hidden;
transition: margin 0.2s ease-in-out, opacity 0.2s ease-in-out, visibility 0.2s ease-in-out,
Expand All @@ -31,12 +62,12 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
}

&:nth-child(n + 2) {
margin-left: -11px;
margin-left: var(--avatar-two-margin);
z-index: 9;
}

&:nth-child(n + 3) {
margin-left: -17px;
margin-left: var(--avatar-three-margin);
opacity: ${100 - 3 * 15}%;
z-index: 8;
}
Expand All @@ -58,11 +89,16 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
}

&.pc-AvatarStack--two {
min-width: 30px;
// this calc explained:
// 1. avatar size + the non-overlapping part of the second avatar
// 2. + the border widths of the first two avatars
min-width: calc(
var(--avatar-size) + calc(var(--avatar-size) + var(--avatar-two-margin)) + var(--avatar-border-width)
);
}

&.pc-AvatarStack--three-plus {
min-width: 38px;
min-width: var(--avatar-stack-three-plus-min-width);
}

&.pc-AvatarStack--right {
Expand All @@ -75,11 +111,11 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
}

&:nth-child(n + 2) {
margin-right: -11px;
margin-right: var(--avatar-two-margin);
}

&:nth-child(n + 3) {
margin-right: -17px;
margin-right: var(--avatar-three-margin);
}
}

Expand All @@ -99,7 +135,7 @@ const AvatarStackWrapper = styled.span<StyledAvatarStackWrapperProps>`
}
}

.pc-AvatarStackBody:hover {
.pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover {
width: auto;

.pc-AvatarItem {
Expand Down Expand Up @@ -127,21 +163,42 @@ const transformChildren = (children: React.ReactNode) => {

export type AvatarStackProps = {
alignRight?: boolean
disableExpand?: boolean
Copy link
Member

@siddharthkp siddharthkp Jul 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personal opinion, not strongly held: disableExpand is a double negative, which can feel confusing

What do you think about expand: boolean where default is true?

<AvatarStack />

<AvatarStack expand={false} />

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels strange to have to explicitly pass propName={false} because boolean props are normally evaluated as false if the prop is excluded.

If we didn't set expand to true by default, then <AvatarStack /> would be the same as <AvatarStack expand={false} />

Copy link
Member

@siddharthkp siddharthkp Jul 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels strange to have to explicitly pass propName={false}

true true!

because boolean props are normally evaluated as false if the prop is excluded

not sure about that tho 😅

// default true
<AvatarStack/>  = <AvatarStack expand /> = <AvatarStack expand={true} />

// disabling default: 
<AvatarStack expand={false}/>

buuuut, also happy to go with what you feel strongly about

size?: number
children: React.ReactNode
} & SxProp

const AvatarStack = ({children, alignRight, sx: sxProp}: AvatarStackProps) => {
const AvatarStack = ({
children,
alignRight,
disableExpand,
size = DEFAULT_AVATAR_SIZE,
sx: sxProp,
}: AvatarStackProps) => {
const count = React.Children.count(children)
const wrapperClassNames = classnames({
'pc-AvatarStack--two': count === 2,
'pc-AvatarStack--three-plus': count > 2,
'pc-AvatarStack--right': alignRight,
})
const bodyClassNames = classnames('pc-AvatarStackBody', {
'pc-AvatarStack--disableExpand': disableExpand,
})

const avatarSizes = React.Children.map(children, child => {
if (!React.isValidElement<AvatarProps>(child)) return size

return child.props.size ? child.props.size : size
})

return (
<AvatarStackWrapper count={count} className={wrapperClassNames} sx={sxProp}>
<Box position="absolute" display="flex" width="38px" className="pc-AvatarStackBody">
{transformChildren(children)}
</Box>
<AvatarStackWrapper
count={count}
className={wrapperClassNames}
sx={sxProp}
style={{'--avatar-size': `${findSmallestNumber(avatarSizes || [])}px`} as React.CSSProperties}
>
<Box className={bodyClassNames}> {transformChildren(children)}</Box>
</AvatarStackWrapper>
)
}
Expand Down
Loading