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

Checkbox: Respect prefers-reduced-motion for the checkmark animation.
77 changes: 76 additions & 1 deletion e2e/components/Checkbox.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 @@ -72,4 +72,79 @@ test.describe('Checkbox', () => {
}
})
}

// The checkmark is drawn by the input's `::before`, which `toHaveCSS` cannot
// target, so these read the computed style of the pseudo-element directly.
const describeCheckmark = (page: Page) =>
page.locator('[data-component="Checkbox"]').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 appears without the clip-path animation @vrt', async ({page}) => {
await visit(page, {
id: 'components-checkbox--playground',
args: {checked: true},
})

expect(await describeCheckmark(page)).toEqual({
animationName: 'none',
// Chromium reports a fully un-clipped value as `inset(0px)`
clipPath: 'inset(0px)',
visibility: 'visible',
transitionDelay: '0s',
})

await visit(page, {
id: 'components-checkbox-features--indeterminate',
})

expect(await describeCheckmark(page)).toEqual({
animationName: 'none',
clipPath: 'inset(0px)',
visibility: 'visible',
transitionDelay: '0s',
})

// Clearing the indeterminate state hides the mark immediately, with no
// delay left over from the animation-out
await page.locator('[data-component="Checkbox"]').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-checkbox-features--indeterminate',
})

// Names are scoped by CSS Modules, so only the base name is stable. The
// clip-path itself is not worth asserting here: by the time it can be
// read, the 80ms wipe has already run and landed on the un-clipped value.
const checkmark = await describeCheckmark(page)
expect(checkmark.animationName).toMatch(/checkmarkIn/)
expect(checkmark.visibility).toBe('visible')

await page.locator('[data-component="Checkbox"]').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')
})
})
})
19 changes: 16 additions & 3 deletions packages/react/src/Checkbox/Checkbox.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@
content: '';
/* stylelint-disable-next-line primer/colors */
background-color: var(--fgColor-onEmphasis);
transition: visibility 0s linear 230ms;
clip-path: inset(var(--base-size-16) 0 0 0);
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;

/* The clip-path wipe is decorative, so it is opt-in like every other
animated component (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;
}
}

&:checked,
Expand All @@ -39,7 +45,14 @@

&:checked::before,
&:indeterminate::before {
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) {
/* 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;
}
}

&:checked:disabled,
Expand Down
Loading