From f23d41c0cf2f88ef38b81e60f5bd0992d191fa62 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sat, 24 Oct 2026 20:12:03 -0700 Subject: [PATCH] fix(Layer): the touch cross-fade uses the motion foundation's Reduce Motion values Under Reduce Motion the layer entrance now takes the values and media queries the motion foundation's policy uses: under a coarse pointer an opacity-only fade of 200 ms on easeInOut that no theme value lengthens (design:reduced-motion DR2, DR4), under any other pointer no animation (DR3). The queries are spelled as that policy spells them (literal keys, which the StyleX build orders as written), and the values become its shared constants once those are on main. The Chromium contract checks the curve too: ease-in-out, which fails on the theme-timed fade on main. Its mouse case asserts that its context is a fine pointer before it checks anything. --- .../layer-touch-reduced-motion-values.md | 7 ++++ .../LayerReducedMotion.a11y.chromium.spec.ts | 17 ++++++-- .../core/src/Layer/layerAnimations.stylex.ts | 40 ++++++++++++------- 3 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 .changeset/layer-touch-reduced-motion-values.md diff --git a/.changeset/layer-touch-reduced-motion-values.md b/.changeset/layer-touch-reduced-motion-values.md new file mode 100644 index 0000000000000..f235db055609e --- /dev/null +++ b/.changeset/layer-touch-reduced-motion-values.md @@ -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 diff --git a/packages/core/src/Layer/__tests__/LayerReducedMotion.a11y.chromium.spec.ts b/packages/core/src/Layer/__tests__/LayerReducedMotion.a11y.chromium.spec.ts index 0ac8e07239610..a0289fa1b2785 100644 --- a/packages/core/src/Layer/__tests__/LayerReducedMotion.a11y.chromium.spec.ts +++ b/packages/core/src/Layer/__tests__/LayerReducedMotion.a11y.chromium.spec.ts @@ -63,12 +63,13 @@ async function entranceOf(page: Page, selector: string) { return page.evaluate(sel => { const layer = document.querySelector(`${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(); for (const sheet of Array.from(document.styleSheets)) { let rules: CSSRuleList; @@ -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); } @@ -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'); }); }); @@ -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'); }); }); @@ -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'); diff --git a/packages/core/src/Layer/layerAnimations.stylex.ts b/packages/core/src/Layer/layerAnimations.stylex.ts index b624fe6eda379..3734261876293 100644 --- a/packages/core/src/Layer/layerAnimations.stylex.ts +++ b/packages/core/src/Layer/layerAnimations.stylex.ts @@ -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)', }, - animationTimingFunction: easeVars['--ease-standard'], animationFillMode: 'backwards' as const, }; @@ -106,19 +114,18 @@ 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, @@ -126,7 +133,8 @@ export const layerAnimations = stylex.create({ 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, @@ -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, @@ -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,