From 2884b4299c1f6cd4a6b8856682787d25f9590d30 Mon Sep 17 00:00:00 2001 From: Aditya Duggirala <311752106+adityalenova@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:29:10 +0530 Subject: [PATCH] ActionList: Respect prefers-reduced-motion for the multi-select checkmark The clip-path wipe on the multi-select checkbox is decorative - the mark is shown and hidden through `visibility` - so move the animation and the 230ms hide delay behind @media (prefers-reduced-motion: no-preference), the same way Skeleton, ProgressBar and SegmentedControl opt in to motion. With motion reduced the mark is simply present when the item is selected. The animated path keeps every declaration it uses today, including the clipped start value that covers the 80ms delay on checkmarkIn, so the rendered end state is unchanged and the existing baselines stay valid. --- .../actionlist-checkmark-reduced-motion.md | 5 ++ e2e/components/ActionList.test.ts | 70 +++++++++++++++++++ .../src/ActionList/ActionList.module.css | 23 ++++-- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 .changeset/actionlist-checkmark-reduced-motion.md 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) {