diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1f4c26..f709135a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## @wix/interact-validate +### [Unreleased] + +#### Added + +- `reducedMotionPolicy` accepted on interactions, sequences and effects, with checks that a named effect exists and is of the same kind (`REDUCED_MOTION_EFFECT_NOT_FOUND`, `REDUCED_MOTION_INCOMPATIBLE_EFFECT`) and the `REDUCED_MOTION` rule category (`REDUCED_MOTION_SELF_REFERENCE`, `REDUCED_MOTION_SHADOWED_EFFECT_ID`, `REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH`, `REDUCED_MOTION_COLLAPSE_CANCELS`) +- An effect named only by a `reducedMotionPolicy` is no longer reported as `UNUSED_EFFECT` + ### [0.2.1] - 2026-10-25 #### Added @@ -112,6 +119,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## @wix/interact +### [Unreleased] + +#### Added + +- `reducedMotionPolicy` on interactions, sequences and effects — `'collapse'` (default), `'ignore'`, `'cancel'` or an effectId — sets what an effect does under `prefers-reduced-motion: reduce`; the most specific level wins. `generate()` emits both outcomes under exclusive `prefers-reduced-motion` media, and the runtime picks the matching one, without adding effects to the config +- `reducedMotionPolicy` and `forceReducedMotion` options for `Interact.setup()` (every instance), `Interact.create()` (one instance) and `generate()` (defaulting to `Interact.setup()`'s) + +#### Changed + +- **Breaking:** under `prefers-reduced-motion: reduce`, effects collapse by default: they play with duration 0, keeping their delay and iterations, so they jump to their end state. Looping, `viewProgress` and `pointerMove` effects are dropped, and sequence staggers collapse. Pass `reducedMotionPolicy: 'ignore'` to `Interact.setup()` to keep the old behavior +- **Breaking:** `Interact.forceReducedMotion` applies the reduced-motion outcome of `reducedMotionPolicy` instead of passing `reducedMotion` to `@wix/motion`. With the default `'collapse'` that matches the old behavior, except that finite multi-iteration animations collapse to their end state instead of being dropped +- Runtime transitions collapse under `prefers-reduced-motion: reduce` through `reducedMotionPolicy`, matching `generate()`, instead of always being wrapped in `prefers-reduced-motion: no-preference` +- `reducedMotion` removed from `InteractOptions`, and the `context` argument from `Interact.getSequence()` / `Interact.addToSequence()` +- 0ms transitions that have a delay are kept, so the delay is honored + ### [2.7.0] - 2026-11-04 #### Added diff --git a/apps/website/assets/main.mjs b/apps/website/assets/main.mjs index 04b8042e..a57b1f81 100644 --- a/apps/website/assets/main.mjs +++ b/apps/website/assets/main.mjs @@ -1739,9 +1739,6 @@ updateTunnelBounds(); window.addEventListener('resize', buildGrid); window.addEventListener('resize', updateTunnelBounds); -// Respect reduced motion settings -Interact.forceReducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; - // Register presets Interact.registerEffects(presets); diff --git a/docs/reduced-motion-design.md b/docs/reduced-motion-design.md new file mode 100644 index 00000000..8817cae3 --- /dev/null +++ b/docs/reduced-motion-design.md @@ -0,0 +1,319 @@ +# Reduced-motion policy in `@wix/interact` — design + +## Context + +Today, reduced motion is handled inconsistently: + +- `Interact.forceReducedMotion` is a runtime-only static flag. Interact passes it to motion as `reducedMotion`. Motion then collapses 1-iteration animations to 1ms and drops looping ones (`packages/motion/src/api/webAnimations.ts`). `viewProgress` and `pointerMove` skip themselves entirely (`handlers/viewProgress.ts`, `handlers/pointerMove.ts`). +- In CSS mode the flag is ignored for animations. `getAnimation` finds the generated CSS animation first (`packages/motion/src/motion.ts`) and never applies `reducedMotion`. +- Runtime transitions always wrap `transition` in `@media (prefers-reduced-motion: no-preference)` (`createTransitionCSS` in `packages/interact/src/utils.ts`). Transitions from `generate()` don't (`effectToCSS` in `core/css.ts`). +- The OS `prefers-reduced-motion` setting is never read for animations. + +**Goal:** a declarative, cascading `reducedMotionPolicy` field with these properties: + +- Every outcome is defined for every trigger. +- The WAAPI and CSS paths match by construction. +- Effects are never pushed into config arrays. An effect's place in the config drives its fallback id (`getFallbackId`), keyframe slot (`getSlotKey`/`getKeyframeSlots`) and CSS list entries, so it must stay stable. + +**Decisions:** + +- The default is `collapse`, so reduced motion is respected out of the box. +- `Interact.forceReducedMotion` is folded into the new mechanism. +- The config gets no new top-level field. The instance-wide policy and forcing are `Interact.setup()` options (every instance) and `Interact.create()` options (one instance), like the other global settings. +- Fallback chain: `effectId` → `collapse` → `cancel`. + +--- + +## 1. API + +```ts +// a string other than the enum values is an effectId in config.effects +export type ReducedMotionPolicy = 'ignore' | 'cancel' | 'collapse' | (string & {}); +``` + +`reducedMotionPolicy?: ReducedMotionPolicy` is added to: + +- `InteractionTrigger` (so it flows through `...rest` into the cached interactions) +- `SequenceOptionsConfig` and `SequenceConfigRef` +- `EffectBase` (covers inline effects, `EffectRef`, and `config.effects` definitions) + +The instance-wide values stay outside the config, so it stays clean: + +```ts +Interact.setup({ reducedMotionPolicy: 'ignore', forceReducedMotion: false }); // every instance +Interact.create(config, { reducedMotionPolicy: 'cancel', forceReducedMotion: true }); // one instance +generate(config, { reducedMotionPolicy: 'cancel', forceReducedMotion: true }); // defaults to setup()'s +``` + +- `Interact.setup()` sets the statics `Interact.reducedMotionPolicy` (default `collapse`) and `Interact.forceReducedMotion` (default `false`). +- `Interact.create()` options override them for the instance. They're read once at `init` into `InteractCache`, since `parseConfig` derives keyframe slots from them. +- `generate()` takes the same two options, defaulting to the statics, and must get the instance's values, the same contract `configIndex` already has. + +### Cascade (most specific first) + +1. Inline effect +2. `config.effects[effectId]`. Items 1 and 2 come from the existing `{ ...effects[id], ...effect }` merge. +3. Sequence: the `SequenceConfigRef` field overrides the `config.sequences[id]` field. This level applies only to effects inside sequences. +4. Interaction +5. Instance (`Interact.create()` option, then `Interact.setup()`) +6. Default: `collapse` + +### Reduced-motion state + +The state is reduced when `(prefers-reduced-motion: reduce)` matches or when it is forced (§3.5). + +### Example + +```json +{ + "effects": { + "fade-in": { "namedEffect": { "type": "FadeIn" }, "duration": 300 }, + "fly-in": { "namedEffect": { "type": "FlyIn", "direction": "left" }, "duration": 800 } + }, + "interactions": [ + { + "key": "hero", + "trigger": "viewEnter", + "effects": [{ "effectId": "fly-in", "reducedMotionPolicy": "fade-in" }] + }, + { + "key": "bg", + "trigger": "viewProgress", + "reducedMotionPolicy": "cancel", + "effects": [{ "namedEffect": { "type": "ParallaxScroll" } }] + } + ] +} +``` + +## 2. Value semantics + +| Value | Under reduced motion | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `collapse` | Default. The effect is replaced in place by its collapsed form: duration 0, everything else kept (§3.3). It keeps its place and media, so winners never change. | +| `ignore` | Nothing changes, except a sequence's stagger (§3.3). | +| `cancel` | The effect gets the media condition `(prefers-reduced-motion: no-preference)` and behaves exactly like a user media condition. It drops out, and in variation competition ("media picks winner") an earlier variation for the same (interaction, target) can win. | +| `` | The effect is replaced in place by the body of `config.effects[effectId]`, keeping the original's identity and targeting (§3.2). Winners never change. | + +## 3. Core mechanism: variants under exclusive reserved media + +### 3.1 Stable effects and the shared builder + +Each effect in the config yields one or two variants. A variant is the effect with a reserved media condition appended. Both paths consume the same variant list: + +- **CSS** emits every variant under its media. +- **Runtime** picks the variant whose media matches and listens on it. This is the existing media-condition rule. + +A new file, `packages/interact/src/core/reducedMotion.ts`, holds the pure, shared logic: + +- `REDUCED_MOTION_CONDITIONS`: two reserved condition ids that can't collide with user ids: + - `__rm-reduce` → `prefers-reduced-motion: reduce` + - `__rm-no-preference` → `prefers-reduced-motion: no-preference` + + Both parsers merge them into their conditions map: + - runtime: `parseConfig` in `core/Interact.ts` + - CSS: `parseConfig` in `core/css.ts` + + `getMediaQuery` and `getFullPredicateByType` (`utils.ts`) then compose them unchanged. + +- `getReducedMotionPolicy(...policies)` implements the cascade in §1. Callers pass the policy values, most specific first, not the objects that hold them. +- `getReducedMotionVariants(effect, policy, { effects, force, collapsesStagger })` returns: + - `ignore` → `[effect]` + - `cancel` → `[effect + no-preference]` + - `collapse` → `[effect + no-preference, collapsed + reduce]` + - `` → `[effect + no-preference, replaced + reduce]` + + `collapse` and `` both pass through the fallback chain (§3.4). With `collapsesStagger` (§3.3), `ignore` yields `[effect + no-preference, effect + reduce]` so the reduce variant can drop the stagger. + +Variants never change indices, `effectId`, `interactionId`, `slotKey`, `suffix` or fallback ids. + +### 3.2 Kinds and the replacement merge + +Kinds: + +- `state` if the effect has `transition` or `transitionProperties` +- otherwise `time` if it has `duration` +- otherwise `scrub` + +This is the discriminator `effectToAnimationOptions` (`handlers/utilities.ts`) already uses to tell time from scrub, and the effect schemas encode it: time effects require `duration`, scroll and pointer effects forbid it. The same function classifies the original (merged with its definition) and the replacement. The replacement is compatible only if its kind equals the original's. + +Merging: + +- **Body fields come from the replacement:** `namedEffect`, `keyframeEffect`, `customEffect`, `transition`, `transitionProperties`, `duration`, `delay`, `easing`, `iterations`, `alternate`, `fill`, `reversed`, `composite`, `rangeStart`, `rangeEnd`, `centeredToTarget`, `transitionDuration`, `transitionEasing`. The original's body fields are removed first, which respects `OneOf`. The list is typed against the keys of `TimeEffect | ScrubEffect | StateEffect`, so adding a field to an effect type fails the build until the field is classified. +- **Kept from the original:** everything else: `key`, `listContainer`, `listItemSelector`, `selector`, `conditions`, `effectId`, `triggerType`, `stateAction`, `interactionId`, `suffix`, `$plugin` fields and the CSS resolver's `initial`. +- **Ignored on the replacement:** its own `key`, `conditions` and `reducedMotionPolicy`, so replacements don't chain. + +Keyframe naming: the builder names an unnamed replacement `keyframeEffect` after the **replacement's** id. Otherwise both paths would name it after the original `effectId` (`resolveEffectForCSS`, `effectToAnimationOptions`), and its keyframes would collide with the original's. + +### 3.3 Collapse transform + +- **time:** `duration: 0`. + - Motion's existing `duration || 1` normalization turns this into 1ms in both the `web` and `style` paths, so the paths match. + - Delay, finite iterations, alternate, reversed, fill, easing, composite and keyframes are kept, so the effect ends exactly where it would, including fractional and `alternate` iterations. The result is applied after the delay. + - With `fill: none`, the result is the base style. This matches what the uncollapsed animation leaves after it ends. + - The `alternate` and `state` trigger types still play, reverse and pause, just instantly. +- **state:** every transition entry gets duration 0 and keeps its delay. The state rule still applies. + - Today `transitionEffectToTransitionsList` (`utils.ts`) drops 0-duration entries. It must keep those that have a delay. Both paths use this helper. +- **No end state** (infinite iterations, i.e. `iterations` 0 or `Infinity`, or kind `scrub`): falls back to `cancel`. +- **Sequence stagger:** under reduced motion a sequence's `offset` becomes 0, so its items land together, unless the sequence's own policy (ref → definition → interaction → instance) is `ignore`. + - The sequence `delay` and effect delays are kept. + - Stagger is a sequence-level value, so it can't follow each effect's policy. Effects whose own policy is `ignore` inside such a sequence still get a reduce variant (`collapsesStagger`) that differs only in the stagger. This keeps the runtime's single `Sequence` offset and the CSS per-effect stagger `calc()` in agreement. + +### 3.4 Fallback chain + +1. `` is missing or has an incompatible kind → `collapse` +2. `collapse` has no end state → `cancel` +3. `cancel` always applies + +Both paths fall back silently, so they behave the same. The validator reports these cases (§6). + +### 3.5 Forced reduced motion (`forceReducedMotion` folded in) + +- With `force: true`, the builder returns only the reduced outcome, with no reserved condition: + - `ignore` → `[effect]` + - `cancel` → `[]`. The effect loses, as if a media condition didn't match. + - `collapse` / `` → `[reducedVariant]` +- **Runtime:** `force` is the instance's `forceReducedMotion` (§1). +- **CSS:** a new `GenerateOptions.forceReducedMotion` (`types/css.ts`), defaulting to `Interact.forceReducedMotion`. In CSS mode it must equal the instance's value. +- Interact stops passing `reducedMotion` to motion and to the handlers: + - It is removed from `InteractOptions` (`types/handlers.ts`). + - Its uses are removed from `core/add.ts`, all handlers and `effectHandlers.ts`. + - The `context` argument of `Interact.getSequence` / `addToSequence` is removed. + - `@wix/motion`'s own `reducedMotion` option stays as is. +- With the default `collapse`, `forceReducedMotion` reproduces the old behavior: 1-iteration animations become instant, looping and scrub animations are dropped. The only difference: animations with finite iterations > 1 now collapse to their end state instead of being dropped. + +## 4. Per-path implementation + +### 4.1 Runtime (`core/add.ts`) + +The merged effect goes through the builder at all three effect-resolution sites: + +- `_addInteraction` +- `addEffectsForTarget` +- `_buildAnimationGroupArgsFromSequence` + +```ts +const variants = getReducedMotionVariants(effect, getReducedMotionPolicy(effect.reducedMotionPolicy, inherited, instanceHandling), {...}); +variants.forEach((v, i) => mql(v) && instance.setupMediaQueryListener(`${baseId}::rm${i}`, mql(v), updateKey, onUpdate)); +const chosen = variants.find((v) => !mql(v) || mql(v).matches); +// no chosen variant → identical to today's "media didn't match" branch (return / return false / continue) +``` + +- `inherited` is the interaction's policy for interaction effects, and the sequence's resolved policy for sequence effects. +- `update()` is disconnect + connect. It rebuilds the handlers, the adopted sheet and the sequence cache, so a reduce toggle rides on the existing media machinery. +- `InteractCache` gains the instance's `reducedMotionPolicy` and `forceReducedMotion`. +- Sequences: `Interact.getSequence` gets the sequence options with `offset: 0` when the state is reduced and the sequence's policy isn't `ignore`. Every effect of such a sequence has a reduce variant, so its listeners cover a toggle. +- `addInteraction`: + - `createTransitionCSS` renders the chosen variant. + - Its hardcoded `no-preference` wrapper is removed. The default `collapse` collapses the transition instead, and the runtime matches `generate()`. +- The `animationEnd` source matching builds `sourceAnimationOptions` from the source's chosen variant. The source's policy resolves from `effects[id]` and the instance only, because the use site is unknown (§5). + +### 4.2 CSS (`core/resolvers.ts`, `core/css.ts`) + +- `resolveEffectForCSS` returns the effect with `variants: ResolvedEffect[]`. + - Each variant has its own `conditions`, including the reserved condition, and its own `initial` (`shouldUseInitial`). + - `resolveSequenceForCSS` resolves the sequence's policy once and passes it to its effects as the inherited value, so effect > definition > sequence holds. It also sets it on `ResolvedSequence.reducedMotionPolicy` for the stagger. +- `parseEffect` computes `customProps` once per effect. + - It runs `effectToCSS` per variant against the same `TargetContext`, unions `wrote`, then calls `endEffect` once. List lengths, slots and `@property` counts are unchanged. + - Each variant starts from the list entries assigned before the effect, and the effect leaves the union of what its variants assigned. The variants' media are exclusive, so each variant writes the same declarations it would write as the only effect in its place: it falls back only entries that an earlier variation assigned. Unused entries are left to their `@property` initial value. + - A reduce variant, or any variant when forced, is rendered with the sequence's `offset` set to 0 unless the sequence's policy is `ignore`. +- `collectKeyframeEntry` records each variant as a member with the same `slotKey`. + - `getKeyframeSlots` then picks a suffix that is collision-free for both name sets. + - The runtime stamps the same suffix in `parseConfig`. +- `initial` rules carry the variant's media. + - Under `cancel`, the element is not hidden on reduce. + - A `cancel` effect emits no reduced-motion rules at all. + - A 0-duration entrance (e.g. a collapsed one) emits only the hide rule (`:not([data-interact-enter])`), not its animation, and falls back the list entries assigned before it. A CSS animation shorter than a frame can finish before the runtime marks it `start`, and `start` would then re-create the animation held until `done`, paused at its first keyframe. Without a CSS animation, the runtime plays the collapsed entrance through WAAPI, as it does without `generate()`. It's detected from the effect's own `duration`, before motion normalizes it. +- Trigger-level CSS is never varied: the `viewProgress` `view-timeline` declaration (`triggerToCSS`) and the sequence list rules (`buildSequenceListsRule`). + +## 5. Flows per trigger, and granularity + +| Trigger / effect | `collapse` (default) | `ignore` | `cancel` | `` | +| ------------------------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| hover / click / activate / interest – time | 1ms, delay kept; alternate/state reverse and pause instantly | as today | dropped by media | time replacement | +| hover / click / activate / interest – transition | state applies; 0s transition with delay | as today (no runtime wrapper) | state rule and transition both dropped | state replacement; the state name stays the original `effectId` (same `toggleEffect`) | +| viewEnter – time | instant after delay; `initial` / `data-interact-enter` unchanged | as today | dropped by media; `initial` hide rule dropped too | time replacement; same `initial` flow | +| animationEnd – target | instant | as today | dropped | replacement | +| animationEnd – source | `animationend` / finish still fire after 1ms; chains continue | – | source never plays, so the chain never fires | the source is matched against the replacement's names | +| viewProgress | → `cancel` | as today | dropped by media | scrub replacement only | +| pointerMove | → `cancel` | as today | dropped | scrub replacement only | +| Sequences | per effect; stagger collapses, delays kept | per effect; stagger kept only if the sequence ignores | per effect; groups and indices recomputed at runtime, and CSS uses the runtime's `--motion--index` | per effect; stagger collapses | +| List items (`addListItems`) | same pure resolution | same | same | same | + +### Granularity limits + +1. **Sequence level is a default for its effects, and decides its stagger.** The sequence's `delay` never changes, and its stagger follows the sequence's policy, not its effects'. +2. **An animationEnd source** resolves its policy from its definition (`effects[id]`) and the instance only. Overrides at its use site (inline, sequence or interaction) must agree, or the chain filter mismatches. The validator warns about this. +3. **Triggers are never varied.** Listeners, observers and view-timelines stay. Only effects change. +4. **`forceReducedMotion` is per instance** (`Interact.create()` option, falling back to `Interact.setup()`'s), not per interaction or effect. + +## 6. Validation (`packages/interact-validate`) + +- **Schema** (`src/schema/effects.ts`, `interactions.ts`, `sequences.ts`): + - `z.union([ReducedMotionMode, z.string().min(1)])`, with `ReducedMotionMode = z.enum(['ignore', 'cancel', 'collapse'])`, which also gives the checks their mode set. + - Applied to interactions, inline/ref/definition effects, and inline/ref/definition sequences. The config root rejects it. + - Update the drift guard, `test/type-parity.spec.ts`. +- **No extra traversal.** The checks run inside the two existing `walkConfig` passes, and `walkConfig` hands each effect its parent sequence: + - Errors run in the `superRefine` walk: a missing effect named on an effect, and an incompatible replacement set on the effect or its definition. + - Warnings and info run in `collectSemanticWarnings`' walk. + - A replacement's kind comes from the effect schema it validates against (`StateEffect`, then `TimeEffect`, else scrub), memoized per definition object across both passes. An effect's own kind follows its trigger, as the interaction schemas pick the effect type by trigger. + - Unused-effect detection drops a referenced id in the `transform` walk that already collects references. + - The instance's policy isn't in the config, so the checks take it to be the default `collapse`. +- **Use sites.** An effect's policy depends on where it plays. A `config.sequences` entry plays wherever it's referenced: + - Issues from the effect's own policy (on it or its definition) are reported once, on the definition. + - Inherited issues (from the reference, the definition's sequence-level value or the interaction) are reported on each sequence reference. Each reference is a distinct path, so nothing is reported twice and nothing needs deduplicating. +- **Referential:** + - A string value must exist in `config.effects`. Explicit at effect level → error. Inherited → warning naming the fallback. + - An effect id equal to `ignore`, `cancel` or `collapse` is shadowed by the enum → warning. +- **Semantic** (`src/semantic/reducedMotion.ts`): + - Incompatible kind → error if explicit at effect level, warning if inherited. + - `collapse` → `cancel` fallback → info, only when a policy is set (the default isn't reported). + - Self-reference → warning. + - An animationEnd source whose use-site policy differs from its definition → warning. +- Update `packages/interact/rules/validate.md`. + +## 7. Behavior changes (release notes) + +1. Under OS reduce, effects collapse by default. Looping, `viewProgress` and `pointerMove` effects are dropped, and sequence staggers collapse. Opt out with `Interact.setup({ reducedMotionPolicy: 'ignore' })`. +2. `forceReducedMotion` applies the `reducedMotionPolicy` outcome. With the default `collapse` it keeps its old behavior, except that finite multi-iteration animations collapse to their end state instead of being dropped. +3. Runtime transitions collapse through `reducedMotionPolicy` instead of a fixed `no-preference` wrapper. +4. 0-duration transition entries that have a delay are now kept, so the delay is honored. + +## 8. Verification plan + +**Browser-verified** (Playwright Chromium / Firefox / WebKit, bundled with esbuild): + +- Run each row of §5 under `page.emulateMedia({ reducedMotion: 'reduce' | 'no-preference' })`, in two modes: + - WAAPI only, with no `generate()` CSS + - CSS mode, with `generate()` injected + + The two modes must produce identical computed styles and `getAnimations()` timing. + +- Toggle the media live to exercise listeners, the sequence cache and transitions. +- Confirm specifically: + - `animationend` / finish fire for collapsed 1ms CSS and WAAPI animations. + - A 0s transition with a delay applies after the delay in all three engines. + - The `initial` hide rule is never stuck under `cancel` or `collapse`. + - The sequence cache is invalidated on a reduce toggle for target-side sequences (`_processSequencesForTarget` keys by source). + - A collapsed sequence lands all its items together in both modes. + +**Unit tests:** + +- `packages/interact/test/css.spec.ts`: variants share list entries, list lengths are unchanged, media is exclusive, each variant falls back only earlier entries, keyframe slots work across variants, stagger collapses. +- `resolvers.spec.ts`, `sequences.spec.ts`, `conditions.spec.ts`, `web.spec.ts`: cascade, default, fallback chain, instance policy and force (`setup()` and per instance), stagger. +- New `reducedMotion.spec.ts` for the builder. +- interact-validate specs, including shared sequence definitions. +- Run `yarn build && yarn lint && yarn format:check && yarn test` (after `nvm use`). + +## 9. Files the implementation will touch + +- `packages/interact/src/types/`: `config.ts`, `effects.ts`, `internal.ts`, `handlers.ts`, `css.ts` +- `packages/interact/src/core/`: `reducedMotion.ts` (new), `add.ts`, `Interact.ts`, `css.ts`, `resolvers.ts` +- `packages/interact/src/utils.ts` +- `packages/interact/src/handlers/*` +- `packages/interact-validate/src/schema/*`, `packages/interact-validate/src/semantic/*`, `packages/interact-validate/src/walkConfig.ts` +- Docs: + - `packages/interact/rules/full-lean.md`, `integration.md`, `validate.md` + - `packages/interact/docs/api/types.md`, `interact-class.md` + - `packages/interact/docs/guides/conditions-and-media-queries.md` +- `CHANGELOG` diff --git a/packages/interact-validate/src/errors.ts b/packages/interact-validate/src/errors.ts index a91199ae..e2e5c3b4 100644 --- a/packages/interact-validate/src/errors.ts +++ b/packages/interact-validate/src/errors.ts @@ -34,6 +34,10 @@ const RULE_CODE_MAP: Record = { FUNCTION_OFFSET_EASING: 'OFFSET_EASING', NAMED_EFFECT_UNKNOWN_PARAM: 'NAMED_EFFECT_PARAMS', NAMED_EFFECT_INVALID_PARAM: 'NAMED_EFFECT_PARAMS', + REDUCED_MOTION_SELF_REFERENCE: 'REDUCED_MOTION', + REDUCED_MOTION_COLLAPSE_CANCELS: 'REDUCED_MOTION', + REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH: 'REDUCED_MOTION', + REDUCED_MOTION_SHADOWED_EFFECT_ID: 'REDUCED_MOTION', }; export function finalize( diff --git a/packages/interact-validate/src/schema/effects.ts b/packages/interact-validate/src/schema/effects.ts index f7683524..5553fa81 100644 --- a/packages/interact-validate/src/schema/effects.ts +++ b/packages/interact-validate/src/schema/effects.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { Keyframe, RangeOffset } from './primitives'; +import { Keyframe, RangeOffset, ReducedMotionPolicy } from './primitives'; import { withPluginFields } from './plugins'; export const StateActionType = z.enum(['add', 'remove', 'toggle', 'clear']); @@ -131,6 +131,7 @@ const EffectBase = { listContainer: z.string().optional(), listItemSelector: z.string().optional(), conditions: z.array(z.string().min(1)).optional(), + reducedMotionPolicy: ReducedMotionPolicy.optional(), }; export const StateEffect = withPluginFields( diff --git a/packages/interact-validate/src/schema/interactions.ts b/packages/interact-validate/src/schema/interactions.ts index ae90230a..660f59ef 100644 --- a/packages/interact-validate/src/schema/interactions.ts +++ b/packages/interact-validate/src/schema/interactions.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { Condition } from './primitives'; +import { Condition, ReducedMotionPolicy } from './primitives'; import { Effect, ViewProgressEffect, @@ -17,6 +17,7 @@ import { withPluginFields } from './plugins'; import type { Path, SemanticIssue } from '../types'; import { walkConfig } from '../walkConfig'; import { collectSemanticWarnings } from '../semantic'; +import { checkReducedMotionErrors } from '../semantic/reducedMotion'; export const TriggerType = z.enum([ 'hover', @@ -58,6 +59,7 @@ const InteractionBase = { listContainer: z.string().optional(), listItemSelector: z.string().optional(), conditions: z.array(z.string().min(1)).optional(), + reducedMotionPolicy: ReducedMotionPolicy.optional(), }; const hasEffectsOrSequences = (interaction: { effects?: unknown[]; sequences?: unknown[] }) => @@ -247,11 +249,14 @@ export const InteractConfigSchema = z onInteraction: (path, interaction) => { validateConditionReferences(ctx, path, configConditions, interaction.conditions); }, - onEffect: (path, effect, isTopLevel) => { + onEffect: (path, effect, isTopLevel, owner, sequence) => { validateConditionReferences(ctx, path, configConditions, effect.conditions); if (!isTopLevel) { validateEffectReference(ctx, path, effect, configEffects); } + checkReducedMotionErrors(config, path, effect, isTopLevel, owner, sequence).forEach( + ({ severity: _, ...issue }) => ctx.addIssue(issue as any), + ); }, onSequence: (path, sequence, isTopLevel) => { validateConditionReferences(ctx, path, configConditions, sequence.conditions); @@ -280,9 +285,11 @@ export const InteractConfigSchema = z walkConfig(config, { onInteraction: (_path, interaction) => { interaction.conditions?.forEach(Set.prototype.delete, conditionReferences); + effectIdReferences.delete(interaction.reducedMotionPolicy!); }, onEffect: (path, effect, isTopLevel) => { effect.conditions?.forEach(Set.prototype.delete, conditionReferences); + effectIdReferences.delete(effect.reducedMotionPolicy!); collectEffectKeyframeNames(warnings, path, keyframeNames, (effect as any).keyframeEffect); if (!isTopLevel && effect.effectId) { if (!configEffects[effect.effectId]) { @@ -298,6 +305,7 @@ export const InteractConfigSchema = z }, onSequence: (_path, sequence, isTopLevel) => { sequence.conditions?.forEach(Set.prototype.delete, conditionReferences); + effectIdReferences.delete(sequence.reducedMotionPolicy!); if (!isTopLevel && sequence.sequenceId) { sequenceIdReferences.delete(sequence.sequenceId); } diff --git a/packages/interact-validate/src/schema/primitives.ts b/packages/interact-validate/src/schema/primitives.ts index 8ac4354e..69374583 100644 --- a/packages/interact-validate/src/schema/primitives.ts +++ b/packages/interact-validate/src/schema/primitives.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; +export const ReducedMotionMode = z.enum(['ignore', 'cancel', 'collapse']); + +// a string other than the modes is an effectId in config.effects +export const ReducedMotionPolicy = z.union([ReducedMotionMode, z.string().min(1)]); + export const Keyframe = z.record(z.string(), z.union([z.string(), z.number()])); export const LengthPercentage = z diff --git a/packages/interact-validate/src/schema/sequences.ts b/packages/interact-validate/src/schema/sequences.ts index e0c70764..721b93e2 100644 --- a/packages/interact-validate/src/schema/sequences.ts +++ b/packages/interact-validate/src/schema/sequences.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { TimeEffect, TimeEffectRef, TimeTriggerType } from './effects'; +import { ReducedMotionPolicy } from './primitives'; const SequenceOptionsConfig = { delay: z.number().int().nonnegative().optional(), @@ -10,6 +11,7 @@ const SequenceOptionsConfig = { triggerType: TimeTriggerType.optional(), sequenceId: z.string().optional(), conditions: z.array(z.string().min(1)).optional(), + reducedMotionPolicy: ReducedMotionPolicy.optional(), }; export const SequenceConfig = z diff --git a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts index adf4e88c..f1a8b980 100644 --- a/packages/interact-validate/src/semantic/collectSemanticWarnings.ts +++ b/packages/interact-validate/src/semantic/collectSemanticWarnings.ts @@ -3,7 +3,8 @@ // inspect the parsed config shape only. Warning/info checks are collected by // `collectSemanticWarnings` (consumed by the schema `transform`). -import type { Path, SemanticIssue, AnyConfig, Visitors } from '../types'; +import type { SemanticIssue, AnyConfig } from '../types'; +import { walkConfig } from '../walkConfig'; import { checkFunctionOffsetEasing } from './cssGeneration'; import { checkCSSPropertyNames, checkInvalidInset } from './cssSyntax'; import { checkSameElementRetrigger, checkHitAreaShift } from './fouc'; @@ -16,41 +17,11 @@ import { checkEmptyStyleProperties, checkStateRemoveWithoutEffectId } from './pa import { checkRecommendedFill } from './recommendedPatterns'; import { findAnimationEndWarnings } from './animationEndGraph'; import { checkNamedEffectParams } from './namedEffectParams'; - -// Single traversal of top-level registry effects/sequences and per-interaction -// effects/sequences, supplying each node's `path`, whether it is a top-level -// registry definition, and its owning interaction (when known). -export function walkConfig(config: AnyConfig, visitors: Visitors): void { - const { onInteraction, onEffect, onSequence } = visitors; - - Object.entries(config.effects ?? {}).forEach(([id, effect]) => { - onEffect(['effects', id], effect, true, undefined); - }); - - Object.entries(config.sequences ?? {}).forEach(([id, sequence]) => { - onSequence(['sequences', id], sequence, true, undefined); - sequence.effects?.forEach((effect, ei) => { - onEffect(['sequences', id, 'effects', ei], effect, false, undefined); - }); - }); - - config.interactions.forEach((interaction, i) => { - onInteraction?.(['interactions', i], interaction); - interaction.effects?.forEach((effect, ei) => { - onEffect(['interactions', i, 'effects', ei], effect, false, interaction); - }); - interaction.sequences?.forEach((sequence, si) => { - const seqPath: Path = ['interactions', i, 'sequences', si]; - onSequence(seqPath, sequence, false, interaction); - sequence.effects?.forEach((effect, ei) => { - onEffect([...seqPath, 'effects', ei], effect, false, interaction); - }); - }); - }); -} +import { createReducedMotionWarnings } from './reducedMotion'; // Collect every warning/info-level semantic issue (consumed by `transform`). export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { + const reducedMotion = createReducedMotionWarnings(config); const warnings: SemanticIssue[] = []; walkConfig(config, { @@ -58,9 +29,10 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { warnings.push(...checkListItemSelectorWithoutContainer(path, interaction)); warnings.push(...checkRedundantSelector(path, interaction)); warnings.push(...checkInvalidInset(path, interaction)); + warnings.push(...reducedMotion.onInteraction(path, interaction)); }, // checks that only look at depth>1 properties in effects/sequences do not need resolving - onEffect: (path, effect, isTopLevel, owner) => { + onEffect: (path, effect, isTopLevel, owner, sequence) => { const { effectId } = effect; const resolvedEffect = !isTopLevel && effectId @@ -76,6 +48,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner)); warnings.push(...checkCSSPropertyNames(path, effect)); warnings.push(...checkNamedEffectParams(path, effect)); + warnings.push(...reducedMotion.onEffect(path, effect, isTopLevel, owner, sequence)); }, onSequence: (path, sequence, isTopLevel, owner) => { const { sequenceId } = sequence; @@ -85,6 +58,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] { : sequence; warnings.push(...checkSameElementRetrigger(path, resolvedSequence, owner)); warnings.push(...checkFunctionOffsetEasing(path, sequence)); + warnings.push(...reducedMotion.onSequence(path, sequence, isTopLevel, owner)); }, }); diff --git a/packages/interact-validate/src/semantic/issue.ts b/packages/interact-validate/src/semantic/issue.ts new file mode 100644 index 00000000..b241fac8 --- /dev/null +++ b/packages/interact-validate/src/semantic/issue.ts @@ -0,0 +1,8 @@ +import type { Path, SemanticIssue } from '../types'; + +export const issue = ( + path: Path, + domainCode: string, + message: string, + severity: SemanticIssue['severity'], +) => ({ code: 'custom', path, message, params: { domainCode }, severity }) as SemanticIssue; diff --git a/packages/interact-validate/src/semantic/namedEffectParams.ts b/packages/interact-validate/src/semantic/namedEffectParams.ts index b4ecfafc..3ca879f9 100644 --- a/packages/interact-validate/src/semantic/namedEffectParams.ts +++ b/packages/interact-validate/src/semantic/namedEffectParams.ts @@ -1,5 +1,6 @@ import { Interact } from '@wix/interact'; import type { AnyEffect, EffectParamSchema, EffectSchema, Path, SemanticIssue } from '../types'; +import { issue } from './issue'; const LENGTH_REGEX = /^-?\d*\.?\d+(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc)?$/i; const ANGLE_REGEX = /^-?\d*\.?\d+(deg|rad|grad|turn)?$/i; @@ -58,13 +59,6 @@ const PARAM_TYPES: Record< }, }; -const issue = ( - path: Path, - domainCode: string, - message: string, - severity: SemanticIssue['severity'], -) => ({ code: 'custom', path, message, params: { domainCode }, severity }) as SemanticIssue; - // the namedEffect's params against the schema of the effect registered on Interact under its type export function checkNamedEffectParams(path: Path, effect: AnyEffect): SemanticIssue[] { const { type, ...params } = effect.namedEffect ?? {}; diff --git a/packages/interact-validate/src/semantic/recommendedPatterns.ts b/packages/interact-validate/src/semantic/recommendedPatterns.ts index decf7bab..71af5c8f 100644 --- a/packages/interact-validate/src/semantic/recommendedPatterns.ts +++ b/packages/interact-validate/src/semantic/recommendedPatterns.ts @@ -1,5 +1,5 @@ import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types'; -import { RETRIGGER_TYPES } from '../types'; +import { RETRIGGER_TYPES, SCRUB_TRIGGERS } from '../types'; function isKeyframeEffect(effect: AnyEffect): boolean { return !!(effect.namedEffect || effect.keyframeEffect); @@ -12,7 +12,7 @@ export function checkRecommendedFill( owner?: AnyInteraction, ): SemanticIssue[] { if (effect.fill === 'both') return []; - const isScrubbed = owner?.trigger === 'viewProgress' || owner?.trigger === 'pointerMove'; + const isScrubbed = !!owner?.trigger && SCRUB_TRIGGERS.includes(owner.trigger); const isToggling = effect.triggerType !== undefined && RETRIGGER_TYPES.includes(effect.triggerType); diff --git a/packages/interact-validate/src/semantic/reducedMotion.ts b/packages/interact-validate/src/semantic/reducedMotion.ts new file mode 100644 index 00000000..44fa2084 --- /dev/null +++ b/packages/interact-validate/src/semantic/reducedMotion.ts @@ -0,0 +1,235 @@ +import { StateEffect, TimeEffect } from '../schema/effects'; +import { ReducedMotionMode } from '../schema/primitives'; +import type { + AnyConfig, + AnyEffect, + AnyInteraction, + AnySequence, + Path, + SemanticIssue, +} from '../types'; +import { SCRUB_TRIGGERS } from '../types'; +import { issue } from './issue'; + +type Kind = 'time' | 'scrub' | 'state'; + +type UseSite = { + effect: AnyEffect; + // set on the effect or its definition + explicit?: string; + // from its sequence or interaction - unknown for a sequence definition's effect + inherited?: string; + kind: Kind; +}; + +const MODES: ReadonlySet = new Set(ReducedMotionMode.options); + +const replacementKinds = new WeakMap(); + +// the effect schema the replacement validates against +function getReplacementKind(replacement: AnyEffect): Kind { + let kind = replacementKinds.get(replacement); + if (!kind) { + kind = StateEffect.safeParse(replacement).success + ? 'state' + : TimeEffect.safeParse(replacement).success + ? 'time' + : 'scrub'; + replacementKinds.set(replacement, kind); + } + return kind; +} + +// the effect schema the interaction picks for its trigger - sequences only play on time triggers +function getKind(effect: AnyEffect, trigger?: string): Kind { + if (effect.transition || effect.transitionProperties) return 'state'; + return trigger && SCRUB_TRIGGERS.includes(trigger) ? 'scrub' : 'time'; +} + +function getUseSite( + config: AnyConfig, + effect: AnyEffect, + owner?: AnyInteraction, + sequence?: AnySequence, +): UseSite { + const definition = effect.effectId ? config.effects?.[effect.effectId] : undefined; + const resolved = { ...definition, ...effect }; + return { + effect: resolved, + explicit: effect.reducedMotionPolicy ?? definition?.reducedMotionPolicy, + inherited: owner && (sequence?.reducedMotionPolicy ?? owner.reducedMotionPolicy), + kind: getKind(resolved, owner?.trigger), + }; +} + +function getReplacement(config: AnyConfig, policy?: string): AnyEffect | undefined { + return policy && !MODES.has(policy) ? config.effects?.[policy] : undefined; +} + +function isIncompatible(config: AnyConfig, { effect, kind }: UseSite, policy?: string): boolean { + const replacement = getReplacement(config, policy); + return !!replacement && policy !== effect.effectId && getReplacementKind(replacement) !== kind; +} + +function checkReference( + config: AnyConfig, + path: Path, + policy: string | undefined, + isEffect: boolean, +): SemanticIssue[] { + if (!policy || MODES.has(policy) || config.effects?.[policy]) return []; + return [ + issue( + [...path, 'reducedMotionPolicy'], + 'REDUCED_MOTION_EFFECT_NOT_FOUND', + `reducedMotionPolicy effect "${policy}" not found${isEffect ? '' : ', so the effects inheriting it fall back to "collapse"'}`, + isEffect ? 'error' : 'warning', + ), + ]; +} + +function incompatible(path: Path, policy: string, site: UseSite, severity: 'error' | 'warning') { + return issue( + path, + 'REDUCED_MOTION_INCOMPATIBLE_EFFECT', + `reducedMotionPolicy effect "${policy}" can't replace a ${site.kind} effect, so it falls back to "collapse"`, + severity, + ); +} + +export function checkReducedMotionErrors( + config: AnyConfig, + path: Path, + effect: AnyEffect, + isTopLevel: boolean, + owner?: AnyInteraction, + sequence?: AnySequence, +): SemanticIssue[] { + const issues = checkReference(config, path, effect.reducedMotionPolicy, true); + if (isTopLevel) return issues; + + const site = getUseSite(config, effect, owner, sequence); + if (site.explicit && isIncompatible(config, site, site.explicit)) { + issues.push(incompatible(path, site.explicit, site, 'error')); + } + return issues; +} + +// the warning and info issues - an explicit incompatible replacement is an error instead. The instance's policy +// isn't part of the config, so it's taken to be the default 'collapse' +export function createReducedMotionWarnings(config: AnyConfig) { + const animationEndSources = new Set( + config.interactions.flatMap(({ trigger, params }) => + trigger === 'animationEnd' && params?.effectId ? [params.effectId] : [], + ), + ); + + const checkUseSite = (path: Path, site: UseSite): SemanticIssue[] => { + const { effect, explicit, inherited, kind } = site; + const policy = explicit ?? inherited ?? 'collapse'; + const issues: SemanticIssue[] = []; + + // the runtime resolves an animationEnd source's policy from its definition and the instance only + if (effect.effectId && animationEndSources.has(effect.effectId)) { + const sourcePolicy = config.effects?.[effect.effectId]?.reducedMotionPolicy ?? 'collapse'; + if (policy !== sourcePolicy) { + issues.push( + issue( + path, + 'REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH', + `Effect "${effect.effectId}" triggers animationEnd interactions, which resolve its reducedMotionPolicy from its definition only - set it there instead`, + 'warning', + ), + ); + } + } + + // the default needs no notice + if (!explicit && !inherited) return issues; + + let collapses = policy === 'collapse'; + if (!MODES.has(policy)) { + if (policy === effect.effectId) { + issues.push( + issue( + path, + 'REDUCED_MOTION_SELF_REFERENCE', + `reducedMotionPolicy replaces effect "${policy}" with itself, which is the same as "ignore"`, + 'warning', + ), + ); + } else if (!getReplacement(config, policy)) { + collapses = true; + } else if (isIncompatible(config, site, policy)) { + collapses = true; + if (!explicit) { + issues.push(incompatible(path, policy, site, 'warning')); + } + } + } + + if ( + collapses && + (kind === 'scrub' || effect.iterations === 0 || effect.iterations === Infinity) + ) { + issues.push( + issue( + path, + 'REDUCED_MOTION_COLLAPSE_CANCELS', + `A ${kind === 'scrub' ? 'scroll or pointer driven' : 'looping'} effect has no end state to collapse to, so reduced motion cancels it`, + 'info', + ), + ); + } + + return issues; + }; + + return { + onInteraction: (path: Path, interaction: AnyInteraction) => + checkReference(config, path, interaction.reducedMotionPolicy, false), + onEffect: ( + path: Path, + effect: AnyEffect, + isTopLevel: boolean, + owner?: AnyInteraction, + sequence?: AnySequence, + ): SemanticIssue[] => { + if (isTopLevel) { + const id = path[1] as string; + return MODES.has(id) + ? [ + issue( + path, + 'REDUCED_MOTION_SHADOWED_EFFECT_ID', + `Effect "${id}" can't be used as a reducedMotionPolicy replacement since "${id}" is a policy mode`, + 'warning', + ), + ] + : []; + } + const site = getUseSite(config, effect, owner, sequence); + // a sequence definition's effect inherits its policy where the sequence is referenced + return owner || site.explicit ? checkUseSite(path, site) : []; + }, + onSequence: ( + path: Path, + sequence: AnySequence, + isTopLevel: boolean, + owner?: AnyInteraction, + ): SemanticIssue[] => { + const issues = checkReference(config, path, sequence.reducedMotionPolicy, false); + const definition = + !isTopLevel && !sequence.effects && sequence.sequenceId + ? config.sequences?.[sequence.sequenceId] + : undefined; + definition?.effects?.forEach((effect) => { + const site = getUseSite(config, effect, owner, { ...definition, ...sequence }); + if (!site.explicit) { + issues.push(...checkUseSite(path, site)); + } + }); + return issues; + }, + }; +} diff --git a/packages/interact-validate/src/types.ts b/packages/interact-validate/src/types.ts index 86c82fdb..8f32383b 100644 --- a/packages/interact-validate/src/types.ts +++ b/packages/interact-validate/src/types.ts @@ -1,4 +1,5 @@ export const RETRIGGER_TYPES = ['repeat', 'alternate', 'state']; +export const SCRUB_TRIGGERS = ['viewProgress', 'pointerMove']; export type Severity = 'error' | 'warning' | 'info'; @@ -52,6 +53,9 @@ export type AnyEffect = { stateAction?: string; fill?: string; delay?: number; + duration?: number; + iterations?: number; + reducedMotionPolicy?: string; namedEffect?: { type?: string; range?: unknown; [k: string]: unknown }; keyframeEffect?: { name?: string; keyframes?: Array> }; transition?: { styleProperties?: unknown[] }; @@ -67,6 +71,7 @@ export type AnySequence = { offsetEasing?: string | ((...args: unknown[]) => unknown); effects?: AnyEffect[]; conditions?: string[]; + reducedMotionPolicy?: string; }; export type AnyInteraction = { @@ -79,6 +84,7 @@ export type AnyInteraction = { effects?: AnyEffect[]; sequences?: AnySequence[]; conditions?: string[]; + reducedMotionPolicy?: string; }; export type AnyConfig = { @@ -92,7 +98,14 @@ export type Visitors = { // `owner` is the parent interaction (carrying trigger/key/selector/params), or // `undefined` for top-level registry effects/sequences whose trigger context is // unknown until their reference site. Trigger-aware checks skip `owner === undefined`. - onEffect: (path: Path, effect: AnyEffect, isTopLevel: boolean, owner?: AnyInteraction) => void; + // `sequence` is the effect's parent sequence, when it's in one. + onEffect: ( + path: Path, + effect: AnyEffect, + isTopLevel: boolean, + owner?: AnyInteraction, + sequence?: AnySequence, + ) => void; onSequence: ( path: Path, sequence: AnySequence, diff --git a/packages/interact-validate/src/walkConfig.ts b/packages/interact-validate/src/walkConfig.ts index d1d2c112..e2a5d408 100644 --- a/packages/interact-validate/src/walkConfig.ts +++ b/packages/interact-validate/src/walkConfig.ts @@ -13,7 +13,7 @@ export function walkConfig(config: AnyConfig, visitors: Visitors): void { Object.entries(config.sequences ?? {}).forEach(([id, sequence]) => { onSequence(['sequences', id], sequence, true, undefined); sequence.effects?.forEach((effect, ei) => { - onEffect(['sequences', id, 'effects', ei], effect, false, undefined); + onEffect(['sequences', id, 'effects', ei], effect, false, undefined, sequence); }); }); @@ -26,7 +26,7 @@ export function walkConfig(config: AnyConfig, visitors: Visitors): void { const seqPath: Path = ['interactions', i, 'sequences', si]; onSequence(seqPath, sequence, false, interaction); sequence.effects?.forEach((effect, ei) => { - onEffect([...seqPath, 'effects', ei], effect, false, interaction); + onEffect([...seqPath, 'effects', ei], effect, false, interaction, sequence); }); }); }); diff --git a/packages/interact-validate/test/rules/reducedMotion.spec.ts b/packages/interact-validate/test/rules/reducedMotion.spec.ts new file mode 100644 index 00000000..0453e2e5 --- /dev/null +++ b/packages/interact-validate/test/rules/reducedMotion.spec.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import { validateInteractConfig } from '../../src'; + +const fade = { namedEffect: { type: 'FadeIn' }, duration: 300 }; +const slide = { namedEffect: { type: 'SlideIn' }, duration: 600 }; +const dim = { transition: { duration: 200, styleProperties: [{ name: 'opacity', value: '0.5' }] } }; + +const issuesOf = (config: unknown, code: string) => + validateInteractConfig(config).errors.filter((e) => e.code === code); + +describe('reducedMotionPolicy', () => { + it('accepts the field at every level', () => { + const result = validateInteractConfig({ + effects: { fade: { ...fade, reducedMotionPolicy: 'ignore' } }, + sequences: { seq: { reducedMotionPolicy: 'cancel', effects: [{ effectId: 'fade' }] } }, + interactions: [ + { + key: 'el', + trigger: 'click', + reducedMotionPolicy: 'fade', + effects: [{ ...slide, reducedMotionPolicy: 'fade' }], + sequences: [{ sequenceId: 'seq', reducedMotionPolicy: 'collapse' }], + }, + ], + }); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('rejects it on the config, where the instance sets it', () => { + expect( + issuesOf( + { + reducedMotionPolicy: 'ignore', + interactions: [{ key: 'el', trigger: 'click', effects: [slide] }], + }, + 'SCHEMA_UNRECOGNIZED_KEYS', + ), + ).toHaveLength(1); + }); + + it('counts a replacement as a reference of its effect', () => { + expect( + issuesOf( + { + effects: { fade }, + interactions: [ + { key: 'el', trigger: 'click', effects: [{ ...slide, reducedMotionPolicy: 'fade' }] }, + ], + }, + 'UNUSED_EFFECT', + ), + ).toHaveLength(0); + }); + + describe('REDUCED_MOTION_EFFECT_NOT_FOUND', () => { + it('is an error on an effect', () => { + const [issue, ...rest] = issuesOf( + { + interactions: [ + { key: 'el', trigger: 'click', effects: [{ ...slide, reducedMotionPolicy: 'nope' }] }, + ], + }, + 'REDUCED_MOTION_EFFECT_NOT_FOUND', + ); + + expect(rest).toEqual([]); + expect(issue).toMatchObject({ + severity: 'error', + path: ['interactions', 0, 'effects', 0, 'reducedMotionPolicy'], + }); + }); + + it('is a warning where effects inherit it', () => { + const issues = issuesOf( + { + interactions: [ + { key: 'el', trigger: 'click', reducedMotionPolicy: 'nope', effects: [slide] }, + ], + }, + 'REDUCED_MOTION_EFFECT_NOT_FOUND', + ); + + expect(issues).toEqual([ + expect.objectContaining({ + severity: 'warning', + path: ['interactions', 0, 'reducedMotionPolicy'], + }), + ]); + }); + }); + + describe('REDUCED_MOTION_INCOMPATIBLE_EFFECT', () => { + it('is an error when set on the effect', () => { + expect( + issuesOf( + { + effects: { dim }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ ...slide, reducedMotionPolicy: 'dim' }], + }, + ], + }, + 'REDUCED_MOTION_INCOMPATIBLE_EFFECT', + ), + ).toEqual([ + expect.objectContaining({ severity: 'error', path: ['interactions', 0, 'effects', 0] }), + ]); + }); + + it('is a warning when inherited', () => { + expect( + issuesOf( + { + effects: { fade }, + interactions: [ + { + key: 'el', + trigger: 'click', + reducedMotionPolicy: 'fade', + effects: [{ effectId: 'fade' }, dim], + }, + { + key: 'el', + trigger: 'viewProgress', + reducedMotionPolicy: 'fade', + effects: [{ namedEffect: { type: 'FadeScroll' } }], + }, + ], + }, + 'REDUCED_MOTION_INCOMPATIBLE_EFFECT', + ).map(({ severity, path }) => ({ severity, path })), + ).toEqual([ + { severity: 'warning', path: ['interactions', 0, 'effects', 1] }, + { severity: 'warning', path: ['interactions', 1, 'effects', 0] }, + ]); + }); + }); + + it('informs that collapsing an effect with no end state cancels it', () => { + expect( + issuesOf( + { + interactions: [ + { + key: 'el', + trigger: 'click', + reducedMotionPolicy: 'collapse', + effects: [slide, { ...slide, iterations: 0 }], + }, + { + key: 'el', + trigger: 'viewProgress', + reducedMotionPolicy: 'collapse', + effects: [{ namedEffect: { type: 'FadeScroll' } }], + }, + ], + }, + 'REDUCED_MOTION_COLLAPSE_CANCELS', + ).map(({ severity, path }) => ({ severity, path })), + ).toEqual([ + { severity: 'info', path: ['interactions', 0, 'effects', 1] }, + { severity: 'info', path: ['interactions', 1, 'effects', 0] }, + ]); + }); + + describe('sequence definitions', () => { + it('reports what an effect inherits where its sequence is referenced, once per reference', () => { + expect( + issuesOf( + { + effects: { dim }, + sequences: { seq: { reducedMotionPolicy: 'dim', effects: [slide] } }, + interactions: [ + { key: 'a', trigger: 'click', sequences: [{ sequenceId: 'seq' }] }, + { key: 'b', trigger: 'hover', sequences: [{ sequenceId: 'seq' }] }, + ], + }, + 'REDUCED_MOTION_INCOMPATIBLE_EFFECT', + ).map(({ severity, path }) => ({ severity, path })), + ).toEqual([ + { severity: 'warning', path: ['interactions', 0, 'sequences', 0] }, + { severity: 'warning', path: ['interactions', 1, 'sequences', 0] }, + ]); + }); + + it("reports an effect's own policy once, on the definition", () => { + expect( + issuesOf( + { + sequences: { + seq: { effects: [{ ...slide, iterations: 0, reducedMotionPolicy: 'collapse' }] }, + }, + interactions: [ + { key: 'a', trigger: 'click', sequences: [{ sequenceId: 'seq' }] }, + { key: 'b', trigger: 'hover', sequences: [{ sequenceId: 'seq' }] }, + ], + }, + 'REDUCED_MOTION_COLLAPSE_CANCELS', + ).map(({ path }) => path), + ).toEqual([['sequences', 'seq', 'effects', 0]]); + }); + }); + + it('tells a time replacement from a scrub one by the effect schema it validates against', () => { + expect( + issuesOf( + { + effects: { fade, scrub: { namedEffect: { type: 'FadeScroll' } } }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { ...slide, reducedMotionPolicy: 'fade' }, + { ...slide, reducedMotionPolicy: 'scrub' }, + ], + }, + ], + }, + 'REDUCED_MOTION_INCOMPATIBLE_EFFECT', + ).map(({ path }) => path), + ).toEqual([['interactions', 0, 'effects', 1]]); + }); + + it('warns about a replacement by the effect itself', () => { + expect( + issuesOf( + { + effects: { fade }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ effectId: 'fade', reducedMotionPolicy: 'fade' }], + }, + ], + }, + 'REDUCED_MOTION_SELF_REFERENCE', + ), + ).toHaveLength(1); + }); + + it('warns about an effect id shadowed by a policy mode', () => { + expect( + issuesOf( + { + effects: { collapse: fade }, + interactions: [{ key: 'el', trigger: 'click', effects: [{ effectId: 'collapse' }] }], + }, + 'REDUCED_MOTION_SHADOWED_EFFECT_ID', + ), + ).toEqual([expect.objectContaining({ path: ['effects', 'collapse'] })]); + }); + + describe('REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH', () => { + const configWith = (sourcePolicy?: string, definitionHandling?: string) => ({ + effects: { fade: { ...fade, reducedMotionPolicy: definitionHandling } }, + interactions: [ + { + key: 'a', + trigger: 'viewEnter', + effects: [{ effectId: 'fade', reducedMotionPolicy: sourcePolicy }], + }, + { key: 'b', trigger: 'animationEnd', params: { effectId: 'fade' }, effects: [slide] }, + ], + }); + + it('warns when the source resolves a different policy at its use site', () => { + expect( + issuesOf(configWith('cancel'), 'REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH'), + ).toEqual([expect.objectContaining({ path: ['interactions', 0, 'effects', 0] })]); + }); + + it('accepts a policy set on the definition', () => { + expect( + issuesOf(configWith(undefined, 'cancel'), 'REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH'), + ).toHaveLength(0); + }); + }); +}); diff --git a/packages/interact-validate/test/type-parity.spec.ts b/packages/interact-validate/test/type-parity.spec.ts index 59ac58ba..496439b8 100644 --- a/packages/interact-validate/test/type-parity.spec.ts +++ b/packages/interact-validate/test/type-parity.spec.ts @@ -52,6 +52,16 @@ describe('schema type parity (drift guard)', () => { >(); }); + it('reducedMotionPolicy is accepted on interactions, effects and sequences (mirrors ReducedMotionPolicy)', () => { + type Canonical = InteractConfig['interactions'][number]['reducedMotionPolicy']; + type InferredInteraction = InferredConfig['interactions'][number]; + type InferredEffect = NonNullable[string]; + type InferredSequence = NonNullable[string]; + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); + it('interaction/effect schemas accept $-prefixed plugin fields (mirrors PluginFields)', () => { // Both the zod-inferred type and the canonical type accept an arbitrary `$`-prefixed field. type InferredInteraction = InferredConfig['interactions'][number]; diff --git a/packages/interact/docs/api/interact-class.md b/packages/interact/docs/api/interact-class.md index 56e6f63a..9b77d6c2 100644 --- a/packages/interact/docs/api/interact-class.md +++ b/packages/interact/docs/api/interact-class.md @@ -22,7 +22,12 @@ class Interact { // Static methods static create( config: InteractConfig, - options?: { useCustomElement?: boolean; configIndex?: number }, + options?: { + useCustomElement?: boolean; + configIndex?: number; + forceReducedMotion?: boolean; + reducedMotionPolicy?: ReducedMotionPolicy; + }, ): Interact; static getInstance(key: string): Interact | undefined; static destroy(): void; @@ -31,6 +36,8 @@ class Interact { pointerOptionsGetter?: () => Partial; viewEnter?: Partial; allowA11yTriggers?: boolean; + forceReducedMotion?: boolean; + reducedMotionPolicy?: ReducedMotionPolicy; }): void; static registerEffects(effects: Record): void; static getController(key: string | undefined): IInteractionController | undefined; @@ -61,6 +68,8 @@ Creates a new `Interact` instance with the provided configuration and initialize - `config: InteractConfig` - The interaction configuration object - `options?.useCustomElement?: boolean` - Whether elements are wrapped in `` (defaults to whether the custom element is available) - `options?.configIndex?: number` - The config's index when several configs are on the same page. It must match the `configIndex` passed to [`generate()`](./functions.md#generateconfig-options) for the same config. Unnamed effects and sequences get ids suffixed by it (`eff-c1-0-0`), which become their generated keyframe and state names, so the configs' names don't collide. +- `options?.forceReducedMotion?: boolean` - Forces reduced motion for this instance, overriding the one set by [`Interact.setup()`](#interactsetupoptions). It must match the `forceReducedMotion` passed to `generate()` for the same config. +- `options?.reducedMotionPolicy?: ReducedMotionPolicy` - The [`ReducedMotionPolicy`](./types.md#reducedmotionpolicy) of the instance's effects that don't set one on their interaction, sequence or effect, overriding the one set by `Interact.setup()`. It must match the `reducedMotionPolicy` passed to `generate()` for the same config. **Returns:** `Interact` - The initialized interact instance @@ -144,8 +153,10 @@ Configures global settings for the Interact system. - `options.pointerOptionsGetter` - Optional callback returning default partial pointer config for `pointerMove` trigger - `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 +- `options.forceReducedMotion` - When `true`, applies the reduced-motion outcome of each effect's `reducedMotionPolicy` regardless of the OS setting (sets `Interact.forceReducedMotion`) +- `options.reducedMotionPolicy` - The default [`ReducedMotionPolicy`](./types.md#reducedmotionpolicy) of every instance's effects (`'collapse'` unless set; sets `Interact.reducedMotionPolicy`) -To force reduced motion globally, set `Interact.forceReducedMotion = true` (static property, not via `setup`). +Both reduced-motion options can be overridden per instance with `Interact.create(config, options)`. `generate()` defaults to the values set here, so set them before generating, or pass the instance's values to `generate()`. **Example:** @@ -165,6 +176,9 @@ Interact.setup({ }), }); +// Keep effects as they are for users who prefer reduced motion, unless an effect sets its own policy +Interact.setup({ reducedMotionPolicy: 'ignore' }); + // Enable accessibility for all click and hover triggers Interact.setup({ allowA11yTriggers: true, diff --git a/packages/interact/docs/api/types.md b/packages/interact/docs/api/types.md index 0f331ce1..24a96008 100644 --- a/packages/interact/docs/api/types.md +++ b/packages/interact/docs/api/types.md @@ -274,7 +274,6 @@ Options passed to interaction handlers. ```typescript type InteractOptions = { - reducedMotion?: boolean; targetController?: IInteractionController; selectorCondition?: string; allowA11yTriggers?: boolean; @@ -283,7 +282,6 @@ type InteractOptions = { **Properties:** -- `reducedMotion` - Whether reduced motion is enabled (respects `prefers-reduced-motion` or `Interact.forceReducedMotion`) - `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 +291,6 @@ type InteractOptions = { ```typescript // Used internally by handlers const options: InteractOptions = { - reducedMotion: Interact.forceReducedMotion, targetController: Interact.getController('my-element'), }; ``` @@ -864,6 +861,27 @@ type Interaction = InteractionTrigger & { An interaction can have `effects` only, `sequences` only, or both. +## Reduced Motion Types + +### `ReducedMotionPolicy` + +What an effect does when the user prefers reduced motion: `(prefers-reduced-motion: reduce)` matches, or reduced motion is forced (`forceReducedMotion` of `Interact.setup()` or `Interact.create()`). + +```typescript +type ReducedMotionPolicy = 'ignore' | 'cancel' | 'collapse' | (string & {}); // string = effectId +``` + +Accepted on `Interaction`, `SequenceConfig` / `SequenceConfigRef` and effects, and per instance as an option of `Interact.create()` / `Interact.setup()` (it isn't a config field). The most specific level wins: inline effect → its `effects` registry entry → sequence → interaction → instance → `'collapse'`. + +- `'collapse'` (default) - Plays with duration 0, keeping everything else (delay, iterations, fill, keyframes), so it jumps to its end state. Transitions become 0ms and keep their delay. Effects with no end state (infinite iterations, `viewProgress` / `pointerMove`) are cancelled instead. +- `'ignore'` - Plays as usual. +- `'cancel'` - Dropped, exactly like a `(prefers-reduced-motion: no-preference)` media condition. +- effectId - Replaced by the animation of `effects[effectId]` (effect type, keyframes and timing), keeping the original's target, conditions, `effectId` and `triggerType`. It must be of the same kind (time, scrub or state) and exist, otherwise the effect collapses. + +A sequence's stagger (`offset`) collapses to 0 under reduced motion, unless the sequence's own policy resolves to `'ignore'`. Its `delay` and its effects' delays are kept. + +`generate()` emits both outcomes under exclusive `prefers-reduced-motion` media queries, so the CSS and the runtime agree. `generate()` takes the instance's `reducedMotionPolicy` and `forceReducedMotion` as options, defaulting to the ones set by `Interact.setup()`. + ## Condition Types ### `Condition` diff --git a/packages/interact/docs/guides/conditions-and-media-queries.md b/packages/interact/docs/guides/conditions-and-media-queries.md index e3834b20..d613b595 100644 --- a/packages/interact/docs/guides/conditions-and-media-queries.md +++ b/packages/interact/docs/guides/conditions-and-media-queries.md @@ -269,6 +269,59 @@ const accessibleConfig: InteractConfig = { }; ``` +### Reduced Motion Policy + +Instead of writing a separate interaction per motion preference, set `reducedMotionPolicy` and let Interact derive the reduced-motion version of each effect. It is accepted on interactions, sequences and effects — the most specific level wins. By default every effect collapses: it jumps to its end state for users who prefer reduced motion. To change that default, pass `reducedMotionPolicy` to `Interact.setup()` for every instance, or to `Interact.create(config, options)` for one. + +```typescript +const config: InteractConfig = { + effects: { + fade: { + keyframeEffect: { name: 'fade', keyframes: [{ opacity: '0' }, { opacity: '1' }] }, + duration: 300, + easing: 'ease-out', + }, + }, + + interactions: [ + { + key: 'hero-title', + trigger: 'viewEnter', + effects: [ + { + keyframeEffect: { + name: 'fade-move', + keyframes: [ + { opacity: '0', transform: 'translateY(50px) rotate(-2deg)' }, + { opacity: '1', transform: 'translateY(0) rotate(0deg)' }, + ], + }, + duration: 800, + // swap in the subtle `fade` effect instead of collapsing + reducedMotionPolicy: 'fade', + }, + ], + }, + { + key: 'background', + trigger: 'viewProgress', + // drop the parallax entirely + reducedMotionPolicy: 'cancel', + effects: [{ namedEffect: { type: 'ParallaxScroll' } }], + }, + ], +}; +``` + +- `'collapse'` (default) plays it with duration 0, keeping its iterations, so it jumps to its end state after its delay. Looping and scroll/pointer-driven effects have no end state, so they're cancelled instead. +- `'ignore'` plays the effect as usual. +- `'cancel'` drops it, exactly as if it had a `(prefers-reduced-motion: no-preference)` condition. +- An effectId replaces the effect's animation with that registry effect's, keeping the original target and conditions. It must be the same kind of effect (time-based, scroll/pointer-driven or a state transition), otherwise the effect collapses. + +A sequence's stagger collapses too, so its items land together, unless the sequence's policy is `'ignore'`. + +`Interact.setup({ forceReducedMotion: true })` (or `Interact.create(config, { forceReducedMotion: true })` for a single instance) applies the reduced-motion outcome regardless of the user's setting. `generate()` defaults to the values set by `Interact.setup()`; pass it the instance's values otherwise. + ## Device-Specific Patterns ### Touch vs Mouse Devices diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index e276a6e2..38644cb6 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**: Effects collapse by default (see [Reduced Motion](#reduced-motion)) — users who prefer reduced motion get the end state without the tween. Use `'cancel'` to drop an effect, an effectId to swap in a gentler effect, or `'ignore'` to keep it as is. `Interact.setup({ reducedMotionPolicy, forceReducedMotion })` changes the default and forces the reduced outcome for every instance; `Interact.create(config, options)` takes both per instance. Never put `reducedMotionPolicy` on the config root. - **Perspective**: Prefer `transform: perspective(...)` inside keyframes. Use the CSS `perspective` property only when multiple children share the same `perspective-origin`. --- @@ -197,6 +198,7 @@ Each interaction maps a source element + trigger to one or more effects. effects?: (Effect | EffectRef)[]; // possible to add multiple effects for same trigger sequences?: (SequenceConfig | SequenceConfigRef)[]; // possible to add multiple sequences for same trigger conditions?: string[]; // ids referencing the top-level conditions map; all must pass + reducedMotionPolicy?: ReducedMotionPolicy; // default for the interaction's effects selector?: string; // optional - CSS selector to refine source element selection within the root element listContainer?: string; // optional — CSS selector for list container listItemSelector?: string; // optional — CSS selector to filter which children of listContainer are observed as sources @@ -361,6 +363,7 @@ Each effect applies a visual change to a target element. An effect is either inl key?: string; // target element key; omit to target the source effectId?: string; // reference to effects registry (EffectRef) conditions?: string[]; // ids referencing the top-level conditions map; all must pass + reducedMotionPolicy?: ReducedMotionPolicy; // see Reduced Motion selector?: string; // optional — CSS selector to refine target element listContainer?: string; // optional — CSS selector for list container listItemSelector?: string; // optional — filter which children of listContainer are selected @@ -542,6 +545,7 @@ Coordinate multiple effects with staggered timing. Prefer sequences over manual sequenceId?: string; // for caching/referencing; also names the CSS stagger custom // properties. Defaults to `seq--` conditions?: string[]; // ids referencing the top-level conditions map + reducedMotionPolicy?: ReducedMotionPolicy; // default for the sequence's effects } ``` @@ -618,6 +622,27 @@ conditions: { --- +## Reduced Motion + +```ts +type ReducedMotionPolicy = 'ignore' | 'cancel' | 'collapse' | string; // string = effectId in the effects registry +``` + +What an effect does when the user prefers reduced motion (`(prefers-reduced-motion: reduce)`, or forced by `forceReducedMotion` of `Interact.setup()` / `Interact.create()`). Set on an interaction, a sequence or an effect, or per instance (`Interact.setup()` / `Interact.create()` option, not a config field); the most specific wins: effect (inline, then its registry entry) → sequence → interaction → instance → `'collapse'`. + +| Value | Under reduced motion | +| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `'collapse'` | Default. Plays with duration 0, keeping everything else (delay, iterations, fill, keyframes), so it jumps to its end state. Transitions become 0ms (with delay). Looping and scroll/pointer-driven effects are cancelled. | +| `'ignore'` | Plays as usual. | +| `'cancel'` | Dropped, like a `(prefers-reduced-motion: no-preference)` media condition — an earlier effect of the same interaction on the same target may then apply instead. | +| effectId | Replaced by that registry effect's animation (effect type, keyframes and timing); the original's target, conditions and `triggerType` stay. It must be of the same kind (time, scrub or state) — otherwise it collapses. | + +- A sequence's stagger (`offset`) collapses to 0, unless the sequence's policy is `'ignore'`; delays are kept. +- An `animationEnd` source resolves its policy from its registry entry and the instance only — set it there. +- `generate()` emits both outcomes under exclusive `prefers-reduced-motion` media. It takes the instance's `reducedMotionPolicy` / `forceReducedMotion` as options, defaulting to `Interact.setup()`'s. + +--- + ## CSS Generation & FOUC Prevention ### Generating CSS @@ -767,10 +792,10 @@ Default split wrapper classes: `.split-c` (chars), `.split-w` (words), `.split-l | 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`. `plugins` = per-plugin SSR style generators (see [Plugins](#plugins)). | -| `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | +| `Interact.create(config, options?)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. `options.forceReducedMotion` / `options.reducedMotionPolicy` override `Interact.setup()`'s for the instance. | | `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` (default: `false`) — apply the reduced-motion outcome of `reducedMotionPolicy` regardless of OS setting. Set by `Interact.setup()`. | | `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, opts?)` | Register an external plugin by name (see [Plugins](#plugins)). Call before `create`. `opts.defer` defers it until `Interact.ready()`. | @@ -784,6 +809,8 @@ Default split wrapper classes: `.split-c` (chars), `.split-w` (words), `.split-l | `pointerOptionsGetter` | `() => Partial` | Function returning defaults for pointer-move animation configuration | | `viewEnter` | `Partial` | Defaults for all viewEnter triggers (`threshold`,`inset`) | | `allowA11yTriggers` | `boolean` | Enable accessibility trigger variants (use `interest` and `activate`) | +| `forceReducedMotion` | `boolean` | Apply the reduced-motion outcome regardless of OS setting | +| `reducedMotionPolicy` | `ReducedMotionPolicy` | Default policy of effects (default `'collapse'`) | Use `setup()` when you need to override default observer thresholds or provide global configuration that applies to all interactions of a given trigger type. diff --git a/packages/interact/rules/integration.md b/packages/interact/rules/integration.md index ccce715c..936b896e 100644 --- a/packages/interact/rules/integration.md +++ b/packages/interact/rules/integration.md @@ -363,6 +363,6 @@ Each `Interact.create(config)` call returns an instance. Keep a reference if you | `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.forceReducedMotion` | `boolean` — apply the reduced-motion outcome of `reducedMotionPolicy` regardless of OS setting. Set by `Interact.setup()`, overridable per instance. Default: `false`. | | `Interact.allowA11yTriggers` | `boolean` — enable accessibility triggers (`interest`, `activate`). Default: `true`. | -| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params. Call before `create`. | +| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params and reduced motion (`forceReducedMotion`, `reducedMotionPolicy`). Call before `create`. | diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 68119e2d..e0449d23 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -127,22 +127,23 @@ Severity is one of `'error' | 'warning'`. 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: -| Rule category | Covers codes | Default severity | -| ------------------------ | --------------------------------------------------------------------------- | ---------------- | -| `UNUSED_DEFINITION` | `UNUSED_EFFECT`, `UNUSED_SEQUENCE`, `UNUSED_CONDITION` | warning | -| `UNIQUE_DEFINITION_IDS` | `DUPLICATE_KEYFRAME_NAME` | warning | -| `VALID_MEDIA_QUERIES` | `INVALID_MEDIA_QUERY` | error | -| `SAME_ELEMENT_RETRIGGER` | `SAME_ELEMENT_RETRIGGER` | warning | -| `HIT_AREA_SHIFT` | `HIT_AREA_SHIFT` | warning | -| `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 | -| `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning | -| `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | -| `VIEW_INSET` | `INVALID_INSET` | warning | -| `OFFSET_EASING` | `FUNCTION_OFFSET_EASING` | warning | -| `NAMED_EFFECT_PARAMS` | `NAMED_EFFECT_INVALID_PARAM` (error), `NAMED_EFFECT_UNKNOWN_PARAM` | warning | +| Rule category | Covers codes | Default severity | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `UNUSED_DEFINITION` | `UNUSED_EFFECT`, `UNUSED_SEQUENCE`, `UNUSED_CONDITION` | warning | +| `UNIQUE_DEFINITION_IDS` | `DUPLICATE_KEYFRAME_NAME` | warning | +| `VALID_MEDIA_QUERIES` | `INVALID_MEDIA_QUERY` | error | +| `SAME_ELEMENT_RETRIGGER` | `SAME_ELEMENT_RETRIGGER` | warning | +| `HIT_AREA_SHIFT` | `HIT_AREA_SHIFT` | warning | +| `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 | +| `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning | +| `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning | +| `VIEW_INSET` | `INVALID_INSET` | warning | +| `OFFSET_EASING` | `FUNCTION_OFFSET_EASING` | warning | +| `NAMED_EFFECT_PARAMS` | `NAMED_EFFECT_INVALID_PARAM` (error), `NAMED_EFFECT_UNKNOWN_PARAM` | warning | +| `REDUCED_MOTION` | `REDUCED_MOTION_SELF_REFERENCE`, `REDUCED_MOTION_SHADOWED_EFFECT_ID`, `REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH`, `REDUCED_MOTION_COLLAPSE_CANCELS` (info) | warning | For each category, set `'off'` to drop those issues entirely, `'warning'` / `'error'` to set their severity: @@ -194,11 +195,13 @@ This is the authoritative list of every code the validator emits today. Codes no ### Referential errors (severity: error) -| Code | What it flags | -| -------------------------------- | ------------------------------------------------------------------------------ | -| `ANIMATION_END_EFFECT_NOT_FOUND` | An `animationEnd` interaction's `params.effectId` references a missing effect. | -| `SEQUENCE_ID_NOT_FOUND` | A `sequenceId` references a key absent from `config.sequences`. | -| `CONDITION_NOT_FOUND` | A `conditions[]` entry references a key absent from `config.conditions`. | +| Code | What it flags | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ANIMATION_END_EFFECT_NOT_FOUND` | An `animationEnd` interaction's `params.effectId` references a missing effect. | +| `SEQUENCE_ID_NOT_FOUND` | A `sequenceId` references a key absent from `config.sequences`. | +| `CONDITION_NOT_FOUND` | A `conditions[]` entry references a key absent from `config.conditions`. | +| `REDUCED_MOTION_EFFECT_NOT_FOUND` | An effect's `reducedMotionPolicy` names an effectId absent from `config.effects`. On an interaction or sequence it is a **warning** instead — the effects inheriting it collapse. | +| `REDUCED_MOTION_INCOMPATIBLE_EFFECT` | An effect's `reducedMotionPolicy` names an effect of another kind (time / scroll-or-pointer-driven / state). When inherited from an interaction or sequence it is a **warning** instead — the effect collapses. An effect of a `config.sequences` entry is reported on each sequence reference it inherits from. | ### Other structural errors (severity: error) @@ -222,23 +225,29 @@ This is the authoritative list of every code the validator emits today. Codes no Statically-detectable authoring pitfalls lifted from the trigger rule files. Each belongs to its own rule category, so `severityOverrides` can silence (`'off'`) or escalate (`'error'`) it. `strict: true` promotes all of these to `error`. -| Code | What it flags | Rule category | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | -| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with `triggerType` `repeat`/`alternate`/`state` on the same source+target element (re-trigger loop). | `SAME_ELEMENT_RETRIGGER` | -| `HIT_AREA_SHIFT` | `hover`/`click`/`interest`/`activate`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` | -| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits for an effect it also produces → it can never start. | `ANIMATION_END_GRAPH` | -| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert) — on an interaction or an effect. | `ELEMENT_SELECTION` | -| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` is ignored when both `listContainer` and `listItemSelector` are 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 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` | -| `FUNCTION_OFFSET_EASING` | A sequence's `offsetEasing` is a function, so `generate()` omits that sequence from the generated CSS. | `OFFSET_EASING` | -| `NAMED_EFFECT_INVALID_PARAM` | A `namedEffect` param does not match its effect's registered `schema` (type, enum values, min/max, length/angle format). | `NAMED_EFFECT_PARAMS` | -| `NAMED_EFFECT_UNKNOWN_PARAM` | A `namedEffect` param is not declared in its effect's registered `schema` (e.g. a renamed param). | `NAMED_EFFECT_PARAMS` | +| Code | What it flags | Rule category | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | +| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with `triggerType` `repeat`/`alternate`/`state` on the same source+target element (re-trigger loop). | `SAME_ELEMENT_RETRIGGER` | +| `HIT_AREA_SHIFT` | `hover`/`click`/`interest`/`activate`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` | +| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits for an effect it also produces → it can never start. | `ANIMATION_END_GRAPH` | +| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert) — on an interaction or an effect. | `ELEMENT_SELECTION` | +| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` is ignored when both `listContainer` and `listItemSelector` are 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 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` | +| `FUNCTION_OFFSET_EASING` | A sequence's `offsetEasing` is a function, so `generate()` omits that sequence from the generated CSS. | `OFFSET_EASING` | +| `NAMED_EFFECT_INVALID_PARAM` | A `namedEffect` param does not match its effect's registered `schema` (type, enum values, min/max, length/angle format). | `NAMED_EFFECT_PARAMS` | +| `NAMED_EFFECT_UNKNOWN_PARAM` | A `namedEffect` param is not declared in its effect's registered `schema` (e.g. a renamed param). | `NAMED_EFFECT_PARAMS` | +| `REDUCED_MOTION_SELF_REFERENCE` | An effect's `reducedMotionPolicy` names the effect itself, which is the same as `'ignore'`. | `REDUCED_MOTION` | +| `REDUCED_MOTION_SHADOWED_EFFECT_ID` | A `config.effects` key is `ignore` / `cancel` / `collapse`, so `reducedMotionPolicy` can't name it. | `REDUCED_MOTION` | +| `REDUCED_MOTION_ANIMATION_END_SOURCE_MISMATCH` | An `animationEnd` source effect gets a different `reducedMotionPolicy` where it plays than from its registry entry, which is all the `animationEnd` trigger sees. | `REDUCED_MOTION` | +| `REDUCED_MOTION_COLLAPSE_CANCELS` (info) | A collapsed effect has no end state (infinite iterations, or `viewProgress` / `pointerMove`), so reduced motion cancels it. Not reported when no `reducedMotionPolicy` is set. | `REDUCED_MOTION` | + +The instance's `reducedMotionPolicy` (an `Interact.setup()` / `Interact.create()` option) isn't part of the config, so the `REDUCED_MOTION_*` checks take it to be the default `'collapse'`. --- diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index f7f381d2..50c8577f 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -11,10 +11,12 @@ import { IInteractionController, IInteractElement, InteractPlugin, + ReducedMotionPolicy, InteractPluginOptions, } from '../types'; import { getElementHash, getInterpolatedKey } from './utilities'; import { getFallbackId, getKeyframeSlots, getSlotKey } from './css'; +import { withReducedMotionConditions } from './reducedMotion'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; import { registerEffects, @@ -25,6 +27,14 @@ import { } from '@wix/motion'; import type { SequenceOptions, AnimationGroupArgs, IndexedGroup } from '@wix/motion'; +type InteractInitOptions = { + useCustomElement?: boolean; + configIndex?: number; + // override Interact's for this instance + forceReducedMotion?: boolean; + reducedMotionPolicy?: ReducedMotionPolicy; +}; + function _convertToKeyTemplate(key: string) { return key.replace(/\[[^\]]+]/g, '[]'); } @@ -46,6 +56,7 @@ export class Interact { }; controllers: Set; static forceReducedMotion: boolean = false; + static reducedMotionPolicy: ReducedMotionPolicy = 'collapse'; static allowA11yTriggers: boolean = true; static instances: Interact[] = []; static controllerCache = new Map(); @@ -64,17 +75,18 @@ export class Interact { this.controllers = new Set(); } - init( - config: InteractConfig, - options?: { useCustomElement?: boolean; configIndex?: number }, - ): void { + init(config: InteractConfig, options?: InteractInitOptions): void { if (typeof window === 'undefined' || !window.customElements) { return; } const useCustomElement = options?.useCustomElement ?? !!Interact.defineInteractElement; - this.dataCache = parseConfig(config, useCustomElement, options?.configIndex); + this.dataCache = parseConfig(config, useCustomElement, { + configIndex: options?.configIndex, + forceReducedMotion: options?.forceReducedMotion ?? Interact.forceReducedMotion, + reducedMotionPolicy: options?.reducedMotionPolicy ?? Interact.reducedMotionPolicy, + }); const defined = Interact.defineInteractElement?.(); @@ -181,10 +193,7 @@ export class Interact { }); } - static create( - config: InteractConfig, - options?: { useCustomElement?: boolean; configIndex?: number }, - ): Interact { + static create(config: InteractConfig, options?: InteractInitOptions): Interact { const instance = new Interact(); Interact.instances.push(instance); @@ -209,6 +218,8 @@ export class Interact { pointerOptionsGetter?: () => Partial; viewEnter?: Partial; allowA11yTriggers?: boolean; + forceReducedMotion?: boolean; + reducedMotionPolicy?: ReducedMotionPolicy; }): void { if (options.scrollOptionsGetter) { TRIGGER_TO_HANDLER_MODULE_MAP.viewProgress.registerOptionsGetter?.( @@ -231,6 +242,14 @@ export class Interact { if (options.allowA11yTriggers !== undefined) { Interact.allowA11yTriggers = options.allowA11yTriggers; } + + if (options.forceReducedMotion !== undefined) { + Interact.forceReducedMotion = options.forceReducedMotion; + } + + if (options.reducedMotionPolicy !== undefined) { + Interact.reducedMotionPolicy = options.reducedMotionPolicy; + } } static getInstance(key: string): Interact | undefined { @@ -316,12 +335,11 @@ export class Interact { cacheKey: string, sequenceOptions: SequenceOptions, animationGroupArgs: AnimationGroupArgs[], - context?: { reducedMotion?: boolean }, ): Sequence { const cached = Interact.sequenceCache.get(cacheKey); if (cached) return cached; - const sequence = getMotionSequence(sequenceOptions, animationGroupArgs, context); + const sequence = getMotionSequence(sequenceOptions, animationGroupArgs); Interact.sequenceCache.set(cacheKey, sequence); Interact._registerSequenceElements(animationGroupArgs, sequence); @@ -332,13 +350,12 @@ export class Interact { cacheKey: string, animationGroupArgs: AnimationGroupArgs[], indices: number[], - context?: { reducedMotion?: boolean }, ): boolean { const cached = Interact.sequenceCache.get(cacheKey); if (!cached) return false; - const newGroups = createAnimationGroups(animationGroupArgs, context); + const newGroups = createAnimationGroups(animationGroupArgs); const entries: IndexedGroup[] = newGroups.map((group, i) => ({ index: indices[i] ?? cached.animationGroups.length, group, @@ -466,14 +483,14 @@ function withSlots( function parseConfig( config: InteractConfig, - useCustomElement: boolean = false, - configIndex?: number, + useCustomElement: boolean, + options: Required> & + Pick, ): InteractCache { + const { configIndex } = options; const { effects: effectMap = {}, sequences: sequenceMap = {}, conditions = {} } = config; const interactions: InteractCache['interactions'] = {}; - const slots = config.interactions - ? getKeyframeSlots(config, configIndex) - : new Map(); + const slots = config.interactions ? getKeyframeSlots(config, options) : new Map(); config.interactions?.forEach((interaction_, interactionIndex) => { const source = interaction_.key; @@ -638,7 +655,9 @@ function parseConfig( return { effects: effectMap, sequences: sequenceMap, - conditions, + conditions: withReducedMotionConditions(conditions), + reducedMotionPolicy: options.reducedMotionPolicy, + forceReducedMotion: options.forceReducedMotion, interactions, }; } diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts index 5418e28b..272a87f1 100644 --- a/packages/interact/src/core/add.ts +++ b/packages/interact/src/core/add.ts @@ -13,10 +13,22 @@ import type { IInteractionController, AnimationEndParams, AnimationOptions, + ReducedMotionPolicy, } from '../types'; import { PLUGIN_FIELD_PREFIX } from '../types'; -import { createTransitionCSS, getMediaQuery, getSelectorCondition, generateId } from '../utils'; +import { + createTransitionCSS, + getMediaQuery, + getSelectorCondition, + generateId, + isTransitionEffect, +} from '../utils'; import { getInterpolatedKey } from './utilities'; +import { + REDUCED_MOTION_REDUCE, + getReducedMotionPolicy, + getReducedMotionVariants, +} from './reducedMotion'; import { effectToAnimationOptions } from '../handlers/utilities'; import { Interact, getSelector } from './Interact'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; @@ -144,6 +156,45 @@ function _queryItemElement(data: Interaction | Effect, elements: HTMLElement[]): .filter(Boolean) as HTMLElement[]; } +/** + * The effect's reduced-motion variant whose media matches, listening on every variant's media so a change + * re-resolves it. + */ +function _resolveVariant( + effect: T, + instance: Interact, + listener: { id: string; key: string; onUpdate: () => void }, + inherited: { policy?: ReducedMotionPolicy; collapsesStagger?: boolean } = {}, +): T | undefined { + const { effects, conditions, reducedMotionPolicy, forceReducedMotion } = instance.dataCache; + const variants = getReducedMotionVariants( + effect, + getReducedMotionPolicy(effect.reducedMotionPolicy, inherited.policy, reducedMotionPolicy), + { effects, force: forceReducedMotion, collapsesStagger: inherited.collapsesStagger }, + ); + + let chosen: T | undefined; + + variants.forEach((variant, index) => { + const mql = getMediaQuery(variant.conditions || [], conditions); + + if (mql) { + instance.setupMediaQueryListener( + index ? `${listener.id}::rm${index}` : listener.id, + mql, + listener.key, + listener.onUpdate, + ); + } + + if (!chosen && (!mql || mql.matches)) { + chosen = variant; + } + }); + + return chosen; +} + function _getInteractionElements( interaction: InteractionTrigger, effect: Effect, @@ -227,12 +278,12 @@ function _addInteraction( (interaction.effects || []).forEach((effect, effectIndex) => { const effectId = (effect as EffectRef).effectId; - const effectOptions = { + const mergedEffect = { ...(instance.dataCache.effects[effectId] || {}), ...effect, effectId, - }; - const targetKey_ = effectOptions.key; + } as Effect; + const targetKey_ = mergedEffect.key; const interactionId = getInterpolatedKey(effect.interactionId!, sourceKey); @@ -246,15 +297,20 @@ function _addInteraction( return; } - const mql = getMediaQuery(effectOptions.conditions || [], instance.dataCache.conditions); - - if (mql) { - instance.setupMediaQueryListener(`${interactionId}::${effectIndex}`, mql, sourceKey, () => { - sourceController.update(); - }); - } + const effectOptions = _resolveVariant( + mergedEffect, + instance, + { + id: `${interactionId}::${effectIndex}`, + key: sourceKey, + onUpdate: () => { + sourceController.update(); + }, + }, + { policy: interaction.reducedMotionPolicy }, + ); - if (!mql || mql.matches) { + if (effectOptions) { interactionVariations[interactionId!] = true; const target = targetKey_ && getInterpolatedKey(targetKey_, sourceKey); @@ -332,6 +388,33 @@ function _addInteraction( _processSequences(sourceKey, sourceController, instance, interaction, elements); } +function _getSequencePolicy( + sequenceConfig: SequenceConfig, + interaction: InteractionTrigger, + instance: Interact, +): ReducedMotionPolicy { + return getReducedMotionPolicy( + sequenceConfig.reducedMotionPolicy, + interaction.reducedMotionPolicy, + instance.dataCache.reducedMotionPolicy, + ); +} + +// the stagger collapses under reduced motion, unless the sequence ignores it +function _getSequenceOptions( + sequenceConfig: SequenceConfig, + interaction: InteractionTrigger, + instance: Interact, +): SequenceConfig { + const { forceReducedMotion, conditions } = instance.dataCache; + const mql = getMediaQuery([REDUCED_MOTION_REDUCE], conditions); + const isReduced = forceReducedMotion || !!(mql && mql.matches); + + return isReduced && _getSequencePolicy(sequenceConfig, interaction, instance) !== 'ignore' + ? { ...sequenceConfig, offset: 0 } + : sequenceConfig; +} + function _isSequenceConfigRef( config: SequenceConfig | SequenceConfigRef, ): config is SequenceConfigRef { @@ -349,6 +432,7 @@ function _isSequenceConfigRef( */ function _buildAnimationGroupArgsFromSequence( sequenceConfig: SequenceConfig, + interaction: InteractionTrigger, cacheKey: string, sourceKey: string, sourceController: IInteractionController, @@ -388,25 +472,23 @@ function _buildAnimationGroupArgsFromSequence( } const animationGroupArgs: AnimationGroupArgs[] = []; + const policy = _getSequencePolicy(sequenceConfig, interaction, instance); let usedListElements = false; for (const effect of seqEffects) { const effectId = (effect as EffectRef).effectId; - const effectOptions = resolveEffect(effect); - - const effectMql = getMediaQuery(effectOptions.conditions || [], instance.dataCache.conditions); - - if (effectMql) { - const effectCacheKey = `${cacheKey}::${effectId || 'eff'}`; - instance.setupMediaQueryListener( - effectCacheKey, - effectMql, - options.updateKey, - options.onUpdate, - ); - } + const effectOptions = _resolveVariant( + resolveEffect(effect), + instance, + { + id: `${cacheKey}::${effectId || 'eff'}`, + key: options.updateKey, + onUpdate: options.onUpdate, + }, + { policy, collapsesStagger: policy !== 'ignore' }, + ); - if (effectMql && !effectMql.matches) continue; + if (!effectOptions) continue; const targetKey_ = effectOptions.key; const target = targetKey_ && getInterpolatedKey(targetKey_, sourceKey); @@ -490,7 +572,6 @@ function _attachSequenceTriggers( { triggerType: sequenceConfig.triggerType } as Effect, interaction.params || {}, { - reducedMotion: Interact.forceReducedMotion, selectorCondition, animation: sequence, allowA11yTriggers: Interact.allowA11yTriggers, @@ -555,6 +636,7 @@ function _processSequences( const animationGroupArgs = _buildAnimationGroupArgsFromSequence( sequenceConfig, + interaction, cacheKey, sourceKey, sourceController, @@ -575,13 +657,13 @@ function _processSequences( elements, ); - Interact.addToSequence(cacheKey, animationGroupArgs, indices, { - reducedMotion: Interact.forceReducedMotion, - }); + Interact.addToSequence(cacheKey, animationGroupArgs, indices); - const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { - reducedMotion: Interact.forceReducedMotion, - }); + const sequence = Interact.getSequence( + cacheKey, + _getSequenceOptions(sequenceConfig, interaction, instance), + animationGroupArgs, + ); const selectorCondition = getSelectorCondition( interaction.conditions || [], @@ -603,9 +685,11 @@ function _processSequences( return; } - const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { - reducedMotion: Interact.forceReducedMotion, - }); + const sequence = Interact.getSequence( + cacheKey, + _getSequenceOptions(sequenceConfig, interaction, instance), + animationGroupArgs, + ); const selectorCondition = getSelectorCondition( interaction.conditions || [], @@ -678,6 +762,7 @@ function _processSequencesForTarget( const animationGroupArgs = _buildAnimationGroupArgsFromSequence( sequenceConfig, + interaction, cacheKey, sourceKey!, sourceController, @@ -694,16 +779,16 @@ function _processSequencesForTarget( if (elements && instance.addedInteractions[cacheKey]) { const indices = _resolveListItemIndices(targetController, listContainer!, elements); - Interact.addToSequence(cacheKey, animationGroupArgs, indices, { - reducedMotion: Interact.forceReducedMotion, - }); + Interact.addToSequence(cacheKey, animationGroupArgs, indices); return true; } - const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { - reducedMotion: Interact.forceReducedMotion, - }); + const sequence = Interact.getSequence( + cacheKey, + _getSequenceOptions(sequenceConfig, interaction, instance), + animationGroupArgs, + ); const selectorCondition = getSelectorCondition( interaction.conditions || [], @@ -767,32 +852,32 @@ function addEffectsForTarget( const effectId = (effect as EffectRef).effectId; - const effectOptions = { + const mergedEffect = { ...(instance!.dataCache.effects[effectId] || {}), ...effect, effectId, - }; + } as Effect; - if (listContainer && effectOptions.listContainer !== listContainer) { + if (listContainer && mergedEffect.listContainer !== listContainer) { // skip this effect if a listContainer was provided and it's not matching this effect.listContainer return false; } - const mql = getMediaQuery(effectOptions.conditions || [], instance!.dataCache.conditions); - - if (mql) { - instance.setupMediaQueryListener( - `${interactionId}::${variationIndex}`, - mql, - targetKey, - () => { + const effectOptions = _resolveVariant( + mergedEffect, + instance, + { + id: `${interactionId}::${variationIndex}`, + key: targetKey, + onUpdate: () => { // For effects on target, we reconcile the target element targetController.update(); }, - ); - } + }, + { policy: interaction.reducedMotionPolicy }, + ); - if (!mql || mql.matches) { + if (effectOptions) { const sourceKey = interaction.key && getInterpolatedKey(interaction.key, targetKey); const sourceController = Interact.getController(sourceKey); @@ -887,7 +972,7 @@ function addInteraction( ): void { let targetController; - if ((effect as StateEffect).transition || (effect as StateEffect).transitionProperties) { + if (isTransitionEffect(effect)) { const args: CreateTransitionCSSParams = { key: targetKey, effectId: (effect as Effect).effectId!, @@ -913,16 +998,25 @@ function addInteraction( if (trigger === 'animationEnd') { const triggerEffectId = (options as AnimationEndParams).effectId; const instance = sourceKey ? Interact.getInstance(sourceKey) : undefined; - const srcEffect = instance?.dataCache.effects[triggerEffectId] as TimeEffect | undefined; + const definition = instance?.dataCache.effects[triggerEffectId]; + // the source's use site is unknown, so its policy resolves from its definition and the config only + const srcEffect = + definition && + _resolveVariant(definition, instance!, { + id: `${sourceKey}::animationEnd::${triggerEffectId}`, + key: sourceKey!, + onUpdate: () => { + Interact.getController(sourceKey)?.update(); + }, + }); if (srcEffect) { - sourceAnimationOptions = effectToAnimationOptions(srcEffect) as + sourceAnimationOptions = effectToAnimationOptions(srcEffect as TimeEffect) as | AnimationOptions<'time'> | undefined; } } TRIGGER_TO_HANDLER_MODULE_MAP[trigger]?.add(source, target, effect, options, { - reducedMotion: Interact.forceReducedMotion, targetController, selectorCondition, allowA11yTriggers: Interact.allowA11yTriggers, diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index eb2f0432..5db3420d 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -8,6 +8,7 @@ import type { CSSRuleData, InteractPluginStyles, GenerateOptions, + ReducedMotionPolicy, } from '../types'; import { PLUGIN_FIELD_PREFIX } from '../types'; import { @@ -17,9 +18,10 @@ import { getFullPredicateByType, getSelectorCondition, } from '../utils'; -import { getSelector } from './Interact'; +import { Interact, getSelector } from './Interact'; import { resolveEffectForCSS, resolveSequenceForCSS } from './resolvers'; import { getElementHash } from './utilities'; +import { isReducedMotionVariant, withReducedMotionConditions } from './reducedMotion'; import { LIST_ANIMATION_PROPERTY_NAMES, LIST_PROPERTY_NAMES, @@ -86,6 +88,8 @@ type GenerateContext = { // only collecting the effects' list entries for getKeyframeSlots, without generating CSS collectSlots: boolean; useFirstChild: boolean; + forceReducedMotion: boolean; + reducedMotionPolicy: ReducedMotionPolicy; plugins?: InteractPluginStyles; configIndex?: number; }; @@ -159,11 +163,16 @@ function getEffectListKind(effect: ResolvedEffect): ListKind | null { return null; } +// an effect's variants are of the same kind, but only some of them may be expressible in CSS +function getVariantsListKind(effect: ResolvedEffect): ListKind | null { + return (effect.variants || [effect]).map(getEffectListKind).find(Boolean) || null; +} + function getSlotUsage(sequence: ResolvedSequence): Map { const counts = new Map>(); sequence.effects.forEach((effect) => { - const kind = getEffectListKind(effect); + const kind = getVariantsListKind(effect); if (!kind) { return; } @@ -316,7 +325,11 @@ function effectToCSS( rules.push(...collectFieldPluginStyles('effect', effect, key, media, ctx.plugins)); } - if (namedEffect || keyframeEffect) { + // a 0-duration entrance can finish before it's marked as started, restarting a CSS animation that's held until + // done, so CSS only hides its element and the runtime plays it + const isInstantEntrance = initial && (effect as { duration?: number }).duration === 0; + + if ((namedEffect || keyframeEffect) && !isInstantEntrance) { const animationOptions = effectToAnimationOptions(effect); const cssAnimations = getCSSAnimation(null, animationOptions, trigger, sequence).filter( (anim) => anim.name, @@ -429,6 +442,16 @@ function effectToCSS( declarations: properties, }); } else { + if (isInstantEntrance) { + rules.push({ + key, + media, + selectorCondition, + childSelector, + declarations: DEFAULT_INITIAL, + selectorSuffix: ':not([data-interact-enter])', + }); + } // setting off animation custom properties declarations.push( ...LIST_ANIMATION_PROPERTY_NAMES.filter((propertyName) => @@ -444,7 +467,12 @@ function effectToCSS( } // records a namedEffect under the animation list entry it writes on its target -function collectKeyframeEntry(target: TargetContext, effect: ResolvedEffect, entry: string) { +function collectKeyframeEntry( + target: TargetContext, + effect: ResolvedEffect, + entry: string, + slotKey: string, +) { const module = effect.namedEffect && (getRegisteredEffect(effect.namedEffect.type, false) as AnimationEffectAPI<'time'> | null); @@ -458,7 +486,19 @@ function collectKeyframeEntry(target: TargetContext, effect: ResolvedEffect, ent return; } const members = target.keyframeEntries.get(entry) || []; - target.keyframeEntries.set(entry, [...members, { slotKey: effect.slotKey!, getNames }]); + target.keyframeEntries.set(entry, [...members, { slotKey, getNames }]); +} + +// the stagger collapses under reduced motion, unless the sequence ignores it +function getVariantSequence( + ctx: GenerateContext, + variant: ResolvedEffect, + sequence?: ResolvedSequence, +): ResolvedSequence | undefined { + const isReduced = ctx.forceReducedMotion || isReducedMotionVariant(variant); + return sequence && isReduced && sequence.reducedMotionPolicy !== 'ignore' + ? { ...sequence, offset: 0 } + : sequence; } function parseEffect( @@ -484,10 +524,13 @@ function parseEffect( const useSlots = slotUsage?.get(targetHash) || NO_SLOTS; const customProps = getCustomProps(current, useSlots, ctx.configIndex); + const variants = effect.variants || [effect]; + const kind = getVariantsListKind(effect); if (ctx.collectSlots) { - collectKeyframeEntry(current, effect, customProps.animation); - const kind = getEffectListKind(effect); + variants.forEach((variant) => + collectKeyframeEntry(current, variant, customProps.animation, effect.slotKey!), + ); endEffect( current, { animation: kind === 'animation', transition: kind === 'transition', timeline: false }, @@ -497,25 +540,38 @@ function parseEffect( return []; } - if (effect.namedEffect) { - effect.suffix = ctx.keyframeSlots.get(effect.slotKey!); - } + const assigned = current.assigned; + const results = variants.map((variant) => { + if (variant.namedEffect) { + variant.suffix = ctx.keyframeSlots.get(effect.slotKey!); + } + // the variants' media are exclusive, so each overrides only what was assigned before the effect + current.assigned = new Set(assigned); + return { + ...effectToCSS( + ctx, + variant, + current, + trigger, + customProps, + getVariantSequence(ctx, variant, sequence), + ), + assigned: current.assigned, + }; + }); + current.assigned = new Set(results.flatMap((result) => [...result.assigned])); - const { rules, keyframes, wrote } = effectToCSS( - ctx, - effect, - current, - trigger, - customProps, - sequence, + results.forEach(({ keyframes }) => + keyframes.forEach(({ name, keyframes }) => ctx.keyframesMap.set(name, keyframes)), ); - keyframes.forEach(({ name, keyframes }) => ctx.keyframesMap.set(name, keyframes)); - + const wrote = Object.fromEntries( + LIST_KINDS.map((kind) => [kind, results.some((result) => result.wrote[kind])]), + ) as Record; endEffect(current, wrote, useSlots); ctx.targetsMap.set(targetHash, current); - return rules; + return results.flatMap(({ rules }) => rules); } function parseSequence( @@ -589,6 +645,7 @@ function parseInteraction( interaction, ctx.config, getFallbackId('eff', [interactionIdx, effIndex], ctx.configIndex), + { force: ctx.forceReducedMotion, defaultPolicy: ctx.reducedMotionPolicy }, ); return resolved && Object.assign(resolved, { slotKey: getSlotKey(interactionIdx, effIndex) }); }) @@ -606,6 +663,7 @@ function parseInteraction( ctx.config, getFallbackId('seq', [interactionIdx, seqIndex], ctx.configIndex), (effIndex) => getSlotKey(interactionIdx, effIndex, seqIndex), + { force: ctx.forceReducedMotion, defaultPolicy: ctx.reducedMotionPolicy }, ), ) .filter((sequence) => sequence !== null); @@ -641,16 +699,27 @@ function parseConfig( config: InteractConfig, options: GenerateOptions & { collectSlots?: boolean }, ) { - const { useFirstChild = true, plugins, configIndex, collectSlots = false } = options; + const { + useFirstChild = true, + plugins, + configIndex, + collectSlots = false, + forceReducedMotion = Interact.forceReducedMotion, + reducedMotionPolicy = Interact.reducedMotionPolicy, + } = options; const ctx: GenerateContext = { config, - configConditions: config.conditions || {}, + configConditions: withReducedMotionConditions(config.conditions), targetsMap: new Map(), keyframesMap: new Map(), - keyframeSlots: collectSlots ? new Map() : getKeyframeSlots(config, configIndex), + keyframeSlots: collectSlots + ? new Map() + : getKeyframeSlots(config, { configIndex, forceReducedMotion, reducedMotionPolicy }), scopedTimelines: [], collectSlots, useFirstChild, + forceReducedMotion, + reducedMotionPolicy, plugins, configIndex, }; @@ -688,9 +757,9 @@ export function getSlotKey(interactionIndex: number, effectIndex: number, sequen */ export function getKeyframeSlots( config: InteractConfig, - configIndex?: number, + options: Pick = {}, ): Map { - const { ctx } = parseConfig(config, { configIndex, collectSlots: true }); + const { ctx } = parseConfig(config, { ...options, collectSlots: true }); const slots = new Map(); const suffixOf = (slot: number) => (slot ? `-${slot}` : undefined); diff --git a/packages/interact/src/core/reducedMotion.ts b/packages/interact/src/core/reducedMotion.ts new file mode 100644 index 00000000..0c98832d --- /dev/null +++ b/packages/interact/src/core/reducedMotion.ts @@ -0,0 +1,160 @@ +import type { Condition, Effect, ReducedMotionPolicy, StateEffect, TimeEffect } from '../types'; +import { isTransitionEffect } from '../utils'; + +export const REDUCED_MOTION_REDUCE = '__rm-reduce'; +export const REDUCED_MOTION_NO_PREFERENCE = '__rm-no-preference'; + +export const REDUCED_MOTION_CONDITIONS: Record = { + [REDUCED_MOTION_REDUCE]: { type: 'media', predicate: 'prefers-reduced-motion: reduce' }, + [REDUCED_MOTION_NO_PREFERENCE]: { + type: 'media', + predicate: 'prefers-reduced-motion: no-preference', + }, +}; + +type EffectKind = 'time' | 'scrub' | 'state'; + +type VariantOptions = { + effects: Record; + force?: boolean; + // the effect plays in a sequence whose stagger collapses, so it has a reduced-motion variant even when ignored + collapsesStagger?: boolean; +}; + +// the effect's animation itself, taken from a replacement - the rest is the original's identity and targeting +const BODY_FIELDS = new Set([ + 'namedEffect', + 'keyframeEffect', + 'customEffect', + 'transition', + 'transitionProperties', + 'duration', + 'delay', + 'easing', + 'iterations', + 'alternate', + 'fill', + 'reversed', + 'composite', + 'rangeStart', + 'rangeEnd', + 'centeredToTarget', + 'transitionDuration', + 'transitionEasing', +]); + +export function withReducedMotionConditions( + conditions: Record = {}, +): Record { + return { ...conditions, ...REDUCED_MOTION_CONDITIONS }; +} + +// policies from the most specific level: effect, sequence, interaction, instance +export function getReducedMotionPolicy( + ...policies: (ReducedMotionPolicy | undefined)[] +): ReducedMotionPolicy { + return policies.find(Boolean) ?? 'collapse'; +} + +// the same time/scrub discriminator as effectToAnimationOptions() +function getEffectKind(effect: object): EffectKind { + if (isTransitionEffect(effect)) { + return 'state'; + } + return 'duration' in effect ? 'time' : 'scrub'; +} + +function collapse(effect: T): T | null { + const kind = getEffectKind(effect); + + if (kind === 'state') { + const { transition, transitionProperties } = effect as StateEffect; + return { + ...effect, + ...(transition && { transition: { ...transition, duration: 0 } }), + ...(transitionProperties && { + transitionProperties: transitionProperties.map((property) => ({ + ...property, + duration: 0, + })), + }), + }; + } + + const { iterations = 1 } = effect as TimeEffect; + if (kind === 'scrub' || !iterations || iterations === Infinity) { + return null; + } + + return { ...effect, duration: 0 }; +} + +function replace(effect: T, replacementId: string, replacement: Effect): T { + const isBody = ([field]: [string, unknown]) => BODY_FIELDS.has(field); + const { keyframeEffect } = replacement as { keyframeEffect?: { name?: string } }; + + return { + ...Object.fromEntries(Object.entries(effect).filter((entry) => !isBody(entry))), + ...Object.fromEntries(Object.entries(replacement).filter(isBody)), + // named after the replacement, or its keyframes would take the original's name + ...(keyframeEffect && { + keyframeEffect: { ...keyframeEffect, name: keyframeEffect.name || replacementId }, + }), + } as T; +} + +function getReducedEffect( + effect: T, + policy: ReducedMotionPolicy, + effects: Record, +): T | null { + switch (policy) { + case 'ignore': + return effect; + case 'cancel': + return null; + case 'collapse': + return collapse(effect); + default: { + const replacement = effects[policy]; + return replacement && getEffectKind(replacement) === getEffectKind(effect) + ? replace(effect, policy, replacement) + : collapse(effect); + } + } +} + +function withCondition(effect: T, condition: string): T { + return { + ...effect, + conditions: [...((effect as { conditions?: string[] }).conditions || []), condition], + }; +} + +export function isReducedMotionVariant(effect: { conditions?: string[] }): boolean { + return !!effect.conditions?.includes(REDUCED_MOTION_REDUCE); +} + +/** + * The effect's variants, each under its reserved media: CSS emits all of them, the runtime picks the one whose + * media matches. When forced, only the reduced-motion outcome, unconditionally. + */ +export function getReducedMotionVariants( + effect: T, + policy: ReducedMotionPolicy, + { effects, force, collapsesStagger }: VariantOptions, +): T[] { + const reduced = getReducedEffect(effect, policy, effects); + + if (force) { + return reduced ? [reduced] : []; + } + + if (reduced === effect && !collapsesStagger) { + return [effect]; + } + + const normal = withCondition(effect, REDUCED_MOTION_NO_PREFERENCE); + + return reduced ? [normal, withCondition(reduced, REDUCED_MOTION_REDUCE)] : [normal]; +} diff --git a/packages/interact/src/core/resolvers.ts b/packages/interact/src/core/resolvers.ts index be6bd0bf..d533ec08 100644 --- a/packages/interact/src/core/resolvers.ts +++ b/packages/interact/src/core/resolvers.ts @@ -1,13 +1,14 @@ -import { MotionKeyframeEffect, NamedEffect } from '@wix/motion'; import type { InteractConfig, Effect, EffectRef, - EffectBase, - StateEffect, Interaction, ResolvedEffect, ResolvedSequence, + EffectProperty, + FullEffect, + ResolveOptions, + ResolveEffectOptions, SequenceConfig, SequenceConfigRef, TimeAnimationTriggerType, @@ -15,6 +16,7 @@ import type { } from '../types'; import { isTemplatedKey, generateId } from '../utils'; import { shouldUseInitial } from './utilities'; +import { getReducedMotionPolicy, getReducedMotionVariants } from './reducedMotion'; const TIME_TRIGGER_TO_DEFAULT_TYPE: Map = new Map([ ['viewEnter', 'once'], @@ -25,11 +27,46 @@ const TIME_TRIGGER_TO_DEFAULT_TYPE: Map = ['interest', 'alternate'], ]); +// whether CSS has rules for the effect - a customEffect plays at runtime only, but falls back its list entries +function hasCSSRules( + { namedEffect, keyframeEffect, customEffect }: FullEffect, + isPointerMove: boolean, +): boolean { + if (namedEffect) { + // With the 2D nature of pointerMove namedEffects, there is no easy way to mimic the + // behavior with CSSAnimations. + return !isPointerMove && !!namedEffect.type; + } + return !!keyframeEffect || !(customEffect && isPointerMove); +} + +// the effect with only the animation source CSS can express, if any +function resolveBody( + effect: FullEffect & Omit, + isPointerMove: boolean, +): ResolvedEffect { + const { namedEffect, customEffect, keyframeEffect, transition, transitionProperties, ...rest } = + effect; + + if (namedEffect) { + return isPointerMove || !namedEffect.type ? rest : { namedEffect, ...rest }; + } else if (keyframeEffect) { + return { keyframeEffect, ...rest }; + } else if (customEffect) { + return rest; + } else if (transition) { + return { transition, ...rest }; + } else { + return transitionProperties ? { transitionProperties, ...rest } : rest; + } +} + export function resolveEffectForCSS( effect: Effect | EffectRef, interaction: Interaction, config: InteractConfig, fallbackId?: string, + options: ResolveEffectOptions = {}, ): ResolvedEffect | null { const { effects = {}, conditions: configConditions = {} } = config; const { key: interactionKey, trigger, conditions: interactionConditions = [] } = interaction; @@ -41,13 +78,7 @@ export function resolveEffectForCSS( } const { effectId } = effect; - const fullEffect: EffectBase & - StateEffect & { - triggerType?: TimeAnimationTriggerType; - namedEffect?: NamedEffect; - customEffect?: (element: Element, progress: any) => void; - keyframeEffect?: MotionKeyframeEffect; - } = { ...(effects[effectId] || {}), ...effect }; + const fullEffect: FullEffect = { ...(effects[effectId] || {}), ...effect }; let { key, conditions, triggerType } = fullEffect; @@ -78,34 +109,32 @@ export function resolveEffectForCSS( triggerType = TIME_TRIGGER_TO_DEFAULT_TYPE.get(trigger)!; } - const { namedEffect, customEffect, keyframeEffect, transition, transitionProperties, ...rest } = { - ...fullEffect, - key, - conditions, - effectId, - triggerType, - }; - - const initial = shouldUseInitial(interaction, rest); + const { namedEffect, keyframeEffect } = fullEffect; + // Need to verify validity of name for CSS? + if (!namedEffect && keyframeEffect && !keyframeEffect.name) { + const canUseEffectId = effectId && !(effects[effectId] && 'keyframeEffect' in effect); + keyframeEffect.name = canUseEffectId ? effectId : generateId(); + } - if (namedEffect) { - // With the 2D nature of pointerMove namedEffects, there is no easy way to mimic the - // behavior with CSSAnimations. - return isPointerMove || !namedEffect.type ? null : { namedEffect, initial, ...rest }; - } else if (keyframeEffect) { - // Need to verify validity of name for CSS? - if (!keyframeEffect.name) { - const canUseEffectId = effectId && !(effects[effectId] && 'keyframeEffect' in effect); - keyframeEffect.name = canUseEffectId ? effectId : generateId(); - } - return { keyframeEffect, initial, ...rest }; - } else if (customEffect) { - return isPointerMove ? null : { initial, ...rest }; - } else if (transition) { - return { transition, initial, ...rest }; - } else { - return transitionProperties ? { transitionProperties, initial, ...rest } : { initial, ...rest }; + if (!hasCSSRules(fullEffect, isPointerMove)) { + return null; } + + const full = { ...fullEffect, key, conditions, effectId, triggerType }; + const resolved = { ...full, initial: shouldUseInitial(interaction, full) }; + + const variants = getReducedMotionVariants( + resolved, + getReducedMotionPolicy( + fullEffect.reducedMotionPolicy, + options.policy, + interaction.reducedMotionPolicy, + options.defaultPolicy, + ), + { effects, force: options.force, collapsesStagger: options.collapsesStagger }, + ).map((variant) => resolveBody(variant, isPointerMove)); + + return variants.length ? { ...resolveBody(resolved, isPointerMove), variants } : null; } export function resolveSequenceForCSS( @@ -114,6 +143,7 @@ export function resolveSequenceForCSS( config: InteractConfig, fallbackId?: string, getSlotKey?: (effectIndex: number) => string, + options: ResolveOptions = {}, ): ResolvedSequence | null { const { sequences = {}, conditions: configConditions = {} } = config; const { trigger, conditions: interactionConditions = [] } = interaction; @@ -124,6 +154,11 @@ export function resolveSequenceForCSS( const { sequenceId } = sequence; const fullSequence = { ...(sequences[sequenceId] || {}), ...sequence }; + const policy = getReducedMotionPolicy( + fullSequence.reducedMotionPolicy, + interaction.reducedMotionPolicy, + options.defaultPolicy, + ); let { effects, @@ -163,6 +198,7 @@ export function resolveSequenceForCSS( interaction, config, `${sequenceId}-eff-${index}`, + { ...options, policy, collapsesStagger: policy !== 'ignore' }, ); return resolved && Object.assign(resolved, { slotKey: getSlotKey?.(index) }); }); @@ -180,6 +216,7 @@ export function resolveSequenceForCSS( delay, offset, offsetEasing, + reducedMotionPolicy: policy, effects: filteredEffects, }; } diff --git a/packages/interact/src/handlers/animationEnd.ts b/packages/interact/src/handlers/animationEnd.ts index 083fcc4f..687c6a60 100644 --- a/packages/interact/src/handlers/animationEnd.ts +++ b/packages/interact/src/handlers/animationEnd.ts @@ -14,20 +14,10 @@ function addAnimationEndHandler( target: HTMLElement, effect: TimeEffect, params: AnimationEndParams, - { - reducedMotion, - selectorCondition, - animation: preCreatedAnimation, - sourceAnimationOptions, - }: InteractOptions, + { selectorCondition, animation: preCreatedAnimation, sourceAnimationOptions }: InteractOptions, ): void { const animation = (preCreatedAnimation || - getAnimation( - target, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - )) as AnimationGroup | null; + getAnimation(target, effectToAnimationOptions(effect))) as AnimationGroup | null; // Early return if animation is null, no handler attached if (!animation) { diff --git a/packages/interact/src/handlers/effectHandlers.ts b/packages/interact/src/handlers/effectHandlers.ts index 879e85a9..5ae34c43 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -13,19 +13,13 @@ import fastdom from 'fastdom'; export function createTimeEffectHandler( element: HTMLElement, effect: TimeEffect & EffectBase, - reducedMotion: boolean = false, selectorCondition?: string, enterLeave?: EventTriggerConfigEnterLeave, preCreatedAnimation?: AnimationGroup, ): ((event: Event) => void) | null { const animation = preCreatedAnimation || - (getAnimation( - element, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - ) as AnimationGroup | null); + (getAnimation(element, effectToAnimationOptions(effect)) as AnimationGroup | null); if (!animation) { return null; diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 24fb59a4..2066f6dd 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -111,12 +111,7 @@ function addEventTriggerHandler( target: HTMLElement, effect: (TimeEffect | StateEffect) & EffectBase, options: EventTriggerParams, - { - reducedMotion, - targetController, - selectorCondition, - animation: preCreatedAnimation, - }: InteractOptions, + { targetController, selectorCondition, animation: preCreatedAnimation }: InteractOptions, ) { const genericConfig = createGenericEventConfig(options.eventConfig); const isTransition = @@ -139,7 +134,6 @@ function addEventTriggerHandler( handler = createTimeEffectHandler( target, effect as TimeEffect & EffectBase, - reducedMotion, selectorCondition, enterLeave, preCreatedAnimation, diff --git a/packages/interact/src/handlers/pointerMove.ts b/packages/interact/src/handlers/pointerMove.ts index 174d8203..1a3970dc 100644 --- a/packages/interact/src/handlers/pointerMove.ts +++ b/packages/interact/src/handlers/pointerMove.ts @@ -34,12 +34,8 @@ function addPointerMoveHandler( target: HTMLElement, effect: ScrubEffect, options: PointerMoveParams = {}, - { reducedMotion, selectorCondition }: InteractOptions, + { selectorCondition }: InteractOptions, ) { - if (reducedMotion) { - return; - } - if (selectorCondition && !target.matches(selectorCondition)) return; const triggerParams = { diff --git a/packages/interact/src/handlers/viewEnter.ts b/packages/interact/src/handlers/viewEnter.ts index 9d0d9e3e..f684f41c 100644 --- a/packages/interact/src/handlers/viewEnter.ts +++ b/packages/interact/src/handlers/viewEnter.ts @@ -157,17 +157,12 @@ function addViewEnterHandler( target: HTMLElement, effect: TimeEffect, options: ViewEnterParams = {}, - { reducedMotion, selectorCondition, animation: preCreatedAnimation }: InteractOptions = {}, + { selectorCondition, animation: preCreatedAnimation }: InteractOptions = {}, ) { const mergedOptions = { ...viewEnterOptions, ...options }; const type = effect.triggerType || 'once'; const animation = (preCreatedAnimation || - getAnimation( - target, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - )) as AnimationGroup | null; + getAnimation(target, effectToAnimationOptions(effect))) as AnimationGroup | null; // Early return if animation is null, no observer created if (!animation) { diff --git a/packages/interact/src/handlers/viewProgress.ts b/packages/interact/src/handlers/viewProgress.ts index a722605a..172a914f 100644 --- a/packages/interact/src/handlers/viewProgress.ts +++ b/packages/interact/src/handlers/viewProgress.ts @@ -20,12 +20,8 @@ function addViewProgressHandler( target: HTMLElement, effect: ScrubEffect, __: ViewEnterParams, - { reducedMotion, selectorCondition }: InteractOptions, + { selectorCondition }: InteractOptions, ): void { - if (reducedMotion) { - return; - } - if (selectorCondition && !target.matches(selectorCondition)) return; const triggerParams = { diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index a9354530..eec29a25 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -1,5 +1,11 @@ import type { TriggerType, TriggerParams } from './triggers'; -import type { Effect, EffectRef, EffectProperty, TimeAnimationTriggerType } from './effects'; +import type { + Effect, + EffectRef, + EffectProperty, + ReducedMotionPolicy, + TimeAnimationTriggerType, +} from './effects'; import type { PluginFields } from './plugins'; export type Condition = { @@ -14,6 +20,7 @@ export type SequenceOptionsConfig = { sequenceId?: string; conditions?: string[]; triggerType?: TimeAnimationTriggerType; + reducedMotionPolicy?: ReducedMotionPolicy; }; export type SequenceConfig = SequenceOptionsConfig & { @@ -27,6 +34,7 @@ export type SequenceConfigRef = { offset?: number; offsetEasing?: string | ((p: number) => number); conditions?: string[]; + reducedMotionPolicy?: ReducedMotionPolicy; }; export type InteractionTrigger = { @@ -37,6 +45,7 @@ export type InteractionTrigger = { params?: TriggerParams; conditions?: string[]; selector?: string; + reducedMotionPolicy?: ReducedMotionPolicy; } & PluginFields; // `$` fields route to plugins registered via `Interact.use()` export type Interaction = InteractionTrigger & { @@ -67,6 +76,8 @@ export type ResolvedEffect = ElementIdentifier & suffix?: string; // the effect's position in the config (see getSlotKey) slotKey?: string; + // the effect's reduced-motion variants, each under its reserved media (see getReducedMotionVariants) + variants?: ResolvedEffect[]; }; export type ResolvedSequence = { @@ -76,5 +87,6 @@ export type ResolvedSequence = { offset: number; offsetEasing: string; conditions: string[]; + reducedMotionPolicy: ReducedMotionPolicy; effects: ResolvedEffect[]; }; diff --git a/packages/interact/src/types/css.ts b/packages/interact/src/types/css.ts index 554e90db..4ae76148 100644 --- a/packages/interact/src/types/css.ts +++ b/packages/interact/src/types/css.ts @@ -1,4 +1,11 @@ +import type { MotionKeyframeEffect, NamedEffect } from '@wix/motion'; import type { InteractPluginStyles } from './plugins'; +import type { + EffectBase, + ReducedMotionPolicy, + StateEffect, + TimeAnimationTriggerType, +} from './effects'; /** * Options bag for `generate()`. Passed as its single optional 2nd argument, which also accepts a @@ -19,6 +26,16 @@ export type GenerateOptions = { * and each config's `Interact.create()` must get the same index, since unnamed effects' ids are suffixed too. */ configIndex?: number; + /** + * Generates only the reduced-motion outcome of every effect, unconditionally. Must equal the instance's + * `forceReducedMotion` at runtime. Defaults to `Interact.forceReducedMotion` (see `Interact.setup()`). + */ + forceReducedMotion?: boolean; + /** + * The policy of effects that don't set one along their interaction, sequence or effect. Must equal the + * instance's `reducedMotionPolicy` at runtime. Defaults to `Interact.reducedMotionPolicy`. + */ + reducedMotionPolicy?: ReducedMotionPolicy; }; export type ListPropertyName = @@ -38,3 +55,24 @@ export type CSSRuleData = { selectorCondition?: string; selectorSuffix?: string; }; + +// an effect merged with its definition, as resolved for CSS +export type FullEffect = EffectBase & + StateEffect & { + triggerType?: TimeAnimationTriggerType; + namedEffect?: NamedEffect; + customEffect?: (element: Element, progress: any) => void; + keyframeEffect?: MotionKeyframeEffect; + }; + +export type ResolveOptions = { + // the instance's + defaultPolicy?: ReducedMotionPolicy; + force?: boolean; +}; + +export type ResolveEffectOptions = ResolveOptions & { + // inherited from the sequence + policy?: ReducedMotionPolicy; + collapsesStagger?: boolean; +}; diff --git a/packages/interact/src/types/effects.ts b/packages/interact/src/types/effects.ts index 46e37509..f689266e 100644 --- a/packages/interact/src/types/effects.ts +++ b/packages/interact/src/types/effects.ts @@ -17,6 +17,9 @@ export type TimeAnimationTriggerType = 'once' | 'repeat' | 'alternate' | 'state' export type StateAction = 'add' | 'remove' | 'toggle' | 'clear'; +// a string other than the enum values is an effectId in config.effects +export type ReducedMotionPolicy = 'ignore' | 'cancel' | 'collapse' | (string & {}); + type EffectEffectProperty = | { keyframeEffect: MotionKeyframeEffect; @@ -85,6 +88,7 @@ export type EffectBase = { conditions?: string[]; selector?: string; effectId?: string; + reducedMotionPolicy?: ReducedMotionPolicy; } & PluginFields; // `$` fields route to plugins registered via `Interact.use()` export type EffectRef = EffectBase & { effectId: string }; diff --git a/packages/interact/src/types/handlers.ts b/packages/interact/src/types/handlers.ts index af73a682..a1d63a8c 100644 --- a/packages/interact/src/types/handlers.ts +++ b/packages/interact/src/types/handlers.ts @@ -20,7 +20,6 @@ export type InteractionParamsTypes = { }; export type InteractOptions = { - reducedMotion?: boolean; targetController?: IInteractionController; selectorCondition?: string; allowA11yTriggers?: boolean; diff --git a/packages/interact/src/types/internal.ts b/packages/interact/src/types/internal.ts index f6c5f263..a9a5cb00 100644 --- a/packages/interact/src/types/internal.ts +++ b/packages/interact/src/types/internal.ts @@ -1,4 +1,10 @@ -import type { Effect, EffectRef, StateEffect, TransitionProperty } from './effects'; +import type { + Effect, + EffectRef, + ReducedMotionPolicy, + StateEffect, + TransitionProperty, +} from './effects'; import type { Condition, SequenceConfig, Interaction, InteractionTrigger } from './config'; export type InteractCache = { @@ -11,6 +17,9 @@ export type InteractCache = { conditions: { [conditionId: string]: Condition; }; + // the instance's + reducedMotionPolicy?: ReducedMotionPolicy; + forceReducedMotion?: boolean; interactions: { [path: string]: { triggers: Interaction[]; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 933c7585..e5f75ee7 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -35,6 +35,12 @@ export function generateId() { ); } +export function isTransitionEffect( + effect: object, +): effect is Pick { + return !!((effect as StateEffect).transition || (effect as StateEffect).transitionProperties); +} + /** * Resolves the style properties of a state effect - `transition.styleProperties` wins * when both are set - with property names normalized to the kebab-case form CSS expects. @@ -59,7 +65,7 @@ export function transitionEffectToTransitionsList(transitionEffect: StateEffect) if (transition?.styleProperties) { const { duration, easing, delay } = transition; - if (duration) { + if (duration || delay) { const hasCustomPropertiesTransition = transition.styleProperties.some((styleProperty) => styleProperty.name.startsWith('--'), ); @@ -67,13 +73,13 @@ export function transitionEffectToTransitionsList(transitionEffect: StateEffect) if (hasCustomPropertiesTransition) { // If there are custom properties in the transition, we need to fall back to Viewer's legacy implementation transitions = [ - `all ${duration}ms ${getEasing(easing || 'ease')}${delay ? ` ${delay}ms` : ''}`, + `all ${duration || 0}ms ${getEasing(easing || 'ease')}${delay ? ` ${delay}ms` : ''}`, 'visibility 0s', ]; } else { transitions = transition.styleProperties.map( (styleProperty) => - `${toCSSPropertyName(styleProperty.name)} ${duration}ms ${getEasing( + `${toCSSPropertyName(styleProperty.name)} ${duration || 0}ms ${getEasing( easing || 'ease', )}${delay ? ` ${delay}ms` : ''}`, ); @@ -82,10 +88,10 @@ export function transitionEffectToTransitionsList(transitionEffect: StateEffect) } else { transitions = transitionProperties - ?.filter((property) => property.duration) + ?.filter((property) => property.duration || property.delay) .map( (property) => - `${toCSSPropertyName(property.name)} ${property.duration}ms ${ + `${toCSSPropertyName(property.name)} ${property.duration || 0}ms ${ getEasing(property.easing) || 'ease' }${property.delay ? ` ${property.delay}ms` : ''}`, ) || []; @@ -139,9 +145,9 @@ export function createTransitionCSS({ ? applySelectorCondition(transitionSelector, selectorCondition) : transitionSelector; - result.push(`@media (prefers-reduced-motion: no-preference) { ${finalTransitionSelector} { + result.push(`${finalTransitionSelector} { transition: ${transitions.join(', ')}; - } }`); + }`); } return result; } diff --git a/packages/interact/test/conditions.spec.ts b/packages/interact/test/conditions.spec.ts index e0e2fa51..d882e2e8 100644 --- a/packages/interact/test/conditions.spec.ts +++ b/packages/interact/test/conditions.spec.ts @@ -2,6 +2,7 @@ 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'; +import { withNoReducedMotionPreference } from './matchMedia'; // Mock @wix/motion module vi.mock('@wix/motion', async () => { @@ -59,7 +60,7 @@ function mockMatchMedia(matchingQueries: string[] = []) { mockMQLs = new Map(); window.matchMedia = vi.fn((query: string) => { - const matches = matchingQueries.includes(query); + const matches = withNoReducedMotionPreference((q) => matchingQueries.includes(q))(query); const mql = { matches, media: query, @@ -207,7 +208,7 @@ describe('Interaction-level conditions in addEffectsForTarget', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -295,7 +296,7 @@ describe('Interaction-level conditions in addEffectsForTarget', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -350,7 +351,7 @@ describe('Interaction-level conditions in addEffectsForTarget', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -414,3 +415,235 @@ describe('Interaction-level conditions in addEffectsForTarget', () => { }); }); }); + +describe('reducedMotionPolicy at runtime', () => { + const REDUCE = '(prefers-reduced-motion: reduce)'; + const NO_PREFERENCE = '(prefers-reduced-motion: no-preference)'; + let instance: Interact; + + const slide = { + namedEffect: { type: 'SlideIn', direction: 'right', power: 'medium' } as NamedEffect, + duration: 500, + delay: 100, + }; + + const createConfig = (reducedMotionPolicy?: string): InteractConfig => ({ + effects: { + fade: { namedEffect: { type: 'FadeIn' } as NamedEffect, duration: 300 }, + }, + interactions: [ + { + trigger: 'click', + key: 'button', + effects: [{ ...slide, reducedMotionPolicy }], + }, + ], + }); + + const addButton = () => { + const element = document.createElement('div'); + add(element, 'button'); + return element; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + instance?.destroy(); + }); + + it('plays the effect as is when motion is not reduced', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([NO_PREFERENCE]); + instance = Interact.create(createConfig('collapse')); + + addButton(); + + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 500, delay: 100 }), + undefined, + expect.anything(), + ); + }); + + it('plays the collapsed effect when motion is reduced', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + instance = Interact.create(createConfig('collapse')); + + addButton(); + + expect(getWebAnimation).toHaveBeenCalledTimes(1); + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 0, delay: 100, namedEffect: slide.namedEffect }), + undefined, + expect.anything(), + ); + }); + + it('collapses by default when motion is reduced', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + instance = Interact.create(createConfig()); + + addButton(); + + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 0, delay: 100 }), + undefined, + expect.anything(), + ); + }); + + describe('instance policy', () => { + afterEach(() => { + Interact.setup({ reducedMotionPolicy: 'collapse' }); + }); + + it('takes the policy from Interact.setup()', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + Interact.setup({ reducedMotionPolicy: 'cancel' }); + instance = Interact.create(createConfig()); + + addButton(); + + expect(getWebAnimation).not.toHaveBeenCalled(); + }); + + it("takes the instance's policy over Interact.setup()", async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + Interact.setup({ reducedMotionPolicy: 'cancel' }); + instance = Interact.create(createConfig(), { reducedMotionPolicy: 'ignore' }); + + addButton(); + + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 500 }), + undefined, + expect.anything(), + ); + }); + }); + + it('plays the replacement when motion is reduced', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + instance = Interact.create(createConfig('fade')); + + addButton(); + + const [, options] = vi.mocked(getWebAnimation).mock.calls[0]; + expect(options).toMatchObject({ duration: 300, namedEffect: { type: 'FadeIn' } }); + expect(options).not.toHaveProperty('delay'); + }); + + it('skips a cancelled effect when motion is reduced', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + instance = Interact.create(createConfig('cancel')); + + addButton(); + + expect(getWebAnimation).not.toHaveBeenCalled(); + }); + + it('listens on the media of every variant', () => { + mockMatchMedia([NO_PREFERENCE]); + instance = Interact.create(createConfig('collapse')); + + addButton(); + + [REDUCE, NO_PREFERENCE].forEach((query) => + expect(mockMQLs.get(query)!.addEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ), + ); + }); + + it('lets an earlier variation win when the effect is cancelled', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([REDUCE]); + instance = Interact.create({ + effects: {}, + interactions: [ + { + trigger: 'click', + key: 'button', + effects: [ + { key: 'target', ...slide, duration: 200, reducedMotionPolicy: 'ignore' }, + { key: 'target', ...slide, reducedMotionPolicy: 'cancel' }, + ], + }, + ], + }); + + add(document.createElement('div'), 'target'); + addButton(); + + expect(getWebAnimation).toHaveBeenCalledTimes(1); + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 200 }), + undefined, + expect.anything(), + ); + }); + + describe('forced', () => { + afterEach(() => { + Interact.forceReducedMotion = false; + }); + + it('is forced per instance, over the static flag', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([NO_PREFERENCE]); + Interact.forceReducedMotion = true; + instance = Interact.create(createConfig('collapse'), { forceReducedMotion: false }); + + addButton(); + + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 500 }), + undefined, + expect.anything(), + ); + }); + + it('plays the reduced outcome regardless of the media', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([NO_PREFERENCE]); + Interact.forceReducedMotion = true; + instance = Interact.create(createConfig('collapse')); + + addButton(); + + expect(getWebAnimation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ duration: 0 }), + undefined, + expect.anything(), + ); + }); + + it('skips a cancelled effect regardless of the media', async () => { + const { getWebAnimation } = await import('@wix/motion'); + mockMatchMedia([NO_PREFERENCE]); + Interact.forceReducedMotion = true; + instance = Interact.create(createConfig('cancel')); + + addButton(); + + expect(getWebAnimation).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 212d482b..fcb5067c 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1283,7 +1283,7 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { cssRules } = _generate(config, { reducedMotionPolicy: 'ignore' }); const animRules = cssRules.filter( (r) => @@ -1474,7 +1474,7 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { cssRules } = _generate(config, { reducedMotionPolicy: 'ignore' }); const animPropsByKey = (key: string) => cssRules @@ -2424,3 +2424,301 @@ describe('css._generate', () => { }); }); }); + +describe('css reducedMotionPolicy', () => { + const NO_PREFERENCE = '(prefers-reduced-motion: no-preference)'; + const REDUCE = '(prefers-reduced-motion: reduce)'; + + const slide = { + keyframeEffect: { name: 'slide', keyframes: [{ translate: '-10px' }, { translate: '0' }] }, + duration: 800, + delay: 100, + }; + const fade = { + keyframeEffect: { name: '', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 300, + }; + + const configWith = (effect: object, rest: Partial = {}): InteractConfig => ({ + effects: { fade }, + interactions: [{ key: 'a', trigger: 'hover', effects: [effect] }], + ...rest, + }); + + const declarationsByMedia = (cssRules: CSSRuleData[]) => + cssRules.map(({ media, declarations }) => ({ media, declarations })); + + it('emits each variant under exclusive media into the same list entry', () => { + const { cssRules, listsRule, atProperty } = _generate( + configWith({ ...slide, reducedMotionPolicy: 'collapse' }), + ); + const plain = _generate(configWith(slide)); + + const animationLists = (value: string) => [ + { _listPropertyName: 'animation', name: '--anm-0', value: expect.stringContaining(value) }, + ]; + expect(declarationsByMedia(cssRules)).toEqual([ + { media: NO_PREFERENCE, declarations: animationLists('slide 800ms 100ms') }, + { media: REDUCE, declarations: animationLists('slide 1ms 100ms') }, + ]); + expect(listsRule).toBe(plain.listsRule); + expect(atProperty).toEqual(plain.atProperty); + }); + + it('emits the effect under no-preference only for cancel, its initial rules included', () => { + const { cssRules } = _generate({ + interactions: [ + { + key: 'a', + trigger: 'viewEnter', + effects: [{ ...slide, reducedMotionPolicy: 'cancel' }], + }, + ], + }); + + expect(cssRules.length).toBeGreaterThan(1); + cssRules.forEach(({ media }) => expect(media).toBe(NO_PREFERENCE)); + }); + + it('emits the replacement under reduce, its keyframes named by its id', () => { + const { cssRules, keyframes } = _generate( + configWith({ ...slide, reducedMotionPolicy: 'fade' }), + ); + + expect([...keyframes.keys()]).toEqual(['slide', 'fade']); + expect(cssRules.find(({ media }) => media === REDUCE)!.declarations).toContainEqual( + expect.objectContaining({ + name: '--anm-0', + value: expect.stringContaining('fade 300ms 0ms'), + }), + ); + }); + + it('only hides a collapsed entrance until it enters, leaving its animation to the runtime', () => { + const { cssRules } = _generate({ + interactions: [ + { + key: 'a', + trigger: 'viewEnter', + effects: [{ ...slide, reducedMotionPolicy: 'collapse' }], + }, + ], + }); + const reduced = cssRules.filter(({ media }) => media === REDUCE); + + expect(reduced.map(({ selectorSuffix }) => selectorSuffix)).toEqual([ + ':not([data-interact-enter])', + ]); + expect(reduced[0].declarations).toBe(DEFAULT_INITIAL); + expect( + cssRules + .filter(({ media }) => media === NO_PREFERENCE) + .map(({ selectorSuffix }) => selectorSuffix), + ).toEqual([':not([data-interact-enter])', ':not([data-interact-enter="done"])']); + }); + + it('collapses a transition to a 0ms one keeping its delay, and its state applies under both', () => { + const transition = { + transition: { + duration: 300, + delay: 100, + styleProperties: [{ name: 'opacity', value: '0.5' }], + }, + reducedMotionPolicy: 'collapse', + }; + const { cssRules } = _generate(configWith(transition)); + + expect( + cssRules.map(({ media, states, declarations }) => ({ media, states, declarations })), + ).toEqual([ + { + media: NO_PREFERENCE, + states: undefined, + declarations: [{ name: '--trns-0', value: 'opacity 300ms ease 100ms' }], + }, + { + media: NO_PREFERENCE, + states: ['eff-0-0'], + declarations: [{ name: 'opacity', value: '0.5' }], + }, + { + media: REDUCE, + states: undefined, + declarations: [{ name: '--trns-0', value: 'opacity 0ms ease 100ms' }], + }, + { media: REDUCE, states: ['eff-0-0'], declarations: [{ name: 'opacity', value: '0.5' }] }, + ]); + }); + + it('falls back, under each variant, only the entries assigned before its effect', () => { + const { cssRules } = _generate({ + effects: { custom: { customEffect: () => {}, duration: 300 } }, + interactions: [ + { + key: 'a', + trigger: 'hover', + effects: [ + { ...slide, reducedMotionPolicy: 'ignore' }, + { ...slide, reducedMotionPolicy: 'custom' }, + ], + }, + ], + }); + + expect(declarationsByMedia(cssRules)).toEqual([ + { media: '', declarations: [expect.objectContaining({ name: '--anm-0' })] }, + { media: NO_PREFERENCE, declarations: [expect.objectContaining({ name: '--anm-0' })] }, + { media: REDUCE, declarations: [{ name: '--anm-0', value: 'none' }] }, + ]); + }); + + it('inherits the policy from the interaction and the instance, the effect taking precedence', () => { + const fromInteraction = _generate( + { + effects: { fade }, + interactions: [ + { + key: 'a', + trigger: 'hover', + reducedMotionPolicy: 'cancel', + effects: [slide, { key: 'b', ...slide, reducedMotionPolicy: 'ignore' }], + }, + { key: 'c', trigger: 'hover', effects: [slide] }, + ], + }, + { reducedMotionPolicy: 'ignore' }, + ); + + expect(fromInteraction.cssRules.map(({ key, media }) => ({ key, media }))).toEqual([ + { key: 'a', media: NO_PREFERENCE }, + { key: 'b', media: '' }, + { key: 'c', media: '' }, + ]); + }); + + it('takes the instance policy and forcing from Interact.setup()', () => { + Interact.setup({ reducedMotionPolicy: 'cancel', forceReducedMotion: true }); + + try { + expect(_generate(configWith(slide)).cssRules).toEqual([]); + } finally { + Interact.setup({ reducedMotionPolicy: 'collapse', forceReducedMotion: false }); + } + }); + + it('inherits the policy from the sequence, the effect taking precedence', () => { + const { cssRules } = _generate({ + sequences: { + seq: { effects: [slide, { key: 'b', ...slide, reducedMotionPolicy: 'ignore' }] }, + }, + interactions: [ + { + key: 'a', + trigger: 'click', + reducedMotionPolicy: 'collapse', + sequences: [{ sequenceId: 'seq', reducedMotionPolicy: 'cancel' }], + }, + ], + }); + + const effectRules = cssRules.filter(({ declarations }) => + declarations.some(({ name }) => name.includes('slot') || name === '--anm-0'), + ); + expect(effectRules.map(({ key, media }) => ({ key, media }))).toEqual([ + { key: 'a', media: NO_PREFERENCE }, + { key: 'b', media: NO_PREFERENCE }, + { key: 'b', media: REDUCE }, + ]); + }); + + it('collapses the stagger under reduce, unless the sequence ignores it', () => { + const staggerOf = (reducedMotionPolicy: string) => + _generate({ + interactions: [ + { + key: 'a', + trigger: 'click', + sequences: [ + { + offset: 100, + reducedMotionPolicy, + effects: [{ ...slide, reducedMotionPolicy: 'ignore' }], + }, + ], + }, + ], + }).cssRules.map(({ media, declarations }) => ({ + media, + staggered: declarations.some(({ value }) => String(value).includes('-index')), + })); + + expect(staggerOf('collapse')).toEqual([ + { media: NO_PREFERENCE, staggered: true }, + { media: REDUCE, staggered: false }, + ]); + expect(staggerOf('ignore')).toEqual([{ media: '', staggered: true }]); + }); + + it('emits only the reduced outcome, unconditionally, when forced', () => { + const config = configWith({ ...slide, reducedMotionPolicy: 'fade' }); + + expect(declarationsByMedia(_generate(config, { forceReducedMotion: true }).cssRules)).toEqual([ + { + media: '', + declarations: [expect.objectContaining({ value: expect.stringContaining('fade 300ms') })], + }, + ]); + expect( + _generate(configWith({ ...slide, reducedMotionPolicy: 'cancel' }), { + forceReducedMotion: true, + }).cssRules, + ).toEqual([]); + }); + + describe('keyframe slots', () => { + const style = + (name: string) => + ({ suffix = '', ...options }: { suffix?: string }) => [ + { ...options, name: `${name}${suffix}`, keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + ]; + registerEffects({ + RMMove: { + web: style('rm-move'), + style: style('rm-move'), + getNames: ({ suffix = '' }: { suffix?: string }) => [`rm-move${suffix}`], + }, + RMFade: { + web: style('rm-fade'), + style: style('rm-fade'), + getNames: ({ suffix = '' }: { suffix?: string }) => [`rm-fade${suffix}`], + }, + } as any); + + it("picks a slot free of the names of both of a position's variants", () => { + const config: InteractConfig = { + effects: { move: { namedEffect: { type: 'RMMove' } as any, duration: 100 } }, + interactions: [ + { key: 'a', trigger: 'viewEnter', effects: [{ effectId: 'move' }] }, + { + key: 'a', + trigger: 'click', + effects: [ + { + namedEffect: { type: 'RMFade' } as any, + duration: 100, + reducedMotionPolicy: 'move', + }, + ], + }, + ], + }; + + expect([...getKeyframeSlots(config).values()]).toEqual(['-1']); + expect([..._generate(config).keyframes.keys()]).toEqual([ + 'rm-move', + 'rm-fade-1', + 'rm-move-1', + ]); + }); + }); +}); diff --git a/packages/interact/test/matchMedia.ts b/packages/interact/test/matchMedia.ts new file mode 100644 index 00000000..6d4ae5d2 --- /dev/null +++ b/packages/interact/test/matchMedia.ts @@ -0,0 +1,31 @@ +const NO_PREFERENCE = '(prefers-reduced-motion: no-preference)'; +const REDUCE = '(prefers-reduced-motion: reduce)'; + +// a browser without a reduced-motion preference, unless `matches` matches reduce +export function withNoReducedMotionPreference( + matches: (query: string) => boolean, +): (query: string) => boolean { + return (query) => { + if (matches(query)) { + return true; + } + if (!query.endsWith(NO_PREFERENCE) || matches(REDUCE)) { + return false; + } + const rest = query.slice(0, -NO_PREFERENCE.length).replace(/ and $/, ''); + return !rest || matches(rest); + }; +} + +export function createMediaQueryList(query: string, matches: boolean): MediaQueryList { + return { + matches, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } as unknown as MediaQueryList; +} diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index 80591de7..bf96fd73 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -6,6 +6,7 @@ import type { InteractConfig, ScrubEffect, TriggerType, IInteractElement } from import type { NamedEffect } from '@wix/motion'; import { effectToAnimationOptions } from '../src/handlers/utilities'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../src/handlers'; +import { withNoReducedMotionPreference } from './matchMedia'; // Mock @wix/motion module vi.mock('@wix/motion', async () => { @@ -412,7 +413,7 @@ describe('interact (mini)', () => { } const mql = { - matches: queryRule === query, + matches: withNoReducedMotionPreference((q) => q === queryRule)(query), media: query, onchange: null, addEventListener: vi.fn(), @@ -609,46 +610,59 @@ describe('interact (mini)', () => { }); describe('reduced motion', () => { - it('should pass reducedMotion=true to getWebAnimation when forceReducedMotion is true', async () => { - const { getWebAnimation } = await import('@wix/motion'); - Interact.forceReducedMotion = true; - Interact.create({ - interactions: [ + it.each([ + ['ignore', 1200], + [undefined, 0], + ['collapse', 0], + ] as const)( + 'should apply reducedMotionPolicy %s when forceReducedMotion is true', + async (reducedMotionPolicy, duration) => { + const { getWebAnimation } = await import('@wix/motion'); + Interact.setup({ forceReducedMotion: true }); + Interact.create( { - trigger: 'hover', - key: 'logo-hover', - effects: [ + interactions: [ { + trigger: 'hover', key: 'logo-hover', - effectId: 'logo-arc-in', + effects: [ + { + key: 'logo-hover', + effectId: 'logo-arc-in', + }, + ], }, ], + effects: { + 'logo-arc-in': { + namedEffect: { + type: 'ArcIn', + direction: 'right', + power: 'medium', + } as NamedEffect, + duration: 1200, + }, + }, }, - ], - effects: { - 'logo-arc-in': { - namedEffect: { - type: 'ArcIn', - direction: 'right', - power: 'medium', - } as NamedEffect, - duration: 1200, - }, - }, - }); + { reducedMotionPolicy }, + ); - element = document.createElement('div'); + element = document.createElement('div'); - add(element, 'logo-hover'); + add(element, 'logo-hover'); - expect(getWebAnimation).toHaveBeenCalledWith(element, expect.any(Object), undefined, { - reducedMotion: true, - }); + expect(getWebAnimation).toHaveBeenCalledWith( + element, + expect.objectContaining({ duration }), + undefined, + { reducedMotion: undefined }, + ); - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = false; - Interact.destroy(); - }); + Interact.destroy(); + }, + ); }); describe('add interaction', () => { @@ -700,7 +714,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -736,7 +750,7 @@ describe('interact (mini)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -759,13 +773,13 @@ describe('interact (mini)', () => { expect(getWebAnimation.mock.calls[1][0]).toBe(element); expect(getWebAnimation.mock.calls[2][0]).toBe(elementClick); expect(getWebAnimation.mock.calls[0][3]).toMatchObject({ - reducedMotion: false, + reducedMotion: undefined, }); expect(getWebAnimation.mock.calls[1][3]).toMatchObject({ - reducedMotion: false, + reducedMotion: undefined, }); expect(getWebAnimation.mock.calls[2][3]).toMatchObject({ - reducedMotion: false, + reducedMotion: undefined, }); }); @@ -959,7 +973,7 @@ describe('interact (mini)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1423,7 +1437,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1450,7 +1464,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1477,7 +1491,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1506,7 +1520,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1533,7 +1547,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1612,7 +1626,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); // Should not be called for mobile effect since condition doesn't match @@ -1624,7 +1638,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1691,7 +1705,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1754,7 +1768,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1807,7 +1821,7 @@ describe('interact (mini)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1925,7 +1939,7 @@ describe('interact (mini)', () => { animationTarget, expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -2248,7 +2262,7 @@ describe('interact (mini)', () => { ); expect(getWebAnimation).toHaveBeenCalledWith(deepTarget, expect.any(Object), undefined, { - reducedMotion: false, + reducedMotion: undefined, }); }); @@ -2333,7 +2347,7 @@ describe('interact (mini)', () => { // Effect should target firstElementChild, not the interaction's selector expect(getWebAnimation).toHaveBeenCalledWith(targetElement, expect.any(Object), undefined, { - reducedMotion: false, + reducedMotion: undefined, }); }); }); @@ -2762,7 +2776,7 @@ describe('interact (mini)', () => { }, }; - const instance = Interact.create(config); + const instance = Interact.create(config, { reducedMotionPolicy: 'ignore' }); const testElement = document.createElement('div'); diff --git a/packages/interact/test/react.spec.tsx b/packages/interact/test/react.spec.tsx index 119a31dd..40389842 100644 --- a/packages/interact/test/react.spec.tsx +++ b/packages/interact/test/react.spec.tsx @@ -5,6 +5,7 @@ import { Interaction, createInteractRef, Interact } from '../src/react'; import * as domApi from '../src/dom/api'; import type { InteractConfig } from '../src/types'; import type { NamedEffect } from '@wix/motion'; +import { withNoReducedMotionPreference } from './matchMedia'; // Mock @wix/motion module vi.mock('@wix/motion', async () => { @@ -196,7 +197,7 @@ describe('interact (react)', () => { } const mql = { - matches: queryRule === query, + matches: withNoReducedMotionPreference((q) => q === queryRule)(query), media: query, onchange: null, addEventListener: vi.fn(), @@ -456,7 +457,7 @@ describe('interact (react)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -481,7 +482,7 @@ describe('interact (react)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -500,7 +501,7 @@ describe('interact (react)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -742,7 +743,7 @@ describe('interact (react)', () => { }; mockMatchMedia(['min-width: 1024px']); - const instance = Interact.create(config); + const instance = Interact.create(config, { reducedMotionPolicy: 'ignore' }); render( @@ -806,7 +807,7 @@ describe('interact (react)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -837,7 +838,7 @@ describe('interact (react)', () => { }); describe('reduced motion', () => { - it('should pass reducedMotion=true to getWebAnimation when forceReducedMotion is true', async () => { + it('should collapse effects when forceReducedMotion is true', async () => { const { getWebAnimation } = await import('@wix/motion'); Interact.forceReducedMotion = true; @@ -851,9 +852,9 @@ describe('interact (react)', () => { expect(getWebAnimation).toHaveBeenCalledWith( expect.any(HTMLElement), - expect.any(Object), + expect.objectContaining({ duration: 0 }), undefined, - { reducedMotion: true }, + { reducedMotion: undefined }, ); }); }); diff --git a/packages/interact/test/reducedMotion.spec.ts b/packages/interact/test/reducedMotion.spec.ts new file mode 100644 index 00000000..2d3ca334 --- /dev/null +++ b/packages/interact/test/reducedMotion.spec.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; +import { + REDUCED_MOTION_NO_PREFERENCE as NO_PREFERENCE, + REDUCED_MOTION_REDUCE as REDUCE, + getReducedMotionPolicy, + getReducedMotionVariants, +} from '../src/core/reducedMotion'; +import { createTransitionCSS, transitionEffectToTransitionsList } from '../src/utils'; +import type { Effect } from '../src/types'; + +const slide = { + effectId: 'slide', + key: 'target', + selector: '.item', + conditions: ['desktop'], + triggerType: 'alternate' as const, + keyframeEffect: { name: 'slide', keyframes: [{ translate: '-10px' }, { translate: '0' }] }, + duration: 800, + delay: 100, + easing: 'ease-in', +}; + +const effects: Record = { + fade: { + key: 'elsewhere', + conditions: ['mobile'], + reducedMotionPolicy: 'cancel', + keyframeEffect: { name: '', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 300, + }, + dim: { transition: { duration: 200, styleProperties: [{ name: 'opacity', value: '0.5' }] } }, + scrub: { keyframeEffect: { name: 'scrub', keyframes: [{ opacity: 0 }] } }, +}; + +const { keyframeEffect, duration, delay, easing, ...scrubSlide } = slide; + +const options = { effects }; + +describe('reducedMotion', () => { + describe('getReducedMotionPolicy', () => { + it('takes the most specific level', () => { + expect(getReducedMotionPolicy('cancel', 'ignore', 'fade', 'ignore')).toBe('cancel'); + expect(getReducedMotionPolicy(undefined, 'ignore', 'fade', undefined)).toBe('ignore'); + expect(getReducedMotionPolicy(undefined, undefined, undefined, 'fade')).toBe('fade'); + }); + + it('defaults to collapse', () => { + expect(getReducedMotionPolicy(undefined, undefined, undefined, undefined)).toBe('collapse'); + }); + }); + + describe('getReducedMotionVariants', () => { + it('keeps the effect as is for ignore', () => { + expect(getReducedMotionVariants(slide, 'ignore', options)).toEqual([slide]); + }); + + it('conditions the effect on no-preference for cancel', () => { + expect(getReducedMotionVariants(slide, 'cancel', options)).toEqual([ + { ...slide, conditions: ['desktop', NO_PREFERENCE] }, + ]); + }); + + it('collapses the duration only, under reduce', () => { + const repeating = { ...slide, iterations: 2.5, alternate: true }; + + expect(getReducedMotionVariants(repeating, 'collapse', options)).toEqual([ + { ...repeating, conditions: ['desktop', NO_PREFERENCE] }, + { ...repeating, duration: 0, conditions: ['desktop', REDUCE] }, + ]); + }); + + it('collapses every transition duration and keeps the delays', () => { + const transitionProperties = [ + { name: 'opacity', value: '0', duration: 200, delay: 50 }, + { name: 'color', value: 'red', duration: 300 }, + ]; + const [, collapsed] = getReducedMotionVariants({ transitionProperties }, 'collapse', options); + + expect(collapsed).toEqual({ + transitionProperties: [ + { name: 'opacity', value: '0', duration: 0, delay: 50 }, + { name: 'color', value: 'red', duration: 0 }, + ], + conditions: [REDUCE], + }); + }); + + it.each([0, Infinity])('falls back to cancel when collapsing %s iterations', (iterations) => { + const looping = { ...slide, iterations }; + + expect(getReducedMotionVariants(looping, 'collapse', options)).toEqual([ + { ...looping, conditions: ['desktop', NO_PREFERENCE] }, + ]); + }); + + it('falls back to cancel when collapsing a scrub effect', () => { + const scrub = { ...scrubSlide, keyframeEffect }; + + expect(getReducedMotionVariants(scrub, 'collapse', options)).toEqual([ + { ...scrub, conditions: ['desktop', NO_PREFERENCE] }, + ]); + }); + + it("replaces the effect's body and keeps its identity and targeting", () => { + const [, replaced] = getReducedMotionVariants(slide, 'fade', options); + + expect(replaced).toEqual({ + effectId: 'slide', + key: 'target', + selector: '.item', + conditions: ['desktop', REDUCE], + triggerType: 'alternate', + keyframeEffect: { name: 'fade', keyframes: [{ opacity: 0 }, { opacity: 1 }] }, + duration: 300, + }); + }); + + it('names an unnamed replacement keyframeEffect by the replacement id without mutating it', () => { + getReducedMotionVariants(slide, 'fade', options); + + expect((effects.fade as { keyframeEffect: { name: string } }).keyframeEffect.name).toBe(''); + }); + + it.each([ + ['an incompatible', 'dim'], + ['a missing', 'nope'], + ])('falls back to collapse for %s replacement', (_, policy) => { + expect(getReducedMotionVariants(slide, policy, options)).toEqual([ + { ...slide, conditions: ['desktop', NO_PREFERENCE] }, + { ...slide, duration: 0, conditions: ['desktop', REDUCE] }, + ]); + }); + + it('accepts a scrub replacement for a scrub effect', () => { + const scrub = { ...scrubSlide, keyframeEffect }; + const [, replaced] = getReducedMotionVariants(scrub, 'scrub', options); + + expect(replaced).toMatchObject({ keyframeEffect: { name: 'scrub' } }); + expect(replaced).not.toHaveProperty('duration'); + }); + + it('splits an ignored effect whose sequence stagger collapses', () => { + expect( + getReducedMotionVariants(slide, 'ignore', { ...options, collapsesStagger: true }), + ).toEqual([ + { ...slide, conditions: ['desktop', NO_PREFERENCE] }, + { ...slide, conditions: ['desktop', REDUCE] }, + ]); + }); + + describe('forced', () => { + const forced = { ...options, force: true }; + + it('keeps the effect for ignore', () => { + expect(getReducedMotionVariants(slide, 'ignore', forced)).toEqual([slide]); + }); + + it('drops the effect for cancel', () => { + expect(getReducedMotionVariants(slide, 'cancel', forced)).toEqual([]); + }); + + it('returns the reduced variant only, unconditionally', () => { + expect(getReducedMotionVariants(slide, 'collapse', forced)).toEqual([ + { ...slide, duration: 0 }, + ]); + }); + }); + }); + + describe('transitions', () => { + it('keeps a 0ms transition that has a delay', () => { + expect( + transitionEffectToTransitionsList({ + transition: { + duration: 0, + delay: 100, + styleProperties: [{ name: 'opacity', value: '0' }], + }, + }), + ).toEqual(['opacity 0ms ease 100ms']); + expect( + transitionEffectToTransitionsList({ + transitionProperties: [ + { name: 'opacity', value: '0', delay: 100 }, + { name: 'color', value: 'red', duration: 0 }, + ], + }), + ).toEqual(['opacity 0ms linear 100ms']); + }); + + it('drops a 0ms transition without a delay', () => { + expect( + transitionEffectToTransitionsList({ + transition: { duration: 0, styleProperties: [{ name: 'opacity', value: '0' }] }, + }), + ).toEqual([]); + }); + + it('renders the runtime transition regardless of the reduced-motion preference', () => { + const [, transitionRule] = createTransitionCSS({ + key: 'a', + effectId: 'dim', + transition: { duration: 200, styleProperties: [{ name: 'opacity', value: '0.5' }] }, + }); + + expect(transitionRule).not.toContain('prefers-reduced-motion'); + expect(transitionRule).toContain('transition: opacity 200ms ease'); + }); + }); +}); diff --git a/packages/interact/test/sequences.spec.ts b/packages/interact/test/sequences.spec.ts index a82f42c3..07d9b662 100644 --- a/packages/interact/test/sequences.spec.ts +++ b/packages/interact/test/sequences.spec.ts @@ -6,6 +6,7 @@ import type { InteractConfig, SequenceConfig } from '../src/types'; import { getSequence, createAnimationGroups } from '@wix/motion'; import { addListItems } from '../src/core/add'; import { removeListItems } from '../src/core/remove'; +import { withNoReducedMotionPreference } from './matchMedia'; vi.mock('@wix/motion', async () => { const { toCSSPropertyName } = await vi.importActual('@wix/motion'); @@ -427,7 +428,6 @@ describe('interact sequences', () => { expect(getSequenceMock).toHaveBeenCalledWith( expect.objectContaining({ sequenceId: 'view-seq' }), expect.any(Array), - { reducedMotion: false }, ); }); @@ -450,7 +450,6 @@ describe('interact sequences', () => { expect(getSequenceMock).toHaveBeenCalledWith( expect.objectContaining({ sequenceId: 'click-seq' }), expect.any(Array), - { reducedMotion: false }, ); }); @@ -715,7 +714,6 @@ describe('interact sequences', () => { expect(interactionCall?.[4]).toEqual( expect.objectContaining({ animation: mockSequence, - reducedMotion: false, allowA11yTriggers: expect.any(Boolean), }), ); @@ -897,7 +895,6 @@ describe('interact sequences', () => { expect(getSequenceMock).toHaveBeenCalledWith( expect.objectContaining({ sequenceId: 'cross-seq' }), expect.any(Array), - { reducedMotion: false }, ); }); @@ -929,7 +926,6 @@ describe('interact sequences', () => { expect(getSequenceMock).toHaveBeenCalledWith( expect.objectContaining({ sequenceId: 'source-after-seq' }), expect.any(Array), - { reducedMotion: false }, ); }); @@ -1735,12 +1731,11 @@ describe('interact sequences', () => { const animationGroupArgs: any[] = [ { target, options: { duration: 300, keyframeEffect: { name: 'fade' } } }, ]; - const context = { reducedMotion: true }; - Interact.getSequence('key-D', sequenceOptions, animationGroupArgs, context); + Interact.getSequence('key-D', sequenceOptions, animationGroupArgs); expect(getSequenceMock).toHaveBeenCalledTimes(1); - expect(getSequenceMock).toHaveBeenCalledWith(sequenceOptions, animationGroupArgs, context); + expect(getSequenceMock).toHaveBeenCalledWith(sequenceOptions, animationGroupArgs); }); }); @@ -1754,7 +1749,7 @@ describe('interact sequences', () => { writable: true, configurable: true, value: vi.fn((query: string) => { - const matches = matchingQueries.includes(query); + const matches = withNoReducedMotionPreference((q) => matchingQueries.includes(q))(query); const mql = { matches, media: query, @@ -1859,6 +1854,72 @@ describe('interact sequences', () => { ); }); + const createReducedMotionConfig = ( + sequenceHandling: string | undefined, + offset = 100, + ): InteractConfig => { + const keyframeEffect = { name: 'pop', keyframes: [{ opacity: 0 }, { opacity: 1 }] }; + return { + interactions: [ + { + trigger: 'click', + key: 'source-key', + reducedMotionPolicy: 'cancel', + sequences: [ + { + sequenceId: 'rm-seq', + offset, + reducedMotionPolicy: sequenceHandling, + effects: [ + { key: 'source-key', keyframeEffect, duration: 300 }, + { + key: 'source-key', + keyframeEffect, + duration: 400, + reducedMotionPolicy: 'ignore', + }, + ], + }, + ], + }, + ], + }; + }; + + const getSequenceArgs = (config: InteractConfig) => { + Interact.create(config, { useCustomElement: false }); + addElement(createInteractElement().element, 'source-key'); + + expect(getSequence).toHaveBeenCalledTimes(1); + return vi.mocked(getSequence).mock.calls[0]; + }; + + test.each([ + ['collapse', [0, 400]], + [undefined, [400]], + ])( + 'resolves reducedMotionPolicy per effect, the sequence (%s) over the interaction (cancel)', + (sequenceHandling, durations) => { + mockMatchMedia(['(prefers-reduced-motion: reduce)']); + + const [, animationGroupArgs] = getSequenceArgs(createReducedMotionConfig(sequenceHandling)); + + expect(animationGroupArgs.map(({ options }) => options.duration)).toEqual(durations); + }, + ); + + test.each([ + ['(prefers-reduced-motion: reduce)', 'collapse', 0], + ['(prefers-reduced-motion: reduce)', 'ignore', 100], + ['(prefers-reduced-motion: no-preference)', 'collapse', 100], + ])('under %s, a %s sequence staggers by %s', (query, sequenceHandling, offset) => { + mockMatchMedia([query]); + + const [sequenceOptions] = getSequenceArgs(createReducedMotionConfig(sequenceHandling)); + + expect(sequenceOptions).toMatchObject({ offset }); + }); + test('sets up media query listener for sequence conditions', () => { mockMatchMedia(['(min-width: 1024px)']); const config = createBaseConfig(); diff --git a/packages/interact/test/setup.ts b/packages/interact/test/setup.ts new file mode 100644 index 00000000..97f582f4 --- /dev/null +++ b/packages/interact/test/setup.ts @@ -0,0 +1,6 @@ +import { createMediaQueryList, withNoReducedMotionPreference } from './matchMedia'; + +if (typeof window !== 'undefined' && !window.matchMedia) { + const matches = withNoReducedMotionPreference(() => false); + window.matchMedia = (query: string) => createMediaQueryList(query, matches(query)); +} diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts index 4750ef94..e8199746 100644 --- a/packages/interact/test/web.spec.ts +++ b/packages/interact/test/web.spec.ts @@ -6,6 +6,7 @@ import type { InteractConfig, ScrubEffect, IInteractElement, TriggerType } from import type { NamedEffect } from '@wix/motion'; import { effectToAnimationOptions } from '../src/handlers/utilities'; import { InteractionController } from '../src/core/InteractionController'; +import { withNoReducedMotionPreference } from './matchMedia'; // Mock @wix/motion module vi.mock('@wix/motion', async () => { @@ -375,7 +376,7 @@ describe('interact (web)', () => { } const mql = { - matches: queryRule === query, + matches: withNoReducedMotionPreference((q) => q === queryRule)(query), media: query, onchange: null, addEventListener: vi.fn(), @@ -590,51 +591,61 @@ describe('interact (web)', () => { }); describe('reduced motion', () => { - it('should pass reducedMotion=true to getWebAnimation when forceReducedMotion is true', async () => { - const { getWebAnimation } = await import('@wix/motion'); - Interact.forceReducedMotion = true; - Interact.create( - { - 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.each([ + ['ignore', 1200], + [undefined, 0], + ['collapse', 0], + ] as const)( + 'should apply reducedMotionPolicy %s when forceReducedMotion is true', + async (reducedMotionPolicy, duration) => { + const { getWebAnimation } = await import('@wix/motion'); + Interact.setup({ forceReducedMotion: true }); + Interact.create( + { + 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, + }, }, }, - }, - { useCustomElement: true }, - ); + { useCustomElement: true, reducedMotionPolicy }, + ); - element = document.createElement('interact-element') as IInteractElement; - const div = document.createElement('div'); - element.append(div); + element = document.createElement('interact-element') as IInteractElement; + const div = document.createElement('div'); + element.append(div); - add(element, 'logo-hover'); + add(element, 'logo-hover'); - expect(getWebAnimation).toHaveBeenCalledWith(div, expect.any(Object), undefined, { - reducedMotion: true, - }); + expect(getWebAnimation).toHaveBeenCalledWith( + div, + expect.objectContaining({ duration }), + undefined, + { reducedMotion: undefined }, + ); - Interact.forceReducedMotion = false; + Interact.forceReducedMotion = false; - Interact.destroy(); - }); + Interact.destroy(); + }, + ); }); describe('add interaction', () => { @@ -688,7 +699,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -826,7 +837,7 @@ describe('interact (web)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -853,13 +864,13 @@ describe('interact (web)', () => { expect(getWebAnimation.mock.calls[1][0]).toBe(div); expect(getWebAnimation.mock.calls[2][0]).toBe(divClick); expect(getWebAnimation.mock.calls[0][3]).toMatchObject({ - reducedMotion: false, + reducedMotion: undefined, }); expect(getWebAnimation.mock.calls[1][3]).toMatchObject({ - reducedMotion: false, + reducedMotion: undefined, }); expect(getWebAnimation.mock.calls[2][3]).toMatchObject({ - reducedMotion: false, + reducedMotion: undefined, }); }); @@ -1062,7 +1073,7 @@ describe('interact (web)', () => { expect.any(HTMLElement), expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1636,7 +1647,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1685,13 +1696,13 @@ describe('interact (web)', () => { targetElement.firstElementChild, expect.objectContaining({ namedEffect: expect.objectContaining({ type: 'SlideIn' }) }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); expect(getWebAnimation).toHaveBeenCalledWith( sourceElement.firstElementChild, expect.objectContaining({ namedEffect: expect.objectContaining({ type: 'SlideIn' }) }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1759,7 +1770,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1791,7 +1802,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1825,7 +1836,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -1857,7 +1868,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -1944,7 +1955,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); // Should not be called for mobile effect since condition doesn't match @@ -1956,7 +1967,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -2028,7 +2039,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -2096,7 +2107,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -2154,7 +2165,7 @@ describe('interact (web)', () => { }), }), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); @@ -2273,7 +2284,7 @@ describe('interact (web)', () => { animationTarget, expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); @@ -2597,7 +2608,7 @@ describe('interact (web)', () => { ); expect(getWebAnimation).toHaveBeenCalledWith(deepTarget, expect.any(Object), undefined, { - reducedMotion: false, + reducedMotion: undefined, }); }); @@ -2687,7 +2698,7 @@ describe('interact (web)', () => { targetFirstChild, expect.any(Object), undefined, - { reducedMotion: false }, + { reducedMotion: undefined }, ); }); }); diff --git a/packages/interact/vitest.config.ts b/packages/interact/vitest.config.ts index dc041f02..fd740e4a 100644 --- a/packages/interact/vitest.config.ts +++ b/packages/interact/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', + setupFiles: ['./test/setup.ts'], }, }); diff --git a/packages/splittext/test/splitText.integration.spec.ts b/packages/splittext/test/splitText.integration.spec.ts index 3653f766..b4c4c7ab 100644 --- a/packages/splittext/test/splitText.integration.spec.ts +++ b/packages/splittext/test/splitText.integration.spec.ts @@ -10,6 +10,14 @@ import { splitTextPlugin, splitTextStyle } from '../src/plugin'; describe('splitText through the Interact plugin bridge (real @wix/splittext)', () => { beforeEach(() => { Interact.use('splitText', splitTextPlugin); + // jsdom has no matchMedia - a browser without a reduced-motion preference + window.matchMedia = (query: string) => + ({ + matches: query.includes('no-preference'), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList; }); afterEach(() => {