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,