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/actionlist-checkmark-reduced-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

ActionList: Respect prefers-reduced-motion for the multi-select checkmark animation.
70 changes: 70 additions & 0 deletions e2e/components/ActionList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
23 changes: 18 additions & 5 deletions packages/react/src/ActionList/ActionList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -427,16 +427,22 @@
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 */
mask-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTEuNzgwMyAwLjIxOTYyNUMxMS45MjEgMC4zNjA0MjcgMTIgMC41NTEzMDUgMTIgMC43NTAzMTNDMTIgMC45NDkzMjEgMTEuOTIxIDEuMTQwMTkgMTEuNzgwMyAxLjI4MUw0LjUxODYgOC41NDA0MkM0LjM3Nzc1IDguNjgxIDQuMTg2ODIgOC43NiAzLjk4Nzc0IDguNzZDMy43ODg2NyA4Ljc2IDMuNTk3NzMgOC42ODEgMy40NTY4OSA4LjU0MDQyTDAuMjAxNjIyIDUuMjg2MkMwLjA2ODkyNzcgNS4xNDM4MyAtMC4wMDMzMDkwNSA0Ljk1NTU1IDAuMDAwMTE2NDkzIDQuNzYwOThDMC4wMDM1NTIwNSA0LjU2NjQzIDAuMDgyMzg5NCA0LjM4MDgxIDAuMjIwMDMyIDQuMjQzMjFDMC4zNTc2NjUgNC4xMDU2MiAwLjU0MzM1NSA0LjAyNjgxIDAuNzM3OTcgNC4wMjMzOEMwLjkzMjU4NCA0LjAxOTk0IDEuMTIwOTMgNC4wOTIxNyAxLjI2MzM0IDQuMjI0ODJMMy45ODc3NCA2Ljk0ODM1TDEwLjcxODYgMC4yMTk2MjVDMTAuODU5NSAwLjA3ODk5MjMgMTEuMDUwNCAwIDExLjI0OTUgMEMxMS40NDg1IDAgMTEuNjM5NSAwLjA3ODk5MjMgMTEuNzgwMyAwLjIxOTYyNVoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
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) {
Expand All @@ -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) {
Expand Down
Loading