diff --git a/.changeset/checkbox-reduced-motion.md b/.changeset/checkbox-reduced-motion.md new file mode 100644 index 00000000000..916383b84a3 --- /dev/null +++ b/.changeset/checkbox-reduced-motion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Checkbox: Respect prefers-reduced-motion for the checkmark animation. diff --git a/e2e/components/Checkbox.test.ts b/e2e/components/Checkbox.test.ts index 68bdc98193f..1b45ca9d12f 100644 --- a/e2e/components/Checkbox.test.ts +++ b/e2e/components/Checkbox.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' @@ -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') + }) + }) }) diff --git a/packages/react/src/Checkbox/Checkbox.module.css b/packages/react/src/Checkbox/Checkbox.module.css index 26e48109d40..9ae48547214 100644 --- a/packages/react/src/Checkbox/Checkbox.module.css +++ b/packages/react/src/Checkbox/Checkbox.module.css @@ -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, @@ -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,