Skip to content
Draft
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-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] With Reduce Motion on, a layer that opens under a finger fades in over 200 ms on easeInOut, the motion foundation's Reduce Motion values, instead of the theme's fast duration and curve. Under a mouse it still appears at once.

@vjeux
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ async function entranceOf(page: Page, selector: string) {
return page.evaluate(sel => {
const layer = document.querySelector<HTMLElement>(`${sel}:popover-open`);
if (layer == null) {
return {name: 'missing', properties: [], duration: NaN};
return {name: 'missing', properties: [], duration: NaN, easing: ''};
}
const computed = getComputedStyle(layer);
const name = computed.animationName;
// Seconds; the first animation's duration.
// Seconds; the first animation's duration and curve.
const duration = parseFloat(computed.animationDuration.split(',')[0]);
const easing = computed.animationTimingFunction.split(/,(?![^(]*\))/)[0];
const properties = new Set<string>();
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList;
Expand All @@ -93,7 +94,7 @@ async function entranceOf(page: Page, selector: string) {
};
visit(rules);
}
return {name, properties: Array.from(properties).sort(), duration};
return {name, properties: Array.from(properties).sort(), duration, easing};
}, selector);
}

Expand Down Expand Up @@ -123,6 +124,9 @@ for (const {name, story, trigger, layer} of cases) {
// design:reduced-motion DR2: a cross-fade of 200 ms or less.
expect(entrance.duration).toBeGreaterThan(0);
expect(entrance.duration).toBeLessThanOrEqual(0.2);
// The motion foundation's cross-fade: Core Animation's easeInOut,
// cubic-bezier(0.42, 0, 0.58, 1), which the engine names ease-in-out.
expect(entrance.easing).toBe('ease-in-out');
Comment on lines +127 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chromium reports this value as cubic-bezier(0.42, 0, 0.58, 1) (computed on a Storybook dev server, and the core package's dist/astryx.css ships it that way); it reads ease-in-out here only because the production Storybook build rewrites it. If the curve stays, accept both spellings and say the build, not the engine, renames it. The same applies at line 153.

});
});

Expand All @@ -144,6 +148,9 @@ for (const {name, story, trigger, layer} of cases) {
expect(entrance.properties).toEqual(['opacity']);
expect(entrance.duration).toBeGreaterThan(0);
expect(entrance.duration).toBeLessThanOrEqual(0.2);
// The motion foundation's cross-fade: Core Animation's easeInOut,
// cubic-bezier(0.42, 0, 0.58, 1), which the engine names ease-in-out.
expect(entrance.easing).toBe('ease-in-out');
});
});

Expand All @@ -167,6 +174,10 @@ for (const {name, story, trigger, layer} of cases) {
await page.goto(
`${storybook.origin}/iframe.html?id=${story}&viewMode=story`,
);
// A fine primary pointer in this context.
expect(
await page.evaluate(() => matchMedia('(pointer: coarse)').matches),
).toBe(false);
await page.getByText(trigger, {exact: true}).hover();
const entrance = await entranceOf(page, layer);
expect(entrance.name).toBe('none');
Expand Down
40 changes: 25 additions & 15 deletions packages/core/src/Layer/layerAnimations.stylex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,22 @@ const fadeIn = stylex.keyframes({
to: {opacity: 1},
});

// Reduce Motion follows the motion foundation's policy
// (design:reduced-motion): under a coarse pointer an opacity-only fade of
// 200 ms on easeInOut that no theme value lengthens, under any other pointer
// no animation. The media keys stay literal, spelled as the policy's queries
// (a test pins them): the StyleX build gives an imported at-rule key another
// priority. The values become `reducedMotionFadeConsts` once it is on main.
const animationBase = {
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)`,
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': '200ms',
},
animationTimingFunction: {
default: easeVars['--ease-standard'],
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)':
'cubic-bezier(0.42, 0, 0.58, 1)',
Comment on lines +103 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This curve is the open question for an owner: design:reduced-motion DR2 bounds the touch cross-fade at 200 ms, opacity only, and names no curve, while main's layer fade uses the theme's --ease-standard. Whether core's reduced-motion touch cross-fade is a fixed 200 ms on easeInOut whatever the theme (the policy proposed in #8056) needs an engineering owner's decision.

},
animationTimingFunction: easeVars['--ease-standard'],
animationFillMode: 'backwards' as const,
};

Expand All @@ -106,27 +114,27 @@ const animationBase = {
* Keyed by LayerPlacement ('above' | 'below' | 'start' | 'end')
* for easy lookup: `layerAnimations[placement]`.
*
* Each entry disables its keyframe animation under
* `prefers-reduced-motion: reduce` so the layer appears instantly instead of
* 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.
* Under `prefers-reduced-motion: reduce` each entry drops its travel and
* scale. Under a finger (the primary pointer is coarse) the layer fades in,
* opacity only, in 200 ms on easeInOut: on a touch screen Reduce Motion
* replaces motion with a cross-fade rather than a jump; under any other
* pointer it appears at once (design:reduced-motion DR1 to DR4).
*/
export const layerAnimations = stylex.create({
below: {
animationName: {
default: enterBelow,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))':
'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 (not (pointer: coarse))':
'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
Expand All @@ -135,7 +143,8 @@ export const layerAnimations = stylex.create({
animationName: {
default: enterEnd,
':is([dir="rtl"] *)': enterEndRtl,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))':
'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
Expand All @@ -144,7 +153,8 @@ export const layerAnimations = stylex.create({
animationName: {
default: enterStart,
':is([dir="rtl"] *)': enterStartRtl,
'@media (prefers-reduced-motion: reduce)': 'none',
'@media (prefers-reduced-motion: reduce) and (not (pointer: coarse))':
'none',
'@media (prefers-reduced-motion: reduce) and (pointer: coarse)': fadeIn,
},
...animationBase,
Expand Down
Loading