Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/layer-touch-reduced-motion-fade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] With Reduce Motion on, a Tooltip, HoverCard, Popover, menu or other layer that opens under a finger now fades in, with no slide and no scale, in 200 ms or less, instead of appearing at once. Under a mouse it still appears at once, and without Reduce Motion the entrance is unchanged.

@vjeux
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2042,6 +2042,16 @@ jobs:
retention-days: 30
if-no-files-found: warn

# Under Reduce Motion a layer that opens under a finger fades in, while
# under a mouse it still appears at once. Reduce Motion and a coarse
# pointer are media queries, which only a real engine resolves.
- name: Run layer reduced-motion entrance contract
run: |
pnpm exec playwright test \
packages/core/src/Layer/__tests__/LayerReducedMotion.a11y.chromium.spec.ts \
--project chromium \
--output layer-reduced-motion-output

# The accordion contract's header role and name, the page Tab step between
# headers, the absence of arrow-key focus movement, and the single-expansion
# sibling collapse are accessibility-tree and real-browser facts that jsdom
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file LayerReducedMotion.a11y.chromium.spec.ts
* @input The Tooltip and HoverCard Touch Triggers stories in a built
* Storybook, Chromium with Reduce Motion emulated, under a finger (phone
* profile, coarse pointer) and under a mouse (desktop profile)
* @output Real-engine proof of the layer entrance under Reduce Motion: under
* a finger the tooltip and the hover card fade in with no travel and no
* scale, in 200 ms or less (design:reduced-motion DR2); under a mouse they
* still appear at once (DR3); without Reduce Motion the entrance is
* unchanged
* @position Run with `pnpm test:a11y-contract`. Media queries only resolve
* in a real engine; the entrance itself is layerAnimations.stylex.ts.
* Reduce Motion is emulated per page (`page.emulateMedia`): the project's
* `use.reducedMotion` does not reach `matchMedia` (playwright.config.ts)
*/

import {expect, test, type Page} from '@playwright/test';
import {
DEFAULT_STORYBOOK_DIR,
serveStorybook,
type StaticServer,
} from '@astryxdesign/a11y-spec/storybook';

let storybook: StaticServer;
test.beforeAll(async () => {
storybook = await serveStorybook(
process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
);
});
test.afterAll(async () => {
await storybook?.close();
});

const phone = {
hasTouch: true,
isMobile: true,
viewport: {width: 390, height: 844},
} as const;

const cases = [
{
name: 'Tooltip',
story: 'core-tooltip--touch-triggers',
trigger: 'hover-able term',
layer: '.astryx-tooltip',
},
{
name: 'HoverCard',
story: 'core-hovercard--touch-triggers',
trigger: 'Jane Doe',
layer: '.astryx-hover-card',
},
];

/**
* The open layer's entrance: its animation name and the properties its
* keyframes animate (read from the page's style sheets).
*/
async function entranceOf(page: Page, selector: string) {
await expect(page.locator(`${selector}:popover-open`)).toHaveCount(1);
return page.evaluate(sel => {
const layer = document.querySelector<HTMLElement>(`${sel}:popover-open`);
if (layer == null) {
return {name: 'missing', properties: [], duration: NaN};
}
const computed = getComputedStyle(layer);
const name = computed.animationName;
// Seconds; the first animation's duration.
const duration = parseFloat(computed.animationDuration.split(',')[0]);
const properties = new Set<string>();
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
continue;
}
const visit = (list: CSSRuleList) => {
for (const rule of Array.from(list)) {
if (rule instanceof CSSKeyframesRule && rule.name === name) {
for (const frame of Array.from(rule.cssRules)) {
const style = (frame as CSSKeyframeRule).style;
for (let i = 0; i < style.length; i += 1) {
properties.add(style.item(i));
}
}
} else if ('cssRules' in rule) {
visit((rule as CSSGroupingRule).cssRules);
}
}
};
visit(rules);
}
return {name, properties: Array.from(properties).sort(), duration};
}, selector);
}

async function tapOpen(page: Page, text: string) {
const box = await page.getByText(text, {exact: true}).boundingBox();
if (box == null) {
throw new Error(`no trigger reading ${text}`);
}
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
}

