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

Tooltip: Respect prefers-reduced-motion for the bubble fade-in.
54 changes: 53 additions & 1 deletion e2e/components/Tooltip.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 All @@ -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')
})
})
})
19 changes: 14 additions & 5 deletions packages/react/src/Tooltip/Tooltip.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading