diff --git a/.changeset/tooltip-reduced-motion.md b/.changeset/tooltip-reduced-motion.md new file mode 100644 index 00000000000..16316b2ffd8 --- /dev/null +++ b/.changeset/tooltip-reduced-motion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Tooltip: Respect prefers-reduced-motion for the bubble fade-in. diff --git a/e2e/components/Tooltip.test.ts b/e2e/components/Tooltip.test.ts index 180e4980fb2..1275a736dcf 100644 --- a/e2e/components/Tooltip.test.ts +++ b/e2e/components/Tooltip.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' @@ -21,4 +21,56 @@ test.describe('Tooltip', () => { }) } }) + + // The bubble is drawn by the trigger's `::after`, which `toHaveCSS` cannot + // target, so these read the computed style of the pseudo-element directly. + const describeBubble = (page: Page) => + page.locator('[role="tooltip"]').evaluate(element => { + const {animationName, display, opacity} = getComputedStyle(element, '::after') + return {animationName, display, opacity} + }) + + 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('bubble is shown without the fade @vrt', async ({page}) => { + await visit(page, { + id: 'deprecated-components-tooltip--default', + }) + + await page.locator('[role="tooltip"]').hover() + + // `tooltip-appear` is what lifts the bubble off the `opacity: 0` in + // `.Tooltip::after`, so this is the case the visible-by-default rule + // exists for - without it, reduced motion would render nothing at all + expect(await describeBubble(page)).toEqual({ + animationName: 'none', + // The rule asks for `inline-block`, but the bubble is absolutely + // positioned, so the computed value is blockified to `block` + display: 'block', + opacity: '1', + }) + }) + }) + + test.describe('no preference for reduced motion', () => { + test.use({contextOptions: {reducedMotion: 'no-preference'}}) + + test('bubble keeps its fade @vrt', async ({page}) => { + await visit(page, { + id: 'deprecated-components-tooltip--default', + }) + + await page.locator('[role="tooltip"]').hover() + + // Names are scoped by CSS Modules, so only the base name is stable + const bubble = await describeBubble(page) + expect(bubble.animationName).toMatch(/tooltip-appear/) + + // The fade still lands on a fully opaque bubble + await expect.poll(async () => (await describeBubble(page)).opacity, {timeout: 1000}).toBe('1') + }) + }) }) diff --git a/packages/react/src/Tooltip/Tooltip.module.css b/packages/react/src/Tooltip/Tooltip.module.css index 00b804da962..1bb480686f9 100644 --- a/packages/react/src/Tooltip/Tooltip.module.css +++ b/packages/react/src/Tooltip/Tooltip.module.css @@ -45,11 +45,20 @@ .Tooltip:focus-within::after { display: inline-block; text-decoration: none; - animation-name: tooltip-appear; - animation-duration: 0.1s; - animation-fill-mode: forwards; - animation-timing-function: ease-in; - animation-delay: 0s; + /* `tooltip-appear` only fades the bubble in, so the visible state is the + default here and the fade is the enhancement. Without this the + reduced-motion path would inherit `opacity: 0` from `.Tooltip::after` and + render nothing at all. */ + opacity: 1; + + /* Only show animations if users don't have a preference for reduced motion */ + @media screen and (prefers-reduced-motion: no-preference) { + animation-name: tooltip-appear; + animation-duration: 0.1s; + animation-fill-mode: forwards; + animation-timing-function: ease-in; + animation-delay: 0s; + } } .Tooltip--noDelay:hover::after,