for (const {name, story, trigger, layer} of cases) {
test.describe(`${name} under a finger with Reduce Motion`, () => {
test.use(phone);

test('fades in, with no travel and no scale, in 200 ms or less', async ({
page,
}) => {
await page.emulateMedia({reducedMotion: 'reduce'});
await page.goto(
`${storybook.origin}/iframe.html?id=${story}&viewMode=story`,
);
await tapOpen(page, trigger);
const entrance = await entranceOf(page, layer);
expect(entrance.name).not.toBe('none');
expect(entrance.properties).toEqual(['opacity']);
// design:reduced-motion DR2: a cross-fade of 200 ms or less.
expect(entrance.duration).toBeGreaterThan(0);
expect(entrance.duration).toBeLessThanOrEqual(0.2);
});
});

test.describe(`${name} under a finger with Reduce Motion and a slow theme`, () => {
test.use(phone);

test('still cross-fades in 200 ms or less', async ({page}) => {
await page.emulateMedia({reducedMotion: 'reduce'});
await page.goto(
`${storybook.origin}/iframe.html?id=${story}&viewMode=story`,
);
// A theme whose fast duration is longer than the cap (DR2, DR4: no
// theme value keeps a longer fade under Reduce Motion).
await page.addStyleTag({
content: ':root, [popover] { --duration-fast-max: 400ms !important; }',
});
await tapOpen(page, trigger);
const entrance = await entranceOf(page, layer);
expect(entrance.properties).toEqual(['opacity']);
expect(entrance.duration).toBeGreaterThan(0);
expect(entrance.duration).toBeLessThanOrEqual(0.2);
});
});

test.describe(`${name} under a finger without Reduce Motion`, () => {
test.use(phone);

test('keeps its entrance', async ({page}) => {
await page.emulateMedia({reducedMotion: 'no-preference'});
await page.goto(
`${storybook.origin}/iframe.html?id=${story}&viewMode=story`,
);
await tapOpen(page, trigger);
const entrance = await entranceOf(page, layer);
expect(entrance.properties).toEqual(['opacity', 'transform']);
});
});

test.describe(`${name} under a mouse with Reduce Motion`, () => {
test('still appears at once', async ({page}) => {
await page.emulateMedia({reducedMotion: 'reduce'});
await page.goto(
`${storybook.origin}/iframe.html?id=${story}&viewMode=story`,
);
await page.getByText(trigger, {exact: true}).hover();
const entrance = await entranceOf(page, layer);
expect(entrance.name).toBe('none');
});
});
}
24 changes: 22 additions & 2 deletions packages/core/src/Layer/layerAnimations.stylex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,20 @@ const enterStartRtl = stylex.keyframes({
to: {opacity: 1, transform: 'translateX(0) scale(1)'},
});

// Reduce Motion under a finger: the same entrance without its travel. A touch
// presentation substitutes a cross-fade for motion, never a jump.
const fadeIn = stylex.keyframes({
from: {opacity: 0},
to: {opacity: 1},
});

const animationBase = {
animationDuration: durationVars['--duration-fast-max'],
animationDuration: {
default: durationVars['--duration-fast-max'],
// The cross-fade is 200 ms or less, whatever the theme's duration
// (design:reduced-motion DR2).
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': `min(${durationVars['--duration-fast-max']}, 200ms)`,
},
animationTimingFunction: easeVars['--ease-standard'],
animationFillMode: 'backwards' as const,
};
Expand All @@ -96,20 +108,26 @@ const animationBase = {
*
* Each entry disables its keyframe animation under
* `prefers-reduced-motion: reduce` so the layer appears instantly instead of
* translating/scaling in (infra-6).
* translating/scaling in (infra-6). Under a finger (the primary pointer is
* coarse) it fades in instead, opacity only and in 200 ms or less: on a touch
* screen Reduce Motion replaces motion with a cross-fade rather than a jump
* (design:reduced-motion DR2, DR3), as iOS does for its menus, popovers and
* tips.
*/
export const layerAnimations = stylex.create({
below: {
animationName: {
default: enterBelow,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
},
above: {
animationName: {
default: enterAbove,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
},
Expand All @@ -118,6 +136,7 @@ export const layerAnimations = stylex.create({
default: enterEnd,
':is([dir="rtl"] *)': enterEndRtl,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
},
Expand All @@ -126,6 +145,7 @@ export const layerAnimations = stylex.create({
default: enterStart,
':is([dir="rtl"] *)': enterStartRtl,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
},
Expand Down
Loading