diff --git a/.changeset/avatarstack-reduced-motion.md b/.changeset/avatarstack-reduced-motion.md new file mode 100644 index 00000000000..acd77fd126b --- /dev/null +++ b/.changeset/avatarstack-reduced-motion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +AvatarStack: Respect prefers-reduced-motion for the hover expansion. diff --git a/e2e/components/AvatarStack.test.ts b/e2e/components/AvatarStack.test.ts index 273f4946c98..09e20bc67ca 100644 --- a/e2e/components/AvatarStack.test.ts +++ b/e2e/components/AvatarStack.test.ts @@ -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' @@ -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') + }) + }) }) diff --git a/packages/react/src/AvatarStack/AvatarStack.module.css b/packages/react/src/AvatarStack/AvatarStack.module.css index e6c77986b84..09d7627c6de 100644 --- a/packages/react/src/AvatarStack/AvatarStack.module.css +++ b/packages/react/src/AvatarStack/AvatarStack.module.css @@ -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) {