diff --git a/.changeset/actionlist-checkmark-reduced-motion.md b/.changeset/actionlist-checkmark-reduced-motion.md new file mode 100644 index 00000000000..bcf43388da5 --- /dev/null +++ b/.changeset/actionlist-checkmark-reduced-motion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +ActionList: Respect prefers-reduced-motion for the multi-select checkmark animation. diff --git a/e2e/components/ActionList.test.ts b/e2e/components/ActionList.test.ts index 2cf513506eb..2bc074e265a 100644 --- a/e2e/components/ActionList.test.ts +++ b/e2e/components/ActionList.test.ts @@ -216,4 +216,74 @@ test.describe('ActionList', () => { }) } }) + + // A multi-select checkbox only renders when the list is not a `menu`: + // `Selection` falls back to the single-select checkmark for `role="menu"`, so + // the FilteredActionList story - a `role="listbox"` with + // `selectionVariant="multiple"` - is what produces `.MultiSelectCheckbox`. + const bugItem = (page: Page) => page.getByRole('option', {name: /^bug/}) + + const describeCheckmark = (page: Page) => + bugItem(page) + .locator('[data-component="ActionList.Selection"] > div') + .evaluate(element => { + const {animationName, clipPath, visibility, transitionDelay} = getComputedStyle(element, '::before') + return {animationName, clipPath, visibility, transitionDelay} + }) + + 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('checkmark is shown without the clip-path animation @vrt', async ({page}) => { + await visit(page, { + id: 'components-filteredactionlist-features--multi-select-with-select-all', + }) + + // `bug` is one of the two items the story starts out with selected + expect(await describeCheckmark(page)).toEqual({ + animationName: 'none', + // Chromium reports a fully un-clipped value as `inset(0px)` + clipPath: 'inset(0px)', + visibility: 'visible', + transitionDelay: '0s', + }) + + // Clearing the selection hides the mark straight away - the wipe-out and + // the 230ms `visibility` delay that goes with it are both motion-only + await bugItem(page).click() + + expect(await describeCheckmark(page)).toEqual({ + animationName: 'none', + clipPath: 'inset(16px 0px 0px)', + visibility: 'hidden', + transitionDelay: '0s', + }) + }) + }) + + test.describe('no preference for reduced motion', () => { + test.use({contextOptions: {reducedMotion: 'no-preference'}}) + + test('checkmark keeps its clip-path animation @vrt', async ({page}) => { + await visit(page, { + id: 'components-filteredactionlist-features--multi-select-with-select-all', + }) + + // Names are scoped by CSS Modules, so only the base name is stable. The + // clip-path is not worth asserting here: by the time it can be read, the + // 80ms wipe has already landed on the un-clipped value. + const checkmark = await describeCheckmark(page) + expect(checkmark.animationName).toMatch(/checkmarkIn/) + expect(checkmark.visibility).toBe('visible') + + await bugItem(page).click() + + const cleared = await describeCheckmark(page) + expect(cleared.animationName).toMatch(/checkmarkOut/) + // The mark is kept visible until the wipe-out has run + expect(cleared.transitionDelay).toBe('0.23s') + }) + }) }) diff --git a/packages/react/src/ActionList/ActionList.module.css b/packages/react/src/ActionList/ActionList.module.css index 7a4a9065c0a..8ece6a80cfa 100644 --- a/packages/react/src/ActionList/ActionList.module.css +++ b/packages/react/src/ActionList/ActionList.module.css @@ -427,7 +427,6 @@ content: ''; /* stylelint-disable-next-line primer/colors */ background-color: var(--control-checked-fgColor-rest); - transition: visibility 0s linear 230ms; clip-path: inset(var(--base-size-16) 0 0 0); /* octicon checkmark image */ @@ -435,8 +434,15 @@ mask-size: 75%; mask-repeat: no-repeat; mask-position: center; - animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1); - /* forwards; slightly snappier animation out */ + + /* The clip-path wipe is decorative, so it is opt-in like the rest of the + library (Checkbox, Skeleton, SegmentedControl). */ + @media (prefers-reduced-motion: no-preference) { + /* checked -> unchecked - wait out the wipe-out before hiding the mark */ + transition: visibility 0s linear 230ms; + animation: checkmarkOut 80ms cubic-bezier(0.65, 0, 0.35, 1); + /* forwards; slightly snappier animation out */ + } } @media (forced-colors: active) { @@ -459,8 +465,15 @@ &::before { visibility: visible; - transition: visibility 0s linear 0s; - animation: checkmarkIn 80ms cubic-bezier(0.65, 0, 0.35, 1) forwards 80ms; + /* Reduced motion: the mark is simply present, without the wipe */ + clip-path: inset(0 0 0 0); + + @media (prefers-reduced-motion: no-preference) { + transition: visibility 0s linear 0s; + /* Keep the mark clipped while `checkmarkIn` waits out its 80ms delay */ + clip-path: inset(var(--base-size-16) 0 0 0); + animation: checkmarkIn 80ms cubic-bezier(0.65, 0, 0.35, 1) forwards 80ms; + } } @media (forced-colors: active) {