Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/avatarstack-reduced-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

AvatarStack: Respect prefers-reduced-motion for the hover expansion.
53 changes: 52 additions & 1 deletion e2e/components/AvatarStack.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {test, expect} from '@playwright/test'
import {test, expect, type Page} from '@playwright/test'
import {visit} from '../test-helpers/storybook'
import {themes} from '../test-helpers/themes'

Expand Down Expand Up @@ -84,4 +84,55 @@ test.describe('AvatarStack', () => {
}
})
}

const lastItem = (page: Page) => page.locator('.pc-AvatarItem').last()

const describeTransition = (page: Page) =>
lastItem(page).evaluate(element => {
const {transitionDuration, transitionProperty} = getComputedStyle(element)
return {transitionDuration, transitionProperty}
})

test.describe('reduced motion', () => {
// `contextOptions` is what the root Playwright config uses to emulate
// `prefers-reduced-motion`, so it is the option that has to be overridden.
test.use({contextOptions: {reducedMotion: 'reduce'}})

test('still expands on hover, without sliding the avatars @vrt', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--align-left',
})

expect(await describeTransition(page)).toEqual({
transitionDuration: '0s',
transitionProperty: 'all',
})

const body = page.locator('[data-component="AvatarStack.Body"]')
const collapsed = await lastItem(page).boundingBox()
await body.hover()

// The reveal itself is unchanged - the last avatar simply reaches its
// expanded position in the same frame instead of over 200ms
const expanded = await lastItem(page).boundingBox()
expect(expanded).not.toBeNull()
expect(collapsed).not.toBeNull()
expect(expanded!.x).toBeGreaterThan(collapsed!.x)
await expect(lastItem(page)).toBeVisible()
})
})

test.describe('no preference for reduced motion', () => {
test.use({contextOptions: {reducedMotion: 'no-preference'}})

test('keeps the hover transition @vrt', async ({page}) => {
await visit(page, {
id: 'components-avatarstack-features--align-left',
})

const {transitionDuration, transitionProperty} = await describeTransition(page)
expect(transitionProperty).toContain('margin')
expect(transitionDuration).toBe('0.2s, 0.2s, 0.2s, 0.2s')
})
})
})
17 changes: 12 additions & 5 deletions packages/react/src/AvatarStack/AvatarStack.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,18 @@
height: var(--avatar-stack-size);
overflow: hidden;
flex-shrink: 0;
transition:
margin 0.2s ease-in-out,
opacity 0.2s ease-in-out,
mask-position 0.2s ease-in-out,
mask-size 0.2s ease-in-out;

/* The hover reveal slides each avatar across the screen, so it is opt-in like
the rest of the library (Skeleton, ProgressBar, SegmentedControl). The
expanded positions come from the `:hover` rules below, so with motion
reduced every avatar is still revealed - it just happens immediately. */
@media (prefers-reduced-motion: no-preference) {
transition:
margin 0.2s ease-in-out,
opacity 0.2s ease-in-out,
mask-position 0.2s ease-in-out,
mask-size 0.2s ease-in-out;
}

/* stylelint-disable-next-line selector-no-qualifying-type */
.AvatarStack:where([data-shape='circle']) &:is(img) {
Expand Down
Loading