diff --git a/CHANGELOG.md b/CHANGELOG.md index af76a49d..71d6f81d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). #### Added +- `REDUCE_GATED_SCRUB` (new rule category `REDUCED_MOTION`, severity `warning`): a `viewProgress` / `pointerMove` interaction or effect gated on a `prefers-reduced-motion: reduce` condition can never run, since the runtime cancels scrubs under `reduce` regardless of conditions. A `no-preference` gate is not reported — it is redundant, not dead - Plugin fields: `$`-prefixed keys on interactions and effects are accepted; every other unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS` (#275) #### Changed @@ -85,11 +86,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). #### Added +- `Interact.reducedMotion` — a read-only static getter resolving to `Interact.forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches`, and the single source of truth the handlers consult. Returns `false` where there is no `window`/`matchMedia` (SSR, JSDOM) - Generic plugin bridge: `Interact.use(name, plugin)` registers a plugin, and a `$` field on an interaction or effect (#275) - `generate()` accepts a `plugins` option — a map of plugin name → build-time style generator (#275) #### Changed +- **Reduced motion is now detected and enforced by default.** `prefers-reduced-motion: reduce` is respected with no setup, and enforcement moved into the CSS that `generate()` emits — so it also applies with JS disabled, under SSR, and when the visitor changes the OS setting mid-session. Previously the only reduced-motion signal was an explicit `Interact.forceReducedMotion = true`, and even that was bypassed for any effect whose CSS had been pre-generated. **Pages that animate today for visitors who prefer reduced motion will stop.** To restore the old behavior, set `Interact.forceReducedMotion = false` before `Interact.create()`. Per effect kind: time effects (including `iterations: Infinity`) collapse to a `1ms` single iteration with no delay; state effects keep the state and drop the tween; `viewProgress` and `pointerMove` effects are cancelled. Nothing is suppressed by name, so a collapsed entrance still completes and can never be stranded behind its own FOUC hiding rule +- `Interact.forceReducedMotion` is now `boolean | undefined` and defaults to `undefined` (was `boolean`, default `false`). It is an **override**: `undefined` follows `prefers-reduced-motion`, `true` forces reduced motion on, `false` forces motion on. `undefined` is falsy, so `if (Interact.forceReducedMotion)` and `!Interact.forceReducedMotion` are unaffected; only an explicit `=== false` comparison changes meaning. Setting it suppresses the preference-change listener, so it must be assigned before `Interact.create()` +- An effect (or interaction) whose `conditions` include a `prefers-reduced-motion` media condition is exempt from the automatic collapse and runs exactly as authored — this is how a gentler alternative is expressed, and it exempts only that effect. A `viewProgress` / `pointerMove` interaction gated on `reduce` never runs in either path, so a scrub's alternative must use a time-based trigger +- A `viewEnter` entrance's FOUC hiding rule is now gated on the union of its interaction's and its effect's conditions. Previously an interaction-level condition left the rule unconditional, so a gated entrance (e.g. `conditions: ['desktop']`) could leave its element permanently hidden wherever the condition did not match - `generate(config, options?)`: the second argument now accepts an options bag — `{ useFirstChild?, plugins? }` — exported as the `GenerateOptions` (#275) - CSS property names may be authored in either camelCase or kebab-case in `transition.styleProperties`, `transitionProperties` and `keyframeEffect.keyframes`; state-effect properties are normalized to kebab-case for the generated CSS (state rules and the `transition:` shorthand) and keyframes to camelCase for WAAPI diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index 9ebd4fe1..c4e31501 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -82,7 +82,7 @@ type ValidationError = { code: string; // domain code — see the catalogue below message: string; // human-readable description path: (string | number)[]; // e.g. ['interactions', 0, 'effects', 0, 'duration'] - severity: 'error' | 'warning'; + severity: 'error' | 'warning' | 'info'; hint?: string; // optional remediation hint (reserved; not currently populated) }; ``` @@ -129,10 +129,10 @@ const ExperienceSchema = z.object({ ## Severity model -Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'` remains. +Every issue is `'error'`, `'warning'` or `'info'`. `valid` is `true` **iff** no `'error'` remains. - **`strict: true`** promotes all remaining issues to `'error'`. -- **`severityOverrides`** is keyed by **rule category**, and only these three categories are registered/overridable: +- **`severityOverrides`** is keyed by **rule category**, and only these categories are registered/overridable: | Rule category | Covers codes | Default severity | | ------------------------ | --------------------------------------------------------------------------- | ---------------- | @@ -145,10 +145,11 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'` | `ANIMATION_END_GRAPH` | `ANIMATION_END_SELF_REFERENCE`, `ANIMATION_END_CYCLE` | warning | | `ELEMENT_SELECTION` | `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER`, `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | warning | | `STATE_EFFECT` | `EMPTY_STYLE_PROPERTIES`, `STATE_REMOVE_WITHOUT_EFFECT_ID` | warning | -| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH` | info | +| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH`, `RECOMMENDED_FILL_BACKWARDS` | info | | `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning | | `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | | `VIEW_INSET` | `INVALID_INSET` | warning | +| `REDUCED_MOTION` | `REDUCE_GATED_SCRUB` | warning | Set a category to `'off'` to drop those issues, or `'warning'` / `'error'` to set their severity. **All other codes** (every `SCHEMA_*`, numeric, effect-source, and referential code) are not in a category and **cannot** be silenced or re-leveled via `severityOverrides` — they always emit at their built-in severity. Precedence: `'off'` first (drops the issue), then a `'warning'`/`'error'` override, then `strict` (forces the rest to `'error'`). @@ -194,26 +195,27 @@ The single source of truth for every code the validator emits. The agent-facing These encode statically-detectable authoring pitfalls from the trigger rule files. Each belongs to a [rule category](#severity-model), so set the category to `'off'` to silence it or `'error'` to make it fail `valid`. -| Code | Trigger | Rule category | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` | -| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` | -| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` | -| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` | -| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` | -| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` | -| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` | -| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` | -| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` | -| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` | -| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` | -| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` | -| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` | -| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` | -| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` | -| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | -| `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` | -| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` | +| Code | Trigger | Rule category | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | +| `UNUSED_EFFECT` | A `config.effects` entry is never referenced. | `UNUSED_DEFINITION` | +| `UNUSED_SEQUENCE` | A `config.sequences` entry is never referenced. | `UNUSED_DEFINITION` | +| `UNUSED_CONDITION` | A `config.conditions` entry is never referenced. | `UNUSED_DEFINITION` | +| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` | +| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` | +| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` | +| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` | +| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` | +| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` | +| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` | +| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` | +| `EMPTY_STYLE_PROPERTIES` | A state effect's `transition.styleProperties` / `transitionProperties` is `[]` (toggles nothing). | `STATE_EFFECT` | +| `STATE_REMOVE_WITHOUT_EFFECT_ID` | `stateAction: 'remove'` with no `effectId` to pair with a matching `'add'`. | `STATE_EFFECT` | +| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` | +| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` | +| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | +| `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` | +| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` | +| `REDUCE_GATED_SCRUB` | A `viewProgress`/`pointerMove` interaction or effect gated on `(prefers-reduced-motion: reduce)` — scrubs are cancelled under `reduce`, so it can never run. | `REDUCED_MOTION` | ## Usage recipes diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index ea5a6a7b..e6fe12cd 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -33,6 +33,7 @@ const RULE_CODE_MAP: Record = { POINTER_AXIS_IGNORED: 'POINTER_AXIS', INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME', INVALID_INSET: 'VIEW_INSET', + REDUCE_GATED_SCRUB: 'REDUCED_MOTION', }; export function finalize( diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index c9c6986d..973152a6 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -17,6 +17,7 @@ import { checkStateRemoveWithoutEffectId, } from './partialData'; import { checkRecommendedFill } from './recommendedPatterns'; +import { checkReduceGatedScrub } from './reducedMotion'; import { findAnimationEndWarnings } from './animationEndGraph'; // Single traversal of top-level registry effects/sequences and per-interaction @@ -54,12 +55,21 @@ export function walkConfig(config: AnyConfig, visitors: Visitors): void { // Collect every warning/info-level semantic issue (consumed by `transform`). export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { const warnings: SemanticIssue[] = []; + const configConditions = config.conditions ?? {}; walkConfig(config, { onInteraction: (path, interaction) => { warnings.push(...checkListItemSelectorWithoutContainer(path, interaction)); warnings.push(...checkRedundantSelector(path, interaction)); warnings.push(...checkInvalidInset(path, interaction)); + warnings.push( + ...checkReduceGatedScrub( + path, + interaction.conditions, + configConditions, + interaction.trigger, + ), + ); }, // checks that only look at depth>1 properties in effects/sequences do not need resolving onEffect: (path, effect, isTopLevel, owner) => { @@ -78,6 +88,9 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { warnings.push(...checkRecommendedFill(path, resolvedEffect, owner)); warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner)); warnings.push(...checkCSSPropertyNames(path, effect)); + warnings.push( + ...checkReduceGatedScrub(path, resolvedEffect.conditions, configConditions, owner?.trigger), + ); }, onSequence: (path, sequence, isTopLevel, owner) => { const { sequenceId } = sequence; diff --git a/packages/interact-validate/src/semantic/reducedMotion.ts b/packages/interact-validate/src/semantic/reducedMotion.ts new file mode 100644 index 00000000..ef3a7c47 --- /dev/null +++ b/packages/interact-validate/src/semantic/reducedMotion.ts @@ -0,0 +1,40 @@ +import type { Path, SemanticIssue, AnyCondition } from '../types'; + +const MOTION_PREFERENCE_FEATURE = 'prefers-reduced-motion'; +const NO_PREFERENCE = new RegExp(`${MOTION_PREFERENCE_FEATURE}\\s*:\\s*no-preference`); +const SCRUB_TRIGGERS = ['viewProgress', 'pointerMove']; + +// `(prefers-reduced-motion: no-preference)` gates on motion being allowed, which is what the +// runtime already does for a scrub — redundant, not dead. Every other spelling of the feature +// (`: reduce`, or the boolean `(prefers-reduced-motion)`) selects the reduce side. +// `not (prefers-reduced-motion: no-preference)` reads as no-preference here — a deliberate +// false negative, cheaper than the false positives a looser test would produce. +function gatesOnReduce(predicate: string): boolean { + return predicate.includes(MOTION_PREFERENCE_FEATURE) && !NO_PREFERENCE.test(predicate); +} + +export function checkReduceGatedScrub( + path: Path, + conditions: string[] | undefined, + configConditions: Record, + trigger?: string, +): SemanticIssue[] { + if (!trigger || !SCRUB_TRIGGERS.includes(trigger) || !conditions) return []; + + const index = conditions.findIndex((conditionId) => { + const condition = configConditions[conditionId]; + return condition?.type === 'media' && gatesOnReduce(condition.predicate); + }); + + if (index === -1) return []; + + return [ + { + code: 'custom', + params: { domainCode: 'REDUCE_GATED_SCRUB' }, + path: [...path, 'conditions', index], + message: `Condition "${conditions[index]}" gates a \`${trigger}\` scrub on reduced motion, so it can never run — scrubbed effects are cancelled under \`(prefers-reduced-motion: reduce)\` whatever their conditions say. Give the reduced-motion alternative a time-based trigger such as \`viewEnter\`, or express it as a plain CSS rule.`, + severity: 'warning', + }, + ]; +} diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index 2d9c6140..4d285f2f 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -69,9 +69,15 @@ export type AnyInteraction = { conditions?: string[]; }; +export type AnyCondition = { + type: string; + predicate: string; +}; + export type AnyConfig = { effects?: Record; sequences?: Record; + conditions?: Record; interactions: AnyInteraction[]; }; diff --git a/packages/interact-validate/test/rules/reduceGatedScrub.spec.ts b/packages/interact-validate/test/rules/reduceGatedScrub.spec.ts new file mode 100644 index 00000000..76d1b7a5 --- /dev/null +++ b/packages/interact-validate/test/rules/reduceGatedScrub.spec.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +// A scrub interaction or effect gated on `(prefers-reduced-motion: reduce)` can never run: +// `generate()` gates the source's `view-timeline` on `no-preference` regardless of +// conditions, and the handlers early-return on `Interact.reducedMotion` regardless of conditions. + +const CODE = 'REDUCE_GATED_SCRUB'; + +const REDUCE = { type: 'media' as const, predicate: '(prefers-reduced-motion: reduce)' }; +const NO_PREFERENCE = { + type: 'media' as const, + predicate: '(prefers-reduced-motion: no-preference)', +}; +const DESKTOP = { type: 'media' as const, predicate: '(min-width: 900px)' }; + +const scrubEffect = { + keyframeEffect: { name: 'parallax', keyframes: [{ transform: 'translateY(0)' }] }, + fill: 'both' as const, + rangeStart: { name: 'cover' as const }, + rangeEnd: { name: 'cover' as const }, +}; + +describe('reduceGatedScrub — REDUCE_GATED_SCRUB', () => { + it('warns for a viewProgress interaction gated on reduce', () => { + const result = validateInteractConfig({ + conditions: { 'motion-reduced': REDUCE }, + interactions: [ + { + key: 'hero', + trigger: 'viewProgress', + conditions: ['motion-reduced'], + effects: [scrubEffect], + }, + ], + }); + const err = result.errors.find((e) => e.code === CODE); + expect(err).toBeDefined(); + expect(err?.severity).toBe('warning'); + expect(err?.path).toEqual(['interactions', 0, 'conditions', 0]); + expect(err?.message).toContain('viewEnter'); + }); + + it('warns for a pointerMove effect gated on reduce', () => { + const result = validateInteractConfig({ + conditions: { calm: REDUCE }, + interactions: [ + { + key: 'card', + trigger: 'pointerMove', + params: { hitArea: 'self' }, + effects: [ + { + keyframeEffect: { name: 'tilt', keyframes: [{ transform: 'rotate(2deg)' }] }, + fill: 'both', + selector: '.inner', + conditions: ['calm'], + }, + ], + }, + ], + }); + const err = result.errors.find((e) => e.code === CODE); + expect(err).toBeDefined(); + expect(err?.path).toEqual(['interactions', 0, 'effects', 0, 'conditions', 0]); + }); + + it('points at the offending condition when it is not the first one', () => { + const result = validateInteractConfig({ + conditions: { desktop: DESKTOP, 'motion-reduced': REDUCE }, + interactions: [ + { + key: 'hero', + trigger: 'viewProgress', + conditions: ['desktop', 'motion-reduced'], + effects: [scrubEffect], + }, + ], + }); + expect(result.errors.find((e) => e.code === CODE)?.path).toEqual([ + 'interactions', + 0, + 'conditions', + 1, + ]); + }); + + it('treats a bare `(prefers-reduced-motion)` predicate as gating on reduce', () => { + const result = validateInteractConfig({ + conditions: { any: { type: 'media', predicate: '(prefers-reduced-motion)' } }, + interactions: [ + { key: 'hero', trigger: 'viewProgress', conditions: ['any'], effects: [scrubEffect] }, + ], + }); + expect(result.errors.some((e) => e.code === CODE)).toBe(true); + }); + + it('resolves conditions from the referenced registry effect', () => { + const result = validateInteractConfig({ + conditions: { 'motion-reduced': REDUCE }, + effects: { calmScroll: { ...scrubEffect, conditions: ['motion-reduced'] } }, + interactions: [ + { key: 'hero', trigger: 'viewProgress', effects: [{ effectId: 'calmScroll' }] }, + ], + }); + expect(result.errors.some((e) => e.code === CODE)).toBe(true); + }); + + it('is silenceable and escalatable through the REDUCED_MOTION category', () => { + const config = { + conditions: { 'motion-reduced': REDUCE }, + interactions: [ + { + key: 'hero', + trigger: 'viewProgress', + conditions: ['motion-reduced'], + effects: [scrubEffect], + }, + ], + }; + expect( + validateInteractConfig(config, { + severityOverrides: { REDUCED_MOTION: 'off' }, + }).errors.filter((e) => e.code === CODE), + ).toHaveLength(0); + + const escalated = validateInteractConfig(config, { + severityOverrides: { REDUCED_MOTION: 'error' }, + }); + expect(escalated.errors.find((e) => e.code === CODE)?.severity).toBe('error'); + expect(escalated.valid).toBe(false); + }); + + describe('no warning for the documented valid patterns', () => { + it('does not warn for a no-preference gate — redundant, not dead', () => { + const result = validateInteractConfig({ + conditions: { 'motion-ok': NO_PREFERENCE }, + interactions: [ + { + key: 'hero', + trigger: 'viewProgress', + conditions: ['motion-ok'], + effects: [scrubEffect], + }, + ], + }); + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for a non-motion condition on a scrub', () => { + const result = validateInteractConfig({ + conditions: { desktop: DESKTOP }, + interactions: [ + { key: 'hero', trigger: 'viewProgress', conditions: ['desktop'], effects: [scrubEffect] }, + ], + }); + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for a reduce gate on a time-based trigger — that is the supported pattern', () => { + const result = validateInteractConfig({ + conditions: { 'motion-reduced': REDUCE }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + conditions: ['motion-reduced'], + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400, fill: 'both' }], + }, + ], + }); + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for a reduce condition on a `selector` condition', () => { + const result = validateInteractConfig({ + conditions: { + odd: { type: 'selector', predicate: ':nth-of-type(odd)' }, + }, + interactions: [ + { key: 'hero', trigger: 'viewProgress', conditions: ['odd'], effects: [scrubEffect] }, + ], + }); + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + + it('does not warn for a top-level registry effect whose trigger is unknown', () => { + const result = validateInteractConfig({ + conditions: { 'motion-reduced': REDUCE }, + effects: { calm: { namedEffect: { type: 'FadeIn' }, conditions: ['motion-reduced'] } }, + interactions: [ + { key: 'hero', trigger: 'viewEnter', effects: [{ effectId: 'calm', fill: 'both' }] }, + ], + }); + expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0); + }); + }); +}); diff --git a/packages/interact/docs/api/interact-class.md b/packages/interact/docs/api/interact-class.md index 43cd1d2e..f5ebeba3 100644 --- a/packages/interact/docs/api/interact-class.md +++ b/packages/interact/docs/api/interact-class.md @@ -35,6 +35,11 @@ class Interact { static getPlugin(name: string): InteractPlugin | undefined; static getPluginNames(): Set; + // Static properties + static forceReducedMotion?: boolean; // override — default `undefined` + static get reducedMotion(): boolean; // resolved decision — read-only + static allowA11yTriggers: boolean; + // Instance methods init(config: InteractConfig, options?: { useCustomElement?: boolean }): void; destroy(): void; @@ -136,7 +141,7 @@ Configures global settings for the Interact system. - `options.viewEnter` - Optional default partial `ViewEnterParams` (e.g. `threshold`, `inset`, `useSafeViewEnter`) - `options.allowA11yTriggers` - When `true`, `click` and `hover` triggers also respond to keyboard (Enter/Space) and focus -To force reduced motion globally, set `Interact.forceReducedMotion = true` (static property, not via `setup`). +Reduced motion is **not** configured through `setup()` — see [`Interact.forceReducedMotion`](#interactforcereducedmotion) below. **Example:** @@ -301,6 +306,47 @@ if (controller) { - Useful for programmatic element manipulation - Returns the controller that manages the element's interactions +## Static Properties + +### `Interact.forceReducedMotion` + +`boolean | undefined` — an **override** for the detected motion preference. Default `undefined`. + +| Value | Meaning | +| --------------------- | ----------------------------------------------------------- | +| `undefined` (default) | Follow `prefers-reduced-motion`. | +| `true` | Force reduced-motion behavior regardless of the OS setting. | +| `false` | Force motion **on** regardless of the OS setting. | + +```typescript +// Restore pre-2.6.0 behavior — animate for everyone +Interact.forceReducedMotion = false; +``` + +**Details:** + +- Detection needs no setup. Leave this `undefined` unless you are deliberately overriding the user. +- Setting it explicitly **suppresses the preference-change listener**, so the value is read once — assign it before `Interact.create()`. +- Reading it returns the raw override, not the resolved decision. Read `Interact.reducedMotion` for that. + +### `Interact.reducedMotion` + +`boolean`, **read-only** — the resolved decision, and the single source of truth the handlers consult: + +```typescript +Interact.forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches; +``` + +**Details:** + +- Returns `false` when there is no `window` or no `matchMedia` (SSR, JSDOM) — safe by construction, because the reduced-motion rules that `generate()` emits carry the preference themselves, so a server render does not need to know it. +- The `MediaQueryList` is created lazily and cached; `Interact.destroy()` drops the cache. +- This is the JS half only. Reduced motion for CSS-backed effects is enforced by `@media (prefers-reduced-motion: reduce)` rules in the output of [`generate()`](functions.md#generateconfig-options), which is why it also works with JS disabled and reacts to a mid-session change immediately. See the [reduced-motion guide](../guides/conditions-and-media-queries.md#reduced-motion) for the per-effect-kind behavior. + +### `Interact.allowA11yTriggers` + +`boolean`, default `true` — enables the accessibility trigger variants (`interest`, `activate`) and layers keyboard/focus behavior onto `hover` and `click`. Also settable via `Interact.setup({ allowA11yTriggers })`. + ## Instance Methods ### `init(config)` diff --git a/packages/interact/docs/api/types.md b/packages/interact/docs/api/types.md index c05e3807..52e6db96 100644 --- a/packages/interact/docs/api/types.md +++ b/packages/interact/docs/api/types.md @@ -283,7 +283,7 @@ type InteractOptions = { **Properties:** -- `reducedMotion` - Whether reduced motion is enabled (respects `prefers-reduced-motion` or `Interact.forceReducedMotion`) +- `reducedMotion` - Whether reduced motion is in effect. Handlers receive the value of [`Interact.reducedMotion`](interact-class.md#interactreducedmotion), which is `Interact.forceReducedMotion` when set and the `prefers-reduced-motion` media query otherwise. Read at bind time; the CSS that `generate()` emits carries the preference independently. - `targetController` - The controller managing the target element - `selectorCondition` - Optional CSS selector condition for element matching - `allowA11yTriggers` - Whether to enable accessibility triggers (keyboard events) for `click` and `hover` triggers. When `true`, `click` responds to Enter/Space keys and `hover` responds to focus events. Defaults to `true`. @@ -293,7 +293,7 @@ type InteractOptions = { ```typescript // Used internally by handlers const options: InteractOptions = { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, targetController: Interact.getController('my-element'), }; ``` diff --git a/packages/interact/docs/examples/hover-effects.md b/packages/interact/docs/examples/hover-effects.md index ca5d096f..d9a6d09f 100644 --- a/packages/interact/docs/examples/hover-effects.md +++ b/packages/interact/docs/examples/hover-effects.md @@ -892,32 +892,37 @@ const config = { ### Accessibility -```css -/* Respect reduced motion preference */ -@media (prefers-reduced-motion: reduce) { - * { - animation-duration: 0.01ms !important; - transition-duration: 0.01ms !important; - } -} -``` +Interact already collapses hover animations and drops state-transition tweens under +`prefers-reduced-motion: reduce`, so a hover interaction needs **no condition** for the baseline — +and a global CSS reset like `* { animation-duration: 0.01ms !important }` is not needed for +Interact's own effects. See [Reduced Motion](../guides/conditions-and-media-queries.md#reduced-motion). + +Add a gated alternative only when you want a specific calmer look instead of the instant end +state. Gate the alternative alone — the condition exempts that effect from the collapse, and its +un-gated neighbours keep working: ```typescript -// Configuration with reduced motion support const config = { conditions: { - 'motion-ok': { + 'motion-reduced': { type: 'media', - predicate: '(prefers-reduced-motion: no-preference)', + predicate: '(prefers-reduced-motion: reduce)', }, }, interactions: [ { key: 'animated-hover', trigger: 'hover', - conditions: ['motion-ok'], effects: [ - /* complex animations */ + /* complex animation — collapsed automatically under `reduce` */ + { + key: 'animated-hover', + conditions: ['motion-reduced'], + transition: { + duration: 150, + styleProperties: [{ name: 'opacity', value: '0.85' }], + }, + }, ], }, ], diff --git a/packages/interact/docs/guides/conditions-and-media-queries.md b/packages/interact/docs/guides/conditions-and-media-queries.md index e3834b20..8db7641e 100644 --- a/packages/interact/docs/guides/conditions-and-media-queries.md +++ b/packages/interact/docs/guides/conditions-and-media-queries.md @@ -55,6 +55,77 @@ Use container queries to respond to element size: } ``` +## Reduced Motion + +Reduced motion is the one preference Interact acts on **by itself**. It is worth reading before you write a `prefers-reduced-motion` condition, because most of the time you don't need one. + +### What happens without any config + +`Interact.reducedMotion` resolves to `Interact.forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches`, and `generate()` emits `@media (prefers-reduced-motion: reduce)` rules next to the base ones. Because the decision lives in CSS, it also holds with JS disabled, under SSR, and when the user flips the OS setting mid-session. + +| Effect kind | Under `reduce` | What the user sees | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------- | +| Time effect (`viewEnter`, `hover`, `click`, `interest`, `activate`, `animationEnd`) | **Collapsed** — `1ms` duration, `0ms` delay, one iteration | The end state, applied instantly | +| Ongoing time effect (`iterations: Infinity`) | **Collapsed too** — iterations capped at 1 | The end state; no perpetual motion | +| State effect (`transition` / `transitionProperties`) | **Tween dropped, state kept** — the `transition` is declared only under `no-preference` | The state toggles instantly | +| `viewProgress` | **Cancelled** — `view-timeline` is declared only under `no-preference` and the handler early-returns | The element's authored **base style** | +| `pointerMove` | **Cancelled** — the handler early-returns, so the paused CSS animation is never driven | The effect's **first keyframe** | +| `customEffect` | JS only — collapsed to `1ms` with the default `iterations: 1`, dropped when `iterations > 1` | Its end state, or nothing | + +Nothing is ever suppressed by name, so a collapsed entrance still completes its FOUC handshake and can never leave an element permanently hidden. + +### When you still need a condition + +Two cases: + +1. **You want a specific calmer look** rather than the instant end state. +2. **A cancelled scrub leaves the element unusable** — e.g. an `in`-range scroll reveal whose base style is `opacity: 0`. Interact cannot see your stylesheet, so it cannot warn you about this one. + +Gate **only** the alternative. A `prefers-reduced-motion` condition on an effect (or on its interaction) exempts that effect from the automatic collapse — and only that effect, so neighbours on the same target need no changes: + +```typescript +{ + conditions: { + 'motion-reduced': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, + }, + interactions: [ + { + key: 'hero-title', + trigger: 'viewEnter', + effects: [ + // No condition — collapsed automatically under `reduce`. + { + keyframeEffect: { + name: 'fade-move', + keyframes: [ + { opacity: '0', transform: 'translateY(50px) rotate(-2deg)' }, + { opacity: '1', transform: 'translateY(0) rotate(0deg)' }, + ], + }, + duration: 800, + fill: 'backwards', + }, + // The calmer alternative, exempt from the collapse because it names the preference. + { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: '0' }, { opacity: '1' }] }, + duration: 300, + fill: 'backwards', + conditions: ['motion-reduced'], + }, + ], + }, + ], +} +``` + +**A scrub's alternative must use a time-based trigger.** A `viewProgress` or `pointerMove` interaction gated on `(prefers-reduced-motion: reduce)` never runs — the runtime cancels scrubs under `reduce` regardless of conditions, and the generated CSS encodes the same decision. Substitute a `viewEnter` effect, or a plain CSS rule in your own stylesheet. [`@wix/interact-validate`](https://github.com/wix/interact/blob/master/packages/interact-validate/README.md) reports the mistake as `REDUCE_GATED_SCRUB`. + +### The override + +`Interact.forceReducedMotion` overrides detection: `true` forces reduced motion on, `false` forces motion on, `undefined` (the default) follows the OS. Setting it explicitly suppresses the preference-change listener, so assign it **before `Interact.create()`**. See [`Interact.forceReducedMotion`](../api/interact-class.md#interactforcereducedmotion). + +With detection left in place, a mid-session change is picked up as follows: CSS-backed time and state effects follow it immediately (no JS involved), `viewProgress` / `pointerMove` interactions rebind and pick it up, and `customEffect` and other WAAPI-only effects pick it up on their next bind. + ## Cascading of effects Interact allows you to apply multiple effects on the same target and have them cascade, just like they do in CSS. @@ -205,63 +276,48 @@ const responsiveConfig: InteractConfig = { }; ``` -### Accessibility-Aware Animations +### Preference-Aware Animations + +Reduced motion has its own section — see [Reduced Motion](#reduced-motion) — because Interact acts on it automatically. Other user preferences do not, so both sides need gating: ```typescript const accessibleConfig: InteractConfig = { conditions: { - 'motion-ok': { - type: 'media', - predicate: '(prefers-reduced-motion: no-preference)', - }, - 'motion-reduced': { - type: 'media', - predicate: '(prefers-reduced-motion: reduce)', - }, 'high-contrast': { type: 'media', predicate: '(prefers-contrast: high)', }, + 'standard-contrast': { + type: 'media', + predicate: '(prefers-contrast: no-preference)', + }, }, interactions: [ - // Full animation for users who prefer motion { - key: 'hero-title', - trigger: 'viewEnter', - conditions: ['motion-ok'], - params: { threshold: 0.3 }, + key: 'cta', + trigger: 'hover', effects: [ + // Subtle tint at standard contrast. { - key: 'hero-title', - keyframeEffect: { - name: 'fade-move', - keyframes: [ - { opacity: '0', transform: 'translateY(50px) rotate(-2deg)' }, - { opacity: '1', transform: 'translateY(0) rotate(0deg)' }, - ], + key: 'cta', + conditions: ['standard-contrast'], + transition: { + duration: 200, + styleProperties: [{ name: 'background-color', value: 'rgba(0, 90, 200, 0.12)' }], }, - duration: 800, - easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', }, - ], - }, - - // Subtle animation for reduced motion users - { - key: 'hero-title', - trigger: 'viewEnter', - conditions: ['motion-reduced'], - params: { threshold: 0.3 }, - effects: [ + // Solid, unambiguous fill when the user asks for high contrast. { - key: 'hero-title', - keyframeEffect: { - name: 'fade', - keyframes: [{ opacity: '0' }, { opacity: '1' }], + key: 'cta', + conditions: ['high-contrast'], + transition: { + duration: 200, + styleProperties: [ + { name: 'background-color', value: '#0033aa' }, + { name: 'color', value: '#ffffff' }, + ], }, - duration: 300, - easing: 'ease-out', }, ], }, @@ -1011,24 +1067,21 @@ const progressiveConfig: InteractConfig = { ### Accessibility First -Always provide accessible alternatives: +Reduced motion is already handled — see [Reduced Motion](#reduced-motion). Add a condition only for a specific calmer look, or when a cancelled scroll/pointer effect would leave the element hidden. Gate the alternative alone, not the primary effect: ```typescript -// Always provide a motion-safe version { key: 'animated-element', trigger: 'viewEnter', - conditions: ['motion-ok'], - effects: [/* complex animation */] -}, -{ - key: 'animated-element', - trigger: 'viewEnter', - conditions: ['motion-reduced'], - effects: [/* simple fade or no animation */] + effects: [ + /* complex animation — collapsed automatically under `reduce` */, + { conditions: ['motion-reduced'], /* simple fade */ } + ] } ``` +For other preferences — `prefers-contrast`, `prefers-color-scheme` — Interact does nothing on its own, so those genuinely need both sides gated. + ## Debugging Conditions TBD diff --git a/packages/interact/docs/guides/effects-and-animations.md b/packages/interact/docs/guides/effects-and-animations.md index 03e6208e..a97aa677 100644 --- a/packages/interact/docs/guides/effects-and-animations.md +++ b/packages/interact/docs/guides/effects-and-animations.md @@ -586,6 +586,11 @@ Avoid animating: ### Accessibility +Interact collapses time effects to 1ms under `prefers-reduced-motion: reduce` on its own, so the +primary effect below needs no condition. Add a gated alternative only when you want a specific +calmer look — the condition is what exempts that effect from the collapse, and it exempts only +that effect. See [Reduced Motion](conditions-and-media-queries.md#reduced-motion). + ```typescript // Respect user preferences { @@ -594,7 +599,8 @@ Avoid animating: namedEffect: { type: 'FadeIn' }, - duration: 600 + duration: 400, + fill: 'backwards' } }, interactions: [ @@ -603,14 +609,17 @@ Avoid animating: trigger: 'viewEnter', effects: [ { + // No condition — collapsed automatically under `reduce` namedEffect: { type: 'SlideIn' }, - duration: 600 + duration: 600, + fill: 'backwards' }, { + // The calmer alternative — its condition keeps it at its authored duration effectId: 'reduced-motion-entry', - conditions: ['reduced-motion'] // Only animate if user allows motion + conditions: ['reduced-motion'] } ] } diff --git a/packages/interact/docs/guides/sequences.md b/packages/interact/docs/guides/sequences.md index 2c22ddee..6a7be4d4 100644 --- a/packages/interact/docs/guides/sequences.md +++ b/packages/interact/docs/guides/sequences.md @@ -373,6 +373,10 @@ const config: InteractConfig = { ### Sequence with Media-Query Conditions +> The `no-reduced-motion` gate below shows how conditions compose across levels; it is **not** +> required for accessibility. Interact collapses time effects under `reduce` on its own — see +> [Reduced Motion](conditions-and-media-queries.md#reduced-motion). + ```typescript const config: InteractConfig = { conditions: { diff --git a/packages/interact/docs/guides/understanding-triggers.md b/packages/interact/docs/guides/understanding-triggers.md index 106dbd70..a2d11239 100644 --- a/packages/interact/docs/guides/understanding-triggers.md +++ b/packages/interact/docs/guides/understanding-triggers.md @@ -684,7 +684,7 @@ You can combine multiple triggers on the same element for complex interactions: ### Accessibility -1. **Respect `prefers-reduced-motion`** media query +1. **`prefers-reduced-motion` is handled for you** — Interact detects it and collapses time effects, drops state-transition tweens, and cancels `viewProgress`/`pointerMove`. Add a gated alternative only for a specific calmer look, or when a cancelled scrub would leave the element hidden. See [Reduced Motion](conditions-and-media-queries.md#reduced-motion) 2. **Use `activate` instead of `click`** for keyboard accessibility 3. **Use `interest` instead of `hover`** for keyboard accessibility 4. **Ensure click targets are accessible** via keyboard diff --git a/packages/interact/docs/integration/README.md b/packages/interact/docs/integration/README.md index 619e7e7d..8ad93f3e 100644 --- a/packages/interact/docs/integration/README.md +++ b/packages/interact/docs/integration/README.md @@ -127,7 +127,7 @@ Framework-specific integration guides and migration documentation for `@wix/inte ### Implementation - [**CSS Fallbacks**](progressive-enhancement.md#css) - CSS-only alternatives -- [**Reduced Motion**](progressive-enhancement.md#reduced-motion) - Accessibility preferences +- [**Reduced Motion**](../guides/conditions-and-media-queries.md#reduced-motion) - Detected and enforced automatically, in JS and in generated CSS - [**Network Awareness**](progressive-enhancement.md#network) - Adaptive loading ## Browser Support diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index 8367f102..ce57c920 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -22,6 +22,7 @@ Declarative configuration-driven interaction library. Binds animations to trigge - [Animation Payloads](#animation-payloads) - [Sequences](#sequences) - [Conditions](#conditions) +- [Reduced motion](#reduced-motion) - [CSS Generation & FOUC Prevention](#css-generation--fouc-prevention) - [Element Resolution](#element-resolution) - [Plugins](#plugins) @@ -39,7 +40,7 @@ Each item here is CRITICAL — ignoring any of them will break animations. events and flickering. Use `selector` to target a child element, or set the effect's `key` to a different element. - **CRITICAL**: For `pointerMove` trigger MUST AVOID using the same element as both source and target with `hitArea: 'self'` and effects that change size or position (e.g. `transform: translate(…)`, `scale(…)`). The transform shifts the hit area, causing jittery re-entry cycles. Instead, use `selector` to target a child element for the animation. - **CRITICAL — Do NOT guess preset options**: If you don't know the expected type/structure for a `namedEffect` param, omit it — rely on defaults rather than guessing. -- **Reduced motion**: Use conditions to provide gentler alternatives (shorter durations, fewer transforms, no perpetual motion) for users who prefer reduced motion. You can also set `Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches` to force a global reduced-motion behavior programmatically. +- **Reduced motion**: Interact detects `prefers-reduced-motion: reduce` on its own and needs no setup — time effects collapse, state transitions drop their tween, and scroll/pointer effects are cancelled. Do NOT re-implement that. Add a condition-gated alternative only when the cancelled or collapsed result is not good enough — and note that a scrub's alternative MUST use a time-based trigger. See [Reduced motion](#reduced-motion). - **Perspective**: Prefer `transform: perspective(...)` inside keyframes. Use the CSS `perspective` property only when multiple children share the same `perspective-origin`. --- @@ -607,6 +608,75 @@ conditions: { --- +## Reduced motion + +### What Interact does on its own + +Interact respects `prefers-reduced-motion: reduce` with **no configuration**. `Interact.reducedMotion` resolves to `Interact.forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches`, and `generate()` emits `@media (prefers-reduced-motion: reduce)` rules alongside the base ones — so enforcement also works with JS disabled, under SSR, and when the user changes the setting mid-session. + +Do NOT gate every effect behind `(prefers-reduced-motion: no-preference)` "to be safe" — it is redundant noise, and it removes the sensible default below in favor of nothing at all. The defaults per effect kind: + +| Effect kind | Under `reduce` | What the user sees | +| :---------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------- | +| Time effect (`viewEnter`, `hover`, `click`, `interest`, `activate`, `animationEnd`) | **Collapsed** — `1ms` duration, `0ms` delay, one iteration | The end state, applied instantly. Nothing is suppressed, so nothing is left hidden. | +| Ongoing time effect (`iterations: Infinity`) | **Collapsed too** — the same rule caps iterations at 1 | The end state. No perpetual motion. | +| State effect (`transition` / `transitionProperties`) | **Tween dropped, state kept** — the `transition` is declared only under `no-preference` | The state toggles instantly. State is meaning; only the motion goes. | +| `viewProgress` | **Cancelled** — `view-timeline` is declared only under `no-preference`, and the handler early-returns | The element's authored **base style**. | +| `pointerMove` | **Cancelled** — the handler early-returns, so the paused CSS animation is never driven | The effect's **first keyframe**. | +| `customEffect` | JS only — collapsed to `1ms` with the default `iterations: 1`, dropped when `iterations > 1` | Its end state, or nothing. | + +**A scrub is the only kind that can leave a problem.** Time and state effects always land on their end state, so an entrance can never be stranded invisible. A cancelled scrub falls back to a state you authored — and if that state hides, clips, or displaces the element, you must supply an alternative. Interact cannot see your CSS, so it cannot warn you. + +### Writing a reduced-motion alternative + +Only when the default above is not good enough. Gate the alternative on an explicit `prefers-reduced-motion` condition: + +```ts +{ + conditions: { + 'motion-reduced': { type: 'media', predicate: '(prefers-reduced-motion: reduce)' }, + }, + interactions: [ + { + key: 'panel', + trigger: 'viewEnter', + effects: [ + // no condition needed — collapsed automatically under `reduce` + { namedEffect: { type: 'SpinIn' }, duration: 700, fill: 'backwards' }, + // the calmer alternative, exempt from the collapse because it names the preference + { + namedEffect: { type: 'FadeIn' }, + duration: 400, + fill: 'backwards', + conditions: ['motion-reduced'], + }, + ], + }, + ], +} +``` + +Rules for the alternative: + +- It MUST carry a `prefers-reduced-motion` condition. That condition is what exempts it from the automatic collapse, and it exempts **only that effect** — neighbouring effects on the same target need no changes and MUST NOT be gated to make it work. +- The condition may sit on the **interaction** or on the **effect**. Both reach an entrance's FOUC hiding rule, so neither can strand the element. +- **A scrub's alternative MUST use a time-based trigger** (`viewEnter`, or a plain CSS rule of your own). A `viewProgress` / `pointerMove` interaction gated on `reduce` never runs — the runtime cancels scrubs under `reduce` whatever the conditions say. `@wix/interact-validate` reports this as `REDUCE_GATED_SCRUB`. +- Prefer opacity/blur-based presets for the alternative (`FadeIn`, `BlurIn`). Common swaps: `BounceIn`/`SpinIn`/`ArcIn`/`FlipIn`/`TurnIn` → `FadeIn`; `ParallaxScroll` → static; mouse presets → static. + +### The override, and when a change takes effect + +`Interact.forceReducedMotion` is the escape hatch, not the mechanism: + +| Value | Meaning | +| :-------------------- | :---------------------------------------------------------- | +| `undefined` (default) | Follow `prefers-reduced-motion`. | +| `true` | Force reduced-motion behavior regardless of the OS setting. | +| `false` | Force motion **on** regardless of the OS setting. | + +Setting it explicitly makes the decision read-once, so assign it **before `Interact.create()`**. With it left `undefined`, a mid-session preference change is picked up as follows: CSS-backed time and state effects follow it immediately (pure CSS, no JS involved); `viewProgress` / `pointerMove` interactions rebind and pick it up; `customEffect` and other WAAPI-only effects pick it up on their next bind. + +--- + ## CSS Generation & FOUC Prevention ### Generating CSS @@ -758,7 +828,8 @@ Default split wrapper classes: `.split-c` (chars), `.split-w` (words), `.split-l | `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | | `Interact.registerEffects(presets)` | Register named effect presets. MUST be called before `generate()` and `create`. | | `Interact.destroy()` | Tear down all instances. Call on unmount or route change to prevent memory leaks. | -| `Interact.forceReducedMotion` | `boolean` (default: `false`) — force reduced-motion behavior regardless of OS setting. | +| `Interact.forceReducedMotion` | `boolean \| undefined` (default: `undefined`) — override the detected preference. `undefined` follows `prefers-reduced-motion`; `true` forces reduced motion on, `false` forces motion on. Set before `create()`. See [Reduced motion](#reduced-motion). | +| `Interact.reducedMotion` | `boolean`, **read-only** — the resolved decision: `forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches`. | | `Interact.allowA11yTriggers` | `boolean` (default: `true`) — enable accessibility trigger variants (`interest`, `activate`). | | `Interact.setup(options)` | Configure global options for scroll, pointer, and viewEnter systems. Call before `create`. See options below. | | `Interact.use(name, plugin)` | Register an external plugin by name (see [Plugins](#plugins)). Call before `create`. | diff --git a/packages/interact/rules/integration.md b/packages/interact/rules/integration.md index 664bb754..820308cc 100644 --- a/packages/interact/rules/integration.md +++ b/packages/interact/rules/integration.md @@ -344,12 +344,13 @@ See [viewenter.md](./viewenter.md) for full details. Each `Interact.create(config)` call returns an instance. Keep a reference if you need to add/remove elements dynamically (vanilla JS) or to destroy a specific instance. Call `Interact.destroy()` to tear down all instances at once (e.g. on page navigation). -| Method / Property | Description | -| :---------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generate(config, options?)` | Produce complete CSS for all interactions. Call at build/generation time; embed in HTML. `options` = `{ useFirstChild?, plugins? }`, or a bare boolean for legacy `useFirstChild`. | -| `Interact.create(config)` | Initialize with a config. Returns the instance. Multiple configs create separate instances. | -| `Interact.registerEffects(presets)` | Register named effect presets before `generate()` and `create`. Required for `namedEffect`. | -| `Interact.destroy()` | Tear down all instances. | -| `Interact.forceReducedMotion` | `boolean` — force reduced-motion behavior regardless of OS setting. Default: `false`. | -| `Interact.allowA11yTriggers` | `boolean` — enable accessibility triggers (`interest`, `activate`). Default: `false`. | -| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params. Call before `create`. | +| Method / Property | Description | +| :---------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `generate(config, options?)` | Produce complete CSS for all interactions. Call at build/generation time; embed in HTML. `options` = `{ useFirstChild?, plugins? }`, or a bare boolean for legacy `useFirstChild`. | +| `Interact.create(config)` | Initialize with a config. Returns the instance. Multiple configs create separate instances. | +| `Interact.registerEffects(presets)` | Register named effect presets before `generate()` and `create`. Required for `namedEffect`. | +| `Interact.destroy()` | Tear down all instances. | +| `Interact.forceReducedMotion` | `boolean \| undefined` — override the detected preference. Default `undefined` = follow `prefers-reduced-motion`; `true` forces reduced motion on, `false` forces motion on. Set before `create()`. | +| `Interact.reducedMotion` | `boolean`, read-only — the resolved decision. Interact detects and enforces reduced motion on its own; see [full-lean.md § Reduced motion](https://wix.github.io/interact/rules/full-lean.md#reduced-motion). | +| `Interact.allowA11yTriggers` | `boolean` — enable accessibility triggers (`interest`, `activate`). Default: `false`. | +| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params. Call before `create`. | diff --git a/packages/interact/rules/pointermove.md b/packages/interact/rules/pointermove.md index d471ea29..4f5ef8b8 100644 --- a/packages/interact/rules/pointermove.md +++ b/packages/interact/rules/pointermove.md @@ -9,6 +9,7 @@ These rules help generate pointer-driven interactions using `@wix/interact`. Poi - [Progress Object Structure](#progress-object-structure) - [Centering with `centeredToTarget`](#centering-with-centeredtotarget) - [Device Conditions](#device-conditions) +- [Reduced Motion](#reduced-motion) - [Rule 1: namedEffect](#rule-1-namedeffect) - [Rule 2: keyframeEffect with Single Axis](#rule-2-keyframeeffect-with-single-axis) - [Rule 3: Two keyframeEffects with Two Axes and `composite`](#rule-3-two-keyframeeffects-with-two-axes-and-composite) @@ -98,6 +99,19 @@ For devices with dynamic viewport sizes (e.g. mobile browsers where the address --- +## Reduced Motion + +`pointerMove` effects are **cancelled**, not collapsed, under `prefers-reduced-motion: reduce` — there is no meaningful slow-down of a pointer-tracked timeline. `addPointerMoveHandler` early-returns on `Interact.reducedMotion`, so no `Pointer` instance and no scene are created and nothing is ever driven. This is automatic; do not add a condition to achieve it. + +**The authoring consequence:** a `keyframeEffect` is pre-generated as a paused CSS animation, so with nothing driving it the element holds the effect's **first keyframe**. `namedEffect` and `customEffect` on `pointerMove` produce no CSS at all (their 2D progress cannot be expressed as a CSS animation), so those elements render at their authored base style. Either way, if that resting state hides or displaces the element, supply an alternative: + +- The alternative MUST use a **time-based trigger** such as `viewEnter`, or be a plain CSS rule in your own stylesheet. A `pointerMove` interaction gated on `(prefers-reduced-motion: reduce)` never runs — `@wix/interact-validate` reports that mistake as `REDUCE_GATED_SCRUB`. +- The `(hover: hover)` condition above is a separate concern and still worth having: it covers touch-only devices, which is a different exclusion from the motion preference. + +See [full-lean.md § Reduced motion](https://wix.github.io/interact/rules/full-lean.md#reduced-motion) for the per-effect-kind table and the general alternative pattern. + +--- + ## Rule 1: namedEffect Use pre-built mouse presets from `@wix/motion-presets` that handle 2D mouse tracking internally. Mouse presets are preferred over `keyframeEffect` for 2D effects. diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 0b0e6ab7..3a14f43d 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -56,7 +56,7 @@ if (!result.valid) { - `valid` is `true` when no remaining issue has severity `'error'`. **Warnings alone do not make `valid: false`.** - `errors` holds **all** surfaced issues — both `'error'` and `'warning'` severities. Filter on `severity` to separate them. - Issues are sorted lexicographically by `path`. -- Validation runs in two layers: a **structural** zod parse first (produces `SCHEMA_*` and numeric/threshold codes); if that succeeds, **referential + semantic** checks run (dangling references, unused definitions, duplicate keyframe names, media-query syntax, and the rule-derived semantic warnings — same-element re-trigger, hit-area shift, scroll-preset `range`, `animationEnd` graph cycles, element-selection coherence, `fill`/`inset` nudges, CSS property names that are neither camelCase nor kebab-case). If the structural parse fails, the semantic layer is skipped. +- Validation runs in two layers: a **structural** zod parse first (produces `SCHEMA_*` and numeric/threshold codes); if that succeeds, **referential + semantic** checks run (dangling references, unused definitions, duplicate keyframe names, media-query syntax, and the rule-derived semantic warnings — same-element re-trigger, hit-area shift, scroll-preset `range`, `animationEnd` graph cycles, element-selection coherence, `fill`/`inset` nudges, CSS property names that are neither camelCase nor kebab-case, scrub effects gated on reduced motion). If the structural parse fails, the semantic layer is skipped. ### assertValidInteractConfig @@ -93,7 +93,7 @@ type ValidationError = { code: string; // domain code — see catalogue below message: string; // human-readable description path: (string | number)[]; // path to the offending value, e.g. ['interactions', 0, 'effects', 0] - severity: 'error' | 'warning'; + severity: 'error' | 'warning' | 'info'; hint?: string; // optional remediation hint (reserved; not currently populated) }; ``` @@ -122,7 +122,7 @@ try { ## Severity, strict, and overrides -Severity is one of `'error' | 'warning'`. There are exactly two levers: +Severity is one of `'error' | 'warning' | 'info'`. There are exactly two levers: 1. **`strict: true`** — promotes all remaining issues to `'error'`, so any warning fails the config. 2. **`severityOverrides`** — keyed by **rule category**, not by individual code. Only the following categories are registered and overridable: @@ -138,10 +138,11 @@ Severity is one of `'error' | 'warning'`. There are exactly two levers: | `ANIMATION_END_GRAPH` | `ANIMATION_END_SELF_REFERENCE`, `ANIMATION_END_CYCLE` | warning | | `ELEMENT_SELECTION` | `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER`, `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | warning | | `STATE_EFFECT` | `EMPTY_STYLE_PROPERTIES`, `STATE_REMOVE_WITHOUT_EFFECT_ID` | warning | -| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH` | info | +| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH`, `RECOMMENDED_FILL_BACKWARDS` | info | | `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning | | `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | | `VIEW_INSET` | `INVALID_INSET` | warning | +| `REDUCED_MOTION` | `REDUCE_GATED_SCRUB` | warning | For each category, set `'off'` to drop those issues entirely, `'warning'` / `'error'` to set their severity: @@ -237,6 +238,7 @@ Statically-detectable authoring pitfalls lifted from the trigger rule files. Eac | `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` | | `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both casings are accepted; this one cannot be normalized). | `CSS_PROPERTY_NAME` | | `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 whitespace-separated CSS lengths/percentages. | `VIEW_INSET` | +| `REDUCE_GATED_SCRUB` | A `viewProgress`/`pointerMove` interaction or effect is gated on a `prefers-reduced-motion: reduce` condition, so it can never run. | `REDUCED_MOTION` | --- @@ -306,8 +308,9 @@ The validator is **static**. It cannot see the DOM, the browser, or the preset r - **Documented authoring pitfalls with no static signal**, which the trigger rule files cover instead: - `overflow: hidden` ancestors breaking `viewProgress` → [viewprogress.md](https://wix.github.io/interact/rules/viewprogress.md). - Whether a `keyframeEffect`/`namedEffect` actually changes size/position when it cannot be introspected (e.g. `namedEffect` options) — only inline `keyframeEffect` transforms are scanned for `HIT_AREA_SHIFT`. - - Reduced-motion alternatives, perspective usage, and other authoring guidance with no static signal. + - **Whether a reduced-motion alternative is needed at all.** `REDUCE_GATED_SCRUB` catches the one reduced-motion mistake that is visible in the config — a scrub gated on `reduce`, which can never run. It cannot tell you that an alternative is _missing_: under `reduce` a suppressed `viewProgress` effect renders the element at its authored **base style** and a suppressed `pointerMove` effect at its **first keyframe**, and whether either state is acceptable depends on your own CSS, which is not in the `InteractConfig`. See [full-lean.md § Reduced motion](https://wix.github.io/interact/rules/full-lean.md#reduced-motion). + - Perspective usage and other authoring guidance with no static signal. -> **Note:** Several pitfalls that previously had "no static signal" are now flagged as **warnings** (see the rule-derived semantic warnings above): same source+target on `viewEnter` with a non-`once` `triggerType` (`SAME_ELEMENT_RETRIGGER`), hit-area shift from inline `keyframeEffect` transforms on `hover`/`pointerMove` (`HIT_AREA_SHIFT`), scroll presets missing/invalid `range` (`SCROLL_PRESET_*`), and missing `fill: 'both'` (`RECOMMENDED_FILL_BOTH`). These are heuristic and conservative (they skip ambiguous cases to avoid false positives), so the trigger rule files remain the authoritative guidance. +> **Note:** Several pitfalls that previously had "no static signal" are now flagged as **warnings** (see the rule-derived semantic warnings above): same source+target on `viewEnter` with a non-`once` `triggerType` (`SAME_ELEMENT_RETRIGGER`), hit-area shift from inline `keyframeEffect` transforms on `hover`/`pointerMove` (`HIT_AREA_SHIFT`), scroll presets missing/invalid `range` (`SCROLL_PRESET_*`), missing `fill: 'both'` (`RECOMMENDED_FILL_BOTH`), and a scrub gated on `(prefers-reduced-motion: reduce)` (`REDUCE_GATED_SCRUB`). These are heuristic and conservative (they skip ambiguous cases to avoid false positives), so the trigger rule files remain the authoritative guidance. For the full API, usage recipes, and the same catalogue in package form, see the [`@wix/interact-validate` README](https://github.com/wix/interact/blob/master/packages/interact-validate/README.md). diff --git a/packages/interact/rules/viewprogress.md b/packages/interact/rules/viewprogress.md index 37286756..07310ab2 100644 --- a/packages/interact/rules/viewprogress.md +++ b/packages/interact/rules/viewprogress.md @@ -8,6 +8,7 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View ## Table of Contents +- [Reduced Motion](#reduced-motion) - [Rule 1: ViewProgress with keyframeEffect or namedEffect](#rule-1-viewprogress-with-keyframeeffect-or-namedeffect) - [Rule 2: ViewProgress with customEffect](#rule-2-viewprogress-with-customeffect) - [Rule 3: ViewProgress with Tall Wrapper + Sticky Container (contain range)](#rule-3-viewprogress-with-tall-wrapper--sticky-container-contain-range) @@ -15,6 +16,21 @@ These rules help generate scroll-driven interactions using `@wix/interact`. View --- +## Reduced Motion + +`viewProgress` effects are **cancelled**, not collapsed, under `prefers-reduced-motion: reduce` — there is no meaningful slow-down of a scrubbed timeline. This happens automatically and in both paths, so it holds with JS disabled too: `generate()` declares the source's `view-timeline` only inside `@media (prefers-reduced-motion: no-preference)`, so the `--trigger-N` name never resolves under `reduce`; and `addViewProgressHandler` early-returns on `Interact.reducedMotion`, so neither the `ViewTimeline` animation nor the polyfill fallback is created. + +**The authoring consequence:** the element renders at its authored **base style**, not at the effect's first keyframe. Interact adds no hiding rule of its own for a `viewProgress` target (only `viewEnter` entrances get one), so a scroll effect that merely embellishes — a parallax shift, a scale, a rotation — is safe with nothing to do. + +It is **not** safe when your own CSS leaves the element invisible or displaced without the animation. That is common with `in`-range reveal presets whose base style is `opacity: 0`. In that case you must supply an alternative: + +- The alternative MUST use a **time-based trigger** such as `viewEnter`, or be a plain CSS rule in your own stylesheet. A `viewProgress` interaction gated on `(prefers-reduced-motion: reduce)` never runs, in CSS or in JS — `@wix/interact-validate` reports that mistake as `REDUCE_GATED_SCRUB`. +- Or drop the hiding from your base style, so the un-animated element is simply already there. + +See [full-lean.md § Reduced motion](https://wix.github.io/interact/rules/full-lean.md#reduced-motion) for the per-effect-kind table and the general alternative pattern. + +--- + ## Rule 1: ViewProgress with keyframeEffect or namedEffect **Use Case**: Scroll-driven CSS-based effects. diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index 13b9ba06..e53a80e0 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -13,7 +13,7 @@ import { InteractPlugin, } from '../types'; import { getInterpolatedKey } from './utilities'; -import { generateId } from '../utils'; +import { generateId, REDUCED_MOTION_QUERY } from '../utils'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; import { registerEffects, @@ -43,13 +43,28 @@ export class Interact { [listContainer: string]: { [interactionId: string]: boolean }; }; controllers: Set; - static forceReducedMotion: boolean = false; + static forceReducedMotion?: boolean = undefined; static allowA11yTriggers: boolean = true; static instances: Interact[] = []; static controllerCache = new Map(); static sequenceCache = new Map(); static elementSequenceMap = new WeakMap>(); private static plugins = new Map(); + private static _prefersReducedMotion?: MediaQueryList; + + static get reducedMotion(): boolean { + if (Interact.forceReducedMotion !== undefined) { + return Interact.forceReducedMotion; + } + + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + + Interact._prefersReducedMotion ??= window.matchMedia(REDUCED_MOTION_QUERY); + + return Interact._prefersReducedMotion.matches; + } constructor() { this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} }; @@ -190,6 +205,7 @@ export class Interact { Interact.controllerCache.clear(); Interact.sequenceCache.clear(); Interact.elementSequenceMap = new WeakMap(); + Interact._prefersReducedMotion = undefined; } static setup(options: { diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts index 8d53e3db..23d3a98b 100644 --- a/packages/interact/src/core/add.ts +++ b/packages/interact/src/core/add.ts @@ -15,7 +15,13 @@ import type { AnimationOptions, } from '../types'; import { PLUGIN_FIELD_PREFIX } from '../types'; -import { createTransitionCSS, getMediaQuery, getSelectorCondition, generateId } from '../utils'; +import { + createTransitionCSS, + getMediaQuery, + getSelectorCondition, + generateId, + REDUCED_MOTION_QUERY, +} from '../utils'; import { getInterpolatedKey } from './utilities'; import { effectToAnimationOptions } from '../handlers/utilities'; import { Interact, getSelector } from './Interact'; @@ -454,7 +460,7 @@ function _attachSequenceTriggers( { triggerType: sequenceConfig.triggerType } as Effect, interaction.params || {}, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, selectorCondition, animation: sequence, allowA11yTriggers: Interact.allowA11yTriggers, @@ -537,11 +543,11 @@ function _processSequences( ); Interact.addToSequence(cacheKey, animationGroupArgs, indices, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, }); const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, }); const selectorCondition = getSelectorCondition( @@ -565,7 +571,7 @@ function _processSequences( } const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, }); const selectorCondition = getSelectorCondition( @@ -649,14 +655,14 @@ function _processSequencesForTarget( const indices = _resolveListItemIndices(targetController, listContainer!, elements); Interact.addToSequence(cacheKey, animationGroupArgs, indices, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, }); return true; } const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, }); const selectorCondition = getSelectorCondition( @@ -867,7 +873,7 @@ function addInteraction( } TRIGGER_TO_HANDLER_MODULE_MAP[trigger]?.add(source, target, effect, options, { - reducedMotion: Interact.forceReducedMotion, + reducedMotion: Interact.reducedMotion, targetController, selectorCondition, allowA11yTriggers: Interact.allowA11yTriggers, @@ -895,6 +901,20 @@ export function add(controller: IInteractionController): boolean { instance.setController(key, controller); + if ( + Interact.forceReducedMotion === undefined && + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + triggers.some((t) => t.trigger === 'viewProgress' || t.trigger === 'pointerMove') + ) { + instance.setupMediaQueryListener( + `${key}::reducedMotion`, + window.matchMedia(REDUCED_MOTION_QUERY), + key, + () => controller.update(), + ); + } + triggers.forEach((interaction, index) => { const mql = getMediaQuery(interaction.conditions, instance!.dataCache.conditions); diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 0f8b9346..9843675a 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -19,6 +19,8 @@ import { transitionEffectToTransitionsList, getFullPredicateByType, getSelectorCondition, + getMotionPreferenceMedia, + hasMotionPreferenceCondition, } from '../utils'; import { getSelector } from './Interact'; import { resolveEffectForCSS, resolveSequenceForCSS } from './resolvers'; @@ -148,7 +150,9 @@ function triggerToCSS( ): CSSRuleData { const { key, conditions } = interaction; - const media = getFullPredicateByType(conditions, configConditions, 'media'); + // Forced: the runtime cancels a scrub under `reduce` whatever the author's conditions say, so an + // interaction gated on `reduce` correctly yields a timeline declaration that never applies. + const media = getMotionPreferenceMedia('no-preference', conditions, configConditions, true); const selectorCondition = getSelectorCondition(conditions, configConditions); const childSelector = getSelector(interaction, { @@ -162,8 +166,6 @@ function triggerToCSS( media, selectorCondition, childSelector, - // invalidating earlier cascaded custom properties affected from earlier transitionEffects - // to implement same-interaction-cascade declarations: [ { name: 'view-timeline', @@ -210,6 +212,7 @@ function effectToCSS( customProps: ListCustomProps, trigger: TriggerVariant, childSelector?: string, + interactionConditions?: string[], plugins?: InteractPluginStyles, ): { rules: CSSRuleData[]; @@ -283,12 +286,17 @@ function effectToCSS( .join(', ') || LIST_PROPERTY_FALLBACKS[propertyName], })); + // initial rule requires the interaction's conditions as well + const effectiveConditions = [ + ...new Set([...(interactionConditions || []), ...(conditions || [])]), + ]; + if (initial) { // declare animation custom properties with initial dependent on data-motion-enter rules.push({ key, - media, - selectorCondition, + media: getFullPredicateByType(effectiveConditions, configConditions, 'media'), + selectorCondition: getSelectorCondition(effectiveConditions, configConditions), childSelector, declarations: DEFAULT_INITIAL, selectorSuffix: ':not([data-interact-enter])', @@ -305,12 +313,38 @@ function effectToCSS( // declare animation custom properties declarations.push(...animationDeclarations); } + + // Reduced motion, per time effect: re-declare only this effect's own `animation` custom property + // with the collapsed shorthand, so an effect the author gated on a motion preference — or any + // effect on a neighbouring target — is left exactly as authored. + if ( + trigger.trigger !== 'view-progress' && + !hasMotionPreferenceCondition(effectiveConditions, configConditions) + ) { + rules.push({ + key, + media: getMotionPreferenceMedia('reduce', conditions, configConditions), + selectorCondition, + childSelector, + selectorSuffix: initial ? ':not([data-interact-enter="done"])' : undefined, + declarations: [ + { + name: customProps.animation, + value: + cssAnimations.map(({ reducedAnimation }) => reducedAnimation).join(', ') || + LIST_PROPERTY_FALLBACKS.animation, + }, + ], + }); + } } else if (transition || transitionProperties) { usedProperties = ['transition']; const properties = getStateStyleProperties(effect); const transitions = transitionEffectToTransitionsList(effect); + // adding 'no-preference' media query to transition rule + rules[0].media = getMotionPreferenceMedia('no-preference', conditions, configConditions); // declaring transition custom property declarations.push({ name: customProps.transition, @@ -347,6 +381,7 @@ function parseEffect( keyframesMap: Map, trigger: TriggerVariant, useFirstChild: boolean = true, + interactionConditions?: string[], plugins?: InteractPluginStyles, sequenceCustomProps?: Record, precomputedTargetHash?: string, @@ -383,6 +418,7 @@ function parseEffect( localCustomProps, trigger, childSelector, + interactionConditions, plugins, ); @@ -400,6 +436,7 @@ function parseSequence( keyframesMap: Map, trigger: TriggerVariant, useFirstChild: boolean = true, + interactionConditions?: string[], targetUsedProperties?: Map>, plugins?: InteractPluginStyles, ): CSSRuleData[] { @@ -435,6 +472,7 @@ function parseSequence( keyframesMap, trigger, useFirstChild, + interactionConditions, plugins, seqCustomProps, targetHash, @@ -525,6 +563,7 @@ function parseInteraction( keyframesMap, motionTrigger, useFirstChild, + conditions, plugins, ); cssRules.push(...rules); @@ -546,6 +585,7 @@ function parseInteraction( keyframesMap, motionTrigger, useFirstChild, + conditions, targetUsedProperties, plugins, ), diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 54a47e0c..8f784d1a 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -1,6 +1,51 @@ import { getEasing, toCSSPropertyName } from '@wix/motion'; import type { Condition, CreateTransitionCSSParams, StateEffect, StyleProperty } from './types'; +const MOTION_PREFERENCE_FEATURE = 'prefers-reduced-motion'; +const MOTION_PREFERENCE_CONDITION = `$${MOTION_PREFERENCE_FEATURE}`; +export const REDUCED_MOTION_QUERY = `(${MOTION_PREFERENCE_FEATURE}: reduce)`; + +export function hasMotionPreferenceCondition( + conditions?: string[], + configConditions?: Record, +): boolean { + return !!conditions?.some((conditionName) => { + const condition = configConditions?.[conditionName]; + return condition?.type === 'media' && condition.predicate.includes(MOTION_PREFERENCE_FEATURE); + }); +} + +/** + * Composes a motion preference into the media predicate of the given conditions, so a gated + * interaction ends up with `(min-width: 900px) and (prefers-reduced-motion: reduce)`. + * + * An author-declared motion-preference condition wins outright, since composing on top of it would + * yield a query that can never match. Pass `force` for a gate the runtime applies regardless + * of what the author asked for, where that unmatchable query is the correct encoding. + */ +export function getMotionPreferenceMedia( + preference: 'reduce' | 'no-preference', + conditions?: string[], + configConditions?: Record, + force = false, +): string { + if (!force && hasMotionPreferenceCondition(conditions, configConditions)) { + return getFullPredicateByType(conditions, configConditions || {}, 'media'); + } + + return getFullPredicateByType( + [...(conditions || []), MOTION_PREFERENCE_CONDITION], + { + ...configConditions, + [MOTION_PREFERENCE_CONDITION]: { + type: 'media', + predicate: `${MOTION_PREFERENCE_FEATURE}: ${preference}`, + }, + }, + 'media', + ); +} + export function roundNumber(num: number, precision = 2): number { return parseFloat(num.toFixed(precision)); } @@ -159,7 +204,7 @@ export function createTransitionCSS({ ? applySelectorCondition(transitionSelector, selectorCondition) : transitionSelector; - result.push(`@media (prefers-reduced-motion: no-preference) { ${finalTransitionSelector} { + result.push(`@media ${getMotionPreferenceMedia('no-preference')} { ${finalTransitionSelector} { transition: ${transitions.join(', ')}; } }`); } diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 934e3efa..dd4e8f83 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { generate, _generate, DEFAULT_INITIAL } from '../src/core/css'; +import { createTransitionCSS } from '../src/utils'; import type { InteractConfig, CSSRuleData } from '../src/types'; describe('css.generate', () => { @@ -1204,7 +1205,9 @@ describe('css._generate', () => { const animRules = cssRules.filter( (r) => r.declarations.some((d) => isAnimationProp(d.name)) && - !r.declarations.some((d) => String(d.value).includes('var(')), + !r.declarations.some((d) => String(d.value).includes('var(')) && + // reduced motion re-declares an effect's own prop; uniqueness is about base declarations + !r.media?.includes('prefers-reduced-motion'), ); expect(animRules.length).toBeGreaterThanOrEqual(2); @@ -1742,4 +1745,470 @@ describe('css._generate', () => { }); }); }); + + describe('initial rule conditions', () => { + const entranceEffect = { + effectId: 'kf1', + triggerType: 'once' as const, + duration: 500, + keyframeEffect: { name: 'enterAnim', keyframes: [{ opacity: '0' }, { opacity: '1' }] }, + }; + const initialRuleOf = (cssRules: CSSRuleData[]) => + cssRules.find((r) => r.selectorSuffix === ':not([data-interact-enter])')!; + + it('should gate the hiding rule on the interaction conditions', () => { + const config: InteractConfig = { + effects: {}, + conditions: { desktop: { type: 'media', predicate: 'min-width: 900px' } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', conditions: ['desktop'], effects: [entranceEffect] }, + ], + }; + + const { cssRules } = _generate(config); + + expect(initialRuleOf(cssRules).media).toBe('(min-width: 900px)'); + }); + + it('should compose interaction and effect conditions into the hiding rule', () => { + const config: InteractConfig = { + effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 900px' }, + wide: { type: 'media', predicate: 'min-width: 1200px' }, + }, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['desktop'], + effects: [{ ...entranceEffect, conditions: ['wide'] }], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect(initialRuleOf(cssRules).media).toContain('(min-width: 900px)'); + expect(initialRuleOf(cssRules).media).toContain('(min-width: 1200px)'); + }); + + it('should carry an interaction selector condition into the hiding rule', () => { + const config: InteractConfig = { + effects: {}, + conditions: { dark: { type: 'selector', predicate: '.dark' } }, + interactions: [ + { key: 'el', trigger: 'viewEnter', conditions: ['dark'], effects: [entranceEffect] }, + ], + }; + + const { cssRules } = _generate(config); + + expect(initialRuleOf(cssRules).selectorCondition).toBe(':is(.dark)'); + }); + + it('should leave the hiding rule unconditional when nothing is gated', () => { + const config: InteractConfig = { + effects: {}, + interactions: [{ key: 'el', trigger: 'viewEnter', effects: [entranceEffect] }], + }; + + const { cssRules } = _generate(config); + const initialRule = initialRuleOf(cssRules); + + expect(initialRule.media).toBeFalsy(); + expect(initialRule.selectorCondition).toBeFalsy(); + }); + }); + + describe('reduced motion', () => { + const REDUCE = '(prefers-reduced-motion: reduce)'; + const NO_PREFERENCE = '(prefers-reduced-motion: no-preference)'; + const MOTION_CONDITIONS = { + 'motion-ok': { type: 'media' as const, predicate: 'prefers-reduced-motion: no-preference' }, + 'motion-reduced': { type: 'media' as const, predicate: 'prefers-reduced-motion: reduce' }, + }; + const keyframeEffect = (name: string) => ({ + name, + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }); + const collapseRulesOf = (cssRules: CSSRuleData[]) => + cssRules.filter( + (r) => + r.media?.includes(REDUCE) && + r.declarations.length === 1 && + isAnimationProp(r.declarations[0].name) && + String(r.declarations[0].value).includes(' 1ms '), + ); + + it('should collapse each effect through its own custom property, after it is declared', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { effectId: 'kf1', duration: 500, delay: 200, keyframeEffect: keyframeEffect('a') }, + ], + }, + { + key: 'el', + trigger: 'hover', + effects: [{ effectId: 'kf2', duration: 500, keyframeEffect: keyframeEffect('b') }], + }, + ], + }; + + const { cssRules } = _generate(config); + const reduceRules = collapseRulesOf(cssRules); + + // one per effect, each touching only its own animation custom property + expect(reduceRules).toHaveLength(2); + const names = reduceRules.flatMap((r) => r.declarations.map((d) => d.name)); + expect(names.every(isAnimationProp)).toBe(true); + expect(new Set(names).size).toBe(2); + + // each override must follow the declaration it overrides + reduceRules.forEach((reduceRule) => { + const propName = reduceRule.declarations[0].name; + const baseIdx = cssRules.findIndex( + (r) => r !== reduceRule && r.declarations.some((d) => d.name === propName), + ); + expect(cssRules.indexOf(reduceRule)).toBeGreaterThan(baseIdx); + }); + }); + + it('should leave an author-gated effect alone while still collapsing its ungated neighbour', () => { + const config: InteractConfig = { + effects: {}, + conditions: MOTION_CONDITIONS, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['motion-reduced'], + effects: [{ effectId: 'calm', duration: 300, keyframeEffect: keyframeEffect('calm') }], + }, + { + key: 'el', + trigger: 'hover', + effects: [{ effectId: 'big', duration: 800, keyframeEffect: keyframeEffect('big') }], + }, + ], + }; + + const { cssRules } = _generate(config); + const reduceRules = collapseRulesOf(cssRules); + + // only the ungated neighbour is collapsed + const collapsed = reduceRules.filter((r) => r.media === REDUCE); + expect(collapsed).toHaveLength(1); + expect(String(collapsed[0].declarations[0].value)).toContain('big'); + + // the gated effect keeps its authored 300ms, declared under its own condition + const calmDecl = cssRules + .flatMap((r) => r.declarations) + .filter((d) => isAnimationProp(d.name)) + .find((d) => String(d.value).includes('calm'))!; + expect(String(calmDecl.value)).toContain('300ms'); + expect(cssRules.some((r) => r.declarations.includes(calmDecl) && r.media === REDUCE)).toBe( + false, + ); + }); + + it('should collapse to a single 1ms iteration while preserving name and fill', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 500, + delay: 200, + iterations: 0, + fill: 'both', + keyframeEffect: keyframeEffect('ongoingAnim'), + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const value = String(collapseRulesOf(cssRules)[0].declarations[0].value); + + expect(value).toContain('ongoingAnim'); + expect(value).toContain('1ms'); + expect(value).toContain('0ms'); + expect(value).not.toContain('infinite'); + expect(value).toContain('both'); + expect(value).not.toContain('500ms'); + expect(value).not.toContain('200ms'); + }); + + it('should compose an ungated effect conditions into the reduce query', () => { + const config: InteractConfig = { + effects: {}, + conditions: { desktop: { type: 'media', predicate: 'min-width: 900px' } }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + conditions: ['desktop'], + duration: 500, + keyframeEffect: keyframeEffect('a'), + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect(collapseRulesOf(cssRules)[0].media).toContain(`(min-width: 900px)`); + expect(collapseRulesOf(cssRules)[0].media).toContain(REDUCE); + }); + + it('should treat an effect-level motion condition as author-gating', () => { + const config: InteractConfig = { + effects: {}, + conditions: MOTION_CONDITIONS, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + conditions: ['motion-reduced'], + duration: 300, + keyframeEffect: keyframeEffect('calm'), + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect(collapseRulesOf(cssRules)).toHaveLength(0); + }); + + it('should cancel a scrub at the source even when the author gated it on reduce', () => { + const config: InteractConfig = { + effects: {}, + conditions: MOTION_CONDITIONS, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + conditions: ['motion-reduced'], + effects: [{ effectId: 'kf1', keyframeEffect: keyframeEffect('scrollAnim') }], + }, + ], + }; + + const { cssRules } = _generate(config); + const triggerRule = cssRules.find((r) => + r.declarations.some((d) => d.name === 'view-timeline'), + )!; + + // parity with the runtime handler, which early-returns under `reduce` whatever the + // interaction's conditions say — so this timeline is declared under a query that never matches + expect(triggerRule.media).toContain(NO_PREFERENCE); + expect(triggerRule.media).toContain(REDUCE); + // and a scrub is never collapsed, since there is no meaningful collapse of a scrubbed timeline + expect(collapseRulesOf(cssRules)).toHaveLength(0); + }); + + it('should gate the scroll-driven timeline on no-preference, leaving timelines untouched', () => { + const config: InteractConfig = { + effects: {}, + conditions: { desktop: { type: 'media', predicate: 'min-width: 900px' } }, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + conditions: ['desktop'], + effects: [{ effectId: 'kf1', keyframeEffect: keyframeEffect('scrollAnim') }], + }, + { + key: 'el', + trigger: 'click', + effects: [ + { effectId: 'kf2', duration: 500, keyframeEffect: keyframeEffect('clickAnim') }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const triggerRule = cssRules.find((r) => + r.declarations.some((d) => d.name === 'view-timeline'), + )!; + + // with no `view-timeline` to resolve, the scroll-driven animation has no timeline at all + expect(triggerRule.media).toBe(`(min-width: 900px) and ${NO_PREFERENCE}`); + // the click effect sharing the target is still collapsed, and touches no timeline + const reduceRules = collapseRulesOf(cssRules); + expect(reduceRules).toHaveLength(1); + expect(String(reduceRules[0].declarations[0].value)).toContain('clickAnim'); + expect(reduceRules[0].declarations.every((d) => !isTimelineProp(d.name))).toBe(true); + }); + + it('should gate a state effect transition on no-preference, composing its conditions', () => { + const config: InteractConfig = { + effects: {}, + conditions: { desktop: { type: 'media', predicate: 'min-width: 900px' } }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'trans1', + conditions: ['desktop'], + transition: { + styleProperties: [{ name: 'opacity', value: '1' }], + duration: 500, + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const transitionRule = cssRules.find((r) => + r.declarations.some((d) => isTransitionProp(d.name)), + )!; + const stateRule = cssRules.find((r) => r.states?.includes('trans1'))!; + + expect(transitionRule.media).toBe(`(min-width: 900px) and ${NO_PREFERENCE}`); + expect(cssRules.some((r) => r.media?.includes(REDUCE))).toBe(false); + expect(stateRule.media).toBe('(min-width: 900px)'); + expect(stateRule.declarations).toEqual([{ name: 'opacity', value: '1' }]); + }); + + it('should let a reduce-gated state effect keep its transition', () => { + const config: InteractConfig = { + effects: {}, + conditions: MOTION_CONDITIONS, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'trans1', + conditions: ['motion-reduced'], + transition: { + styleProperties: [{ name: 'opacity', value: '1' }], + duration: 200, + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const transitionRule = cssRules.find((r) => + r.declarations.some((d) => isTransitionProp(d.name)), + )!; + + // unlike a scrub, a state effect the author scoped to `reduce` is theirs to define + expect(transitionRule.media).toBe(REDUCE); + expect(String(transitionRule.declarations[0].value)).toContain('200ms'); + }); + + it('should never suppress an animation that owns an initial rule', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + effectId: 'kf1', + triggerType: 'once', + duration: 800, + delay: 100, + iterations: 2, + keyframeEffect: keyframeEffect('enterAnim'), + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const initialRule = cssRules.find((r) => r.selectorSuffix === ':not([data-interact-enter])')!; + const reduceRule = collapseRulesOf(cssRules)[0]; + + // the rule that hides the element must stay unconditional, so nothing may drop the + // animation that lets `data-interact-enter` reach `done` + expect(initialRule.media).toBeFalsy(); + expect(reduceRule).toBeDefined(); + // and it must carry the same suffix as the declaration it overrides, or it never applies + expect(reduceRule.selectorSuffix).toBe(':not([data-interact-enter="done"])'); + expect(String(reduceRule.declarations[0].value)).toContain('enterAnim'); + expect( + cssRules.some((r) => + r.declarations.some( + (d) => (d.name === 'animation-name' || isAnimationProp(d.name)) && d.value === 'none', + ), + ), + ).toBe(false); + }); + + it('should not strand an element whose entrance is gated at the interaction level', () => { + const config: InteractConfig = { + effects: {}, + conditions: MOTION_CONDITIONS, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + conditions: ['motion-reduced'], + effects: [ + { + effectId: 'calm', + triggerType: 'once', + duration: 300, + keyframeEffect: keyframeEffect('calm'), + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const initialRule = cssRules.find((r) => r.selectorSuffix === ':not([data-interact-enter])')!; + + // under no-preference this interaction never binds, so an unconditional hiding rule would + // leave the element invisible forever — the exact shape Phase 2.2 asks authors to write + expect(initialRule.media).toBe(REDUCE); + }); + + it('should gate the runtime transition path the same way', () => { + const result = createTransitionCSS({ + key: 'el', + effectId: 'trans1', + transition: { styleProperties: [{ name: 'opacity', value: '1' }], duration: 200 }, + }).join('\n'); + + expect(result).toContain(`@media ${NO_PREFERENCE}`); + expect(result).toContain('transition: opacity 200ms ease;'); + expect(result).not.toContain('transition: none'); + }); + }); }); diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index da8324f3..e77120df 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -503,7 +503,7 @@ describe('interact (mini)', () => { // Clear Interact instances to ensure test isolation Interact.destroy(); // Reset forceReducedMotion to default - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = undefined; // Reset allowA11yTriggers to default false for test isolation Interact.allowA11yTriggers = false; }); @@ -642,7 +642,7 @@ describe('interact (mini)', () => { reducedMotion: true, }); - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = undefined; Interact.destroy(); }); diff --git a/packages/interact/test/plugins.spec.ts b/packages/interact/test/plugins.spec.ts index 139d2430..abe7550b 100644 --- a/packages/interact/test/plugins.spec.ts +++ b/packages/interact/test/plugins.spec.ts @@ -50,7 +50,7 @@ describe('interact plugin bridge', () => { vi.restoreAllMocks(); vi.clearAllMocks(); Interact.destroy(); - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = undefined; Interact.allowA11yTriggers = false; }); diff --git a/packages/interact/test/react.spec.tsx b/packages/interact/test/react.spec.tsx index 78572f18..c369be7f 100644 --- a/packages/interact/test/react.spec.tsx +++ b/packages/interact/test/react.spec.tsx @@ -222,7 +222,7 @@ describe('interact (react)', () => { // Clear Interact instances to ensure test isolation Interact.destroy(); // Reset forceReducedMotion to default - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = undefined; }); describe('createInteractRef', () => { diff --git a/packages/interact/test/reducedMotion.spec.ts b/packages/interact/test/reducedMotion.spec.ts new file mode 100644 index 00000000..2ebe84c5 --- /dev/null +++ b/packages/interact/test/reducedMotion.spec.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Interact, add } from '../src/index'; +import type { InteractConfig } from '../src/types'; +import type { NamedEffect } from '@wix/motion'; + +// Mock @wix/motion module +vi.mock('@wix/motion', async () => { + const { toCSSPropertyName } = await vi.importActual('@wix/motion'); + const mock: any = { + getWebAnimation: vi.fn().mockReturnValue({ + play: vi.fn(), + cancel: vi.fn(), + onFinish: vi.fn(), + pause: vi.fn(), + reverse: vi.fn(), + progress: vi.fn(), + persist: vi.fn(), + isCSS: false, + playState: 'idle', + ready: Promise.resolve(), + }), + getElementCSSAnimation: vi.fn().mockReturnValue(null), + prepareAnimation: vi.fn(), + getScrubScene: vi.fn().mockReturnValue({}), + getEasing: vi.fn().mockImplementation((v) => v), + getAnimation: vi.fn().mockImplementation((target, options, trigger, reducedMotion) => { + return mock.getWebAnimation(target, options, trigger, { reducedMotion }); + }), + registerEffects: vi.fn(), + toCSSPropertyName, + }; + + return mock; +}); + +const REDUCE_QUERY = '(prefers-reduced-motion: reduce)'; + +function mockMatchMedia(matches: boolean | undefined) { + const mql = { + matches, + media: REDUCE_QUERY, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + const matchMedia = matches === undefined ? undefined : vi.fn().mockReturnValue(mql); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: matchMedia, + }); + + return matchMedia!; +} + +describe('reduced motion', () => { + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + Interact.forceReducedMotion = undefined; + Interact.destroy(); + vi.clearAllMocks(); + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: originalMatchMedia, + }); + }); + + describe('resolution', () => { + it('should detect the client preference when no override is set', () => { + const matchMedia = mockMatchMedia(true); + + expect(Interact.reducedMotion).toBe(true); + expect(matchMedia).toHaveBeenCalledWith(REDUCE_QUERY); + }); + + it('should resolve to false when the client does not prefer reduced motion', () => { + mockMatchMedia(false); + + expect(Interact.reducedMotion).toBe(false); + }); + + it('should let an explicit false override a matching client preference', () => { + mockMatchMedia(true); + Interact.forceReducedMotion = false; + + expect(Interact.reducedMotion).toBe(false); + }); + + it('should let an explicit true override a non-matching client preference', () => { + const matchMedia = mockMatchMedia(false); + Interact.forceReducedMotion = true; + + expect(Interact.reducedMotion).toBe(true); + expect(matchMedia).not.toHaveBeenCalled(); + }); + + it('should resolve to false without throwing when matchMedia is unavailable', () => { + mockMatchMedia(undefined); + + expect(() => Interact.reducedMotion).not.toThrow(); + expect(Interact.reducedMotion).toBe(false); + }); + + it('should cache the MediaQueryList across reads', () => { + const matchMedia = mockMatchMedia(true); + + expect(Interact.reducedMotion).toBe(true); + expect(Interact.reducedMotion).toBe(true); + expect(Interact.reducedMotion).toBe(true); + + expect(matchMedia).toHaveBeenCalledTimes(1); + }); + + it('should drop the cached MediaQueryList on destroy', () => { + mockMatchMedia(true); + expect(Interact.reducedMotion).toBe(true); + + Interact.destroy(); + mockMatchMedia(false); + + expect(Interact.reducedMotion).toBe(false); + }); + }); + + describe('handlers', () => { + const config: InteractConfig = { + interactions: [ + { + trigger: 'hover', + key: 'logo-hover', + effects: [{ key: 'logo-hover', effectId: 'logo-arc-in' }], + }, + ], + effects: { + 'logo-arc-in': { + namedEffect: { type: 'ArcIn', direction: 'right', power: 'medium' } as NamedEffect, + duration: 1200, + }, + }, + }; + + it('should pass the detected preference to the animation layer', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia(true); + Interact.create(config); + + const element = document.createElement('div'); + add(element, 'logo-hover'); + + expect(getWebAnimation).toHaveBeenCalledWith(element, expect.any(Object), undefined, { + reducedMotion: true, + }); + }); + }); + + describe('reactivity', () => { + beforeEach(() => { + (window as any).IntersectionObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it('should call update() for viewProgress on preference change, not for viewEnter + once', () => { + const mql = { + matches: true, + media: REDUCE_QUERY, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockReturnValue(mql), + }); + + const vpConfig: InteractConfig = { + interactions: [ + { + trigger: 'viewProgress', + key: 'vp-key', + effects: [{ key: 'vp-key', effectId: 'vp-fx' }], + }, + ], + effects: { 'vp-fx': { namedEffect: { type: 'FadeIn' } as NamedEffect, duration: 300 } }, + }; + const veConfig: InteractConfig = { + interactions: [ + { trigger: 'viewEnter', key: 've-key', effects: [{ key: 've-key', effectId: 've-fx' }] }, + ], + effects: { 've-fx': { namedEffect: { type: 'FadeIn' } as NamedEffect, duration: 300 } }, + }; + + const vpInstance = Interact.create(vpConfig); + const veInstance = Interact.create(veConfig); + + add(document.createElement('div'), 'vp-key'); + add(document.createElement('div'), 've-key'); + + expect(vpInstance.mediaQueryListeners.size).toBe(1); + expect(veInstance.mediaQueryListeners.size).toBe(0); + + const vpController = Interact.controllerCache.get('vp-key')!; + const vpUpdateSpy = vi.spyOn(vpController, 'update').mockImplementation(() => {}); + + const [[, changeHandler]] = (mql.addEventListener as any).mock.calls; + changeHandler({} as any); + + expect(vpUpdateSpy).toHaveBeenCalledTimes(1); + }); + + it('should not register a prefers-reduced-motion listener when forceReducedMotion is set', () => { + Interact.forceReducedMotion = true; + const mql = { + matches: true, + media: REDUCE_QUERY, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockReturnValue(mql), + }); + + const config: InteractConfig = { + interactions: [ + { + trigger: 'viewProgress', + key: 'vp-key', + effects: [{ key: 'vp-key', effectId: 'vp-fx' }], + }, + ], + effects: { 'vp-fx': { namedEffect: { type: 'FadeIn' } as NamedEffect, duration: 300 } }, + }; + + const instance = Interact.create(config); + add(document.createElement('div'), 'vp-key'); + + expect(instance.mediaQueryListeners.size).toBe(0); + }); + + it('should remove the prefers-reduced-motion listener on destroy', () => { + const mql = { + matches: true, + media: REDUCE_QUERY, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockReturnValue(mql), + }); + + const config: InteractConfig = { + interactions: [ + { + trigger: 'viewProgress', + key: 'vp-key', + effects: [{ key: 'vp-key', effectId: 'vp-fx' }], + }, + ], + effects: { 'vp-fx': { namedEffect: { type: 'FadeIn' } as NamedEffect, duration: 300 } }, + }; + + const instance = Interact.create(config); + add(document.createElement('div'), 'vp-key'); + + expect(mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + expect(instance.mediaQueryListeners.size).toBe(1); + + instance.destroy(); + + expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + expect(instance.mediaQueryListeners.size).toBe(0); + }); + + // The reduced-motion listener is only ever registered on the *source* key, since `add()` reads + // `triggers`. Both connect orderings must therefore rebind a cross-key scrub from the source: + // 'target first' attaches via the source's `_addInteraction`, 'source first' via the target's + // `addEffectsForTarget` — and only the former is the obvious case. + it.each([ + ['target first', true], + ['source first', false], + ])( + 'should re-attach a cross-key scrub handler when the source rebinds (%s)', + async (_label, targetFirst) => { + const { getAnimation } = await import('@wix/motion'); + // take the ViewTimeline branch of addViewProgressHandler, which is fully mocked + (window as any).ViewTimeline ??= class {}; + + // `matches: false` so the handler actually attaches — this is about whether the rebind + // reaches a cross-key target, not about the flag suppressing anything + const mql = { + matches: false, + media: REDUCE_QUERY, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockReturnValue(mql), + }); + + const config: InteractConfig = { + interactions: [ + { + trigger: 'viewProgress', + key: 'src-key', + effects: [{ key: 'tgt-key', effectId: 'vp-fx' }], + }, + ], + effects: { 'vp-fx': { namedEffect: { type: 'FadeIn' } as NamedEffect, duration: 300 } }, + }; + + const instance = Interact.create(config); + const targetEl = document.createElement('div'); + const sourceEl = document.createElement('div'); + + if (targetFirst) { + add(targetEl, 'tgt-key'); + add(sourceEl, 'src-key'); + } else { + add(sourceEl, 'src-key'); + add(targetEl, 'tgt-key'); + } + + // the listener lives on the source key only — a target-only key has no `triggers` entry + const listenerIds = () => [...instance.mediaQueryListeners.keys()]; + expect(listenerIds()).toContain('src-key::reducedMotion'); + expect(listenerIds()).not.toContain('tgt-key::reducedMotion'); + + const attachCount = () => + (getAnimation as any).mock.calls.filter((args: unknown[]) => args[0] === targetEl).length; + + expect(attachCount()).toBe(1); + + const { handler } = instance.mediaQueryListeners.get('src-key::reducedMotion')!; + vi.clearAllMocks(); + handler(); + + // exactly once: the rebind reaches the cross-key target, and does not double-attach + expect(attachCount()).toBe(1); + expect(listenerIds()).toContain('src-key::reducedMotion'); + }, + ); + }); +}); diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts index b8aac340..620c9e41 100644 --- a/packages/interact/test/web.spec.ts +++ b/packages/interact/test/web.spec.ts @@ -468,7 +468,7 @@ describe('interact (web)', () => { // Clear Interact instances to ensure test isolation Interact.destroy(); // Reset forceReducedMotion to default - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = undefined; // Reset allowA11yTriggers to default false for test isolation Interact.allowA11yTriggers = false; }); @@ -630,7 +630,7 @@ describe('interact (web)', () => { reducedMotion: true, }); - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = undefined; Interact.destroy(); }); diff --git a/packages/motion/src/api/cssAnimations.ts b/packages/motion/src/api/cssAnimations.ts index 4d61acf0..a9800bdb 100644 --- a/packages/motion/src/api/cssAnimations.ts +++ b/packages/motion/src/api/cssAnimations.ts @@ -68,6 +68,11 @@ function getCSSAnimation( return { target: getAnimationTarget(target, item.part), animation: getAnimationAsCSS(item, isViewProgress), + // same shorthand collapsed to a single 1ms iteration with no delay, for reduced motion. + reducedAnimation: getAnimationAsCSS( + { ...item, options: { ...item.options, duration: 1, delay: 0, iterations: 1 } }, + isViewProgress, + ), composition: item.options.composite, custom: item.effect.custom, name: item.effect.name, diff --git a/packages/motion/test/motion.spec.ts b/packages/motion/test/motion.spec.ts index 371a6852..e6b9555c 100644 --- a/packages/motion/test/motion.spec.ts +++ b/packages/motion/test/motion.spec.ts @@ -338,6 +338,21 @@ describe('motion.ts', () => { }); }); + test('should collapse the reduced motion shorthand to a single 1ms iteration', () => { + const animationOptions: AnimationOptions = { + namedEffect: { type: 'FadeIn', id: 'fade' }, + duration: 1000, + delay: 200, + fill: 'forwards', + iterations: 2, + }; + + const result = getCSSAnimation('test-target', animationOptions); + + expect(result[0].animation).toBe('fade-in 1000ms 200ms ease-in forwards 2 paused'); + expect(result[0].reducedAnimation).toBe('fade-in 1ms 0ms ease-in forwards 1 paused'); + }); + test('should preserve explicit zero delay in generated CSS animation shorthand', () => { const animationOptions: AnimationOptions = { namedEffect: { type: 'FadeIn', id: 'fade' }, diff --git a/skills/interactor/references/config-schema.md b/skills/interactor/references/config-schema.md index e1f87409..ec1617ca 100644 --- a/skills/interactor/references/config-schema.md +++ b/skills/interactor/references/config-schema.md @@ -301,8 +301,10 @@ type Condition = { type: 'media' | 'container' | 'selector'; predicate?: string Attach with `conditions: ['desktop']` on an interaction (gates the whole trigger), an effect (skips just that effect), or a sequence. **All** listed conditions must -pass. Conditions re-evaluate when the media state changes — the primary mechanism -for reduced-motion alternatives. +pass. Conditions re-evaluate when the media state changes. A `prefers-reduced-motion` +condition additionally exempts that effect from Interact's automatic reduced-motion +collapse — which is how a gentler alternative is expressed. See +[`presets.md` § Accessibility & reduced motion](presets.md#accessibility--reduced-motion). ```ts conditions: { @@ -390,17 +392,18 @@ common choice); omit it if you'd rather it lay out like a normal block. `Interact.create(config)` returns an instance. Keep the reference to manage its lifecycle. -| Member | Description | -| :---------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Interact.create(config, options?)` | Initialize; returns an independent instance. `options.useCustomElement` toggles `` mode. | -| `Interact.registerEffects(presets)` | Register named-effect presets. **Call before `create()`/`generate()`** when using `namedEffect`. Same function as `@wix/motion`'s `registerEffects`. | -| `Interact.setup(options)` | Global defaults — call before `create()`. See below. | -| `Interact.destroy()` | Static — tears down **all** instances (e.g. on route change). | -| `Interact.getInstance(key)` / `Interact.getController(key)` | Look up the instance/controller owning a key. | -| `Interact.forceReducedMotion` | `boolean`, default `false` — force reduced-motion globally. | -| `Interact.allowA11yTriggers` | `boolean`, **default `true`** — enable `interest`/`activate` and layer a11y behavior onto `hover`/`click`. | -| `instance.destroy()` | Tear down just this instance — call on component unmount. | -| `instance.has(key)` / `instance.get(key)` | Instance lookups. | +| Member | Description | +| :---------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Interact.create(config, options?)` | Initialize; returns an independent instance. `options.useCustomElement` toggles `` mode. | +| `Interact.registerEffects(presets)` | Register named-effect presets. **Call before `create()`/`generate()`** when using `namedEffect`. Same function as `@wix/motion`'s `registerEffects`. | +| `Interact.setup(options)` | Global defaults — call before `create()`. See below. | +| `Interact.destroy()` | Static — tears down **all** instances (e.g. on route change). | +| `Interact.getInstance(key)` / `Interact.getController(key)` | Look up the instance/controller owning a key. | +| `Interact.forceReducedMotion` | `boolean \| undefined`, default `undefined` — override the detected preference. `undefined` follows `prefers-reduced-motion`; `true` forces reduced motion on, `false` forces motion on. Set before `create()`. | +| `Interact.reducedMotion` | `boolean`, read-only — the resolved decision (`forceReducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches`). | +| `Interact.allowA11yTriggers` | `boolean`, **default `true`** — enable `interest`/`activate` and layer a11y behavior onto `hover`/`click`. | +| `instance.destroy()` | Tear down just this instance — call on component unmount. | +| `instance.has(key)` / `instance.get(key)` | Instance lookups. | **Standalone functions** (exported from every entry point): diff --git a/skills/interactor/references/motion-engine.md b/skills/interactor/references/motion-engine.md index 2248e22f..e884897c 100644 --- a/skills/interactor/references/motion-engine.md +++ b/skills/interactor/references/motion-engine.md @@ -122,7 +122,7 @@ group's delay so all end together. ## Engine gotchas - **fastdom batching** makes setup async: `play()`/`reverse()` await `ready`; await `scene.ready` before reading `currentTime`/`progress`. Use `prepareAnimation()` to pre-measure layout values CSS can't compute. -- **Reduced motion** (`options.reducedMotion` / `context.reducedMotion`): time-based collapses `duration` to 1ms (single iteration); multi-iteration animations are dropped entirely (returns `[]`). +- **Reduced motion** (`options.reducedMotion` / `context.reducedMotion`): time-based collapses `duration` to 1ms (single iteration); multi-iteration animations are dropped entirely (returns `[]`). This is the **WAAPI** path only — `getAnimation()` returns a matching CSS animation before the flag is consulted, and deliberately so: `@wix/interact` enforces reduced motion for CSS-backed effects in the CSS that `generate()` emits, not here. `@wix/motion` does no detection of its own; the caller passes the flag. - **`customEffect`** is the only path with a rAF loop; it forces `composite: 'add'` and calls `customEffect(target, null)` on cancel (teardown signal). - **Effect modules** (`AnimationEffectAPI`): `{ web(options, dom?) => AnimationData[], getNames(options) => string[], style?(options) => AnimationData[], prepare?(options, dom?) }`. Mouse presets instead export a factory `(options) => (target) => instance`. - `iterations: 0` or `Infinity` → infinite. Generated animation ids: `${effectId}-${index+1}`. diff --git a/skills/interactor/references/presets.md b/skills/interactor/references/presets.md index 8f14a925..97a96473 100644 --- a/skills/interactor/references/presets.md +++ b/skills/interactor/references/presets.md @@ -180,31 +180,42 @@ one exists (`FadeIn`↔`FadeScroll`, `SlideIn`↔`SlideScroll`, `RevealIn`↔`Re ## Accessibility & reduced motion -Motion is a host responsibility: gate risky effects with a -`(prefers-reduced-motion: reduce)` condition and swap to a calmer effect (conditions -re-evaluate when the preference changes). Apply constraints **only when the user -asks** for "accessible" / "reduced-motion safe" / "subtle" / "tone it down" — don't -limit creativity by default. +**Interact already handles the baseline.** Under `prefers-reduced-motion: reduce` it +collapses time effects to 1ms, drops state-transition tweens, and cancels `*Scroll` +and mouse presets outright — detected automatically, enforced in the generated CSS. +So do **not** gate every preset behind `(prefers-reduced-motion: no-preference)`, and +apply extra constraints **only when the user asks** for "accessible" / +"reduced-motion safe" / "subtle" / "tone it down" — don't limit creativity by default. - **High-risk** (spin/bounce/flash/3D/large parallax): `SpinIn`, `Spin`, `SpinScroll`, `Spin3dScroll`, `BounceIn`, `Bounce`, `ArcIn`, `ArcScroll`, `FlipIn`, `FlipScroll`, `Tilt3DMouse`, `Flash`, `Jello`, `Wiggle`. - **Safe**: `FadeIn`, `FadeScroll`, `BlurIn`, `BlurScroll`, `Pulse` (subtle), `Breathe`, `SlideIn`/`GlideIn` (subtle). - **Reduced-motion fallbacks:** `BounceIn`/`SpinIn`/`ArcIn`/`FlipIn`/`TurnIn` → `FadeIn`; `Spin`/`Bounce`/`Wiggle` → stop or subtle `Pulse`; `Flash` → reduce to <3/sec; `ParallaxScroll` → static; `*Scroll` → `FadeScroll` or disable; mouse presets → static state. -Reduced-motion pattern with conditions: +A named alternative is worth adding when the automatic collapse is too abrupt, or — +**required** — when a cancelled `*Scroll` preset would leave the element hidden at its +base style. Gate only the alternative; a `prefers-reduced-motion` condition exempts +that effect from the collapse and leaves its neighbours alone: ```ts { interactions: [ - { key: 'hero', trigger: 'viewEnter', conditions: ['ok'], effects: [{ effectId: 'spin-in' }] }, - { key: 'hero', trigger: 'viewEnter', conditions: ['rm'], effects: [{ effectId: 'fade-in' }] }, + { key: 'hero', trigger: 'viewEnter', effects: [ + { effectId: 'spin-in' }, // collapsed automatically under reduce + { effectId: 'fade-in', conditions: ['rm'] }, // the calmer alternative + ] }, ], effects: { - 'spin-in': { duration: 800, namedEffect: { type: 'SpinIn' }, triggerType: 'once' }, - 'fade-in': { duration: 400, namedEffect: { type: 'FadeIn' }, triggerType: 'once' }, + 'spin-in': { duration: 800, namedEffect: { type: 'SpinIn' }, triggerType: 'once', fill: 'backwards' }, + 'fade-in': { duration: 400, namedEffect: { type: 'FadeIn' }, triggerType: 'once', fill: 'backwards' }, }, - conditions: { ok: { type: 'media', predicate: '(prefers-reduced-motion: no-preference)' }, - rm: { type: 'media', predicate: '(prefers-reduced-motion: reduce)' } } } + conditions: { rm: { type: 'media', predicate: '(prefers-reduced-motion: reduce)' } } } ``` +**A scrub's alternative must use a time-based trigger.** A `viewProgress` or +`pointerMove` interaction gated on `reduce` never runs — the runtime cancels scrubs +under `reduce` whatever the conditions say, so substitute a `viewEnter` effect or a +plain CSS rule instead. `@wix/interact-validate` reports the mistake as +`REDUCE_GATED_SCRUB`. + ## Duration guidance Functional UI feedback < 500ms · decorative entrances up to ~1200ms · hero / showcase