diff --git a/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md b/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md index 07b6e45..373f1f8 100644 --- a/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md +++ b/.cursor/plans/sequence_feature_implementation_84c01c97.plan.md @@ -4,54 +4,54 @@ overview: implement a new Sequence class that allows controling playback of muti todos: - id: motion-sequence-class content: Create Sequence class in packages/motion/src/Sequence.ts - status: pending + status: completed - id: motion-sequence-types content: Add SequenceOptions type to packages/motion/src/types.ts - status: pending + status: completed dependencies: - motion-sequence-class - id: motion-sequence-export content: Export Sequence and SequenceOptions from packages/motion/src/index.ts - status: pending + status: completed dependencies: - motion-sequence-class - motion-sequence-types - id: motion-get-sequence content: Implement getSequence() function in packages/motion/src/motion.ts and export it - status: pending + status: completed dependencies: - motion-sequence-class - motion-sequence-types - id: interact-types content: Update types in packages/interact/src/types.ts (SequenceOptionsConfig, SequenceConfig, SequenceConfigRef, InteractConfig, Interaction) - status: pending + status: completed dependencies: - motion-sequence-types - id: interact-cache-types content: Update InteractCache type to include sequences field - status: pending + status: completed dependencies: - interact-types - id: interact-parse-config content: Update parseConfig in packages/interact/src/core/Interact.ts to handle sequences - status: pending + status: completed dependencies: - interact-types - interact-cache-types - id: interact-add content: Update effect processing in packages/interact/src/core/add.ts to create Sequence instances - status: pending + status: completed dependencies: - motion-get-sequence - interact-parse-config - id: interact-sequence-cache content: Implement Sequence caching on Interact class (sequenceCache static property and getEffect() endpoint) - status: pending + status: completed dependencies: - interact-add - id: interact-handlers content: Update trigger handlers (viewEnter.ts, click.ts, etc.) to support Sequence instances - status: pending + status: completed dependencies: - interact-add - id: tests-unit @@ -165,10 +165,8 @@ type getSequence = ( ) => Sequence; ``` -The `getSequence()` funciton has 2 flows: - -- If passed `animations: AnimationGroupArgs` it creates a `Sequence` from a single effect definition applied to multiple targets. -- If passed `animations: AnimationGroupArgs[]` it creates a `Sequence` from a each effect definition in the array. +The `getSequence()` funciton is passed `animations: AnimationGroupArgs[]` it creates a `Sequence` from a each effect definition in the array. +If an `Effect` in the array resolves to multiple elements, each resulting instance becomes an effect in the array. ## Part 2: @wix/interact Package Changes @@ -186,15 +184,9 @@ export type SequenceOptionsConfig = { }; // New SequenceConfig type -export type SequenceConfig = SequenceOptionsConfig & - ( - | { - effect: Effect | EffectRef; - } - | { - effects: (Effect | EffectRef)[]; - } - ); +export type SequenceConfig = SequenceOptionsConfig & { + effects: (Effect | EffectRef)[]; +}; // New SequenceConfigRef type export type SequenceConfigRef = { @@ -260,12 +252,10 @@ Modify `packages/interact/src/core/Interact.ts`: 2. Process `interaction.sequences` array: - Resolve `sequenceId` references from `config.sequences` -- Process each effect within the sequence: - - Either as list of multiple effects as `effects: Effect[]` - - Or a single `effect: Effect` declaration, generating a list of effects on multiple target elements +- Process each effect within the sequence - Generate unique IDs for sequence effects -3. Track sequence membership for effects (needed for delay calculation) +1. Track sequence membership for effects (needed for delay calculation) ### 2.4 Update Effect Processing in `add.ts` diff --git a/.cursor/plans/sequences_feature_tests_e12d5b15.plan.md b/.cursor/plans/sequences_feature_tests_e12d5b15.plan.md new file mode 100644 index 0000000..ece558d --- /dev/null +++ b/.cursor/plans/sequences_feature_tests_e12d5b15.plan.md @@ -0,0 +1,211 @@ +--- +name: Sequences Feature Tests +overview: Create comprehensive test suites for the Sequences feature across both `@wix/motion` and `@wix/interact` packages, covering the Sequence class, getSequence function, AnimationGroup.applyOffset, config parsing, add/remove flows, listContainer interactions, and sequence caching. +todos: + - id: skeleton-motion + content: 'Create test file skeletons with describe/test titles for motion package: Sequence.spec.ts, applyOffset tests in AnimationGroup.spec.ts, getSequence.spec.ts' + status: completed + - id: skeleton-interact + content: 'Create test file skeleton with describe/test titles for interact package: sequences.spec.ts (suites A-G)' + status: completed + - id: impl-sequence-class + content: 'Implement Sequence.spec.ts tests: constructor, offset calculation, applyOffsets, inherited playback API, onFinish' + status: completed + - id: impl-apply-offset + content: Implement applyOffset() tests in AnimationGroup.spec.ts + status: completed + - id: impl-get-sequence + content: 'Implement getSequence.spec.ts tests: AnimationGroupArgs[] flow, options forwarding, edge cases' + status: completed + - id: impl-interact-config + content: 'Implement sequences.spec.ts Suite A: config parsing tests' + status: completed + - id: impl-interact-source + content: 'Implement sequences.spec.ts Suite B: sequence processing from source element via add()' + status: completed + - id: impl-interact-target + content: 'Implement sequences.spec.ts Suite C: cross-element sequence processing via addEffectsForTarget' + status: completed + - id: impl-interact-list + content: 'Implement sequences.spec.ts Suite D: sequence with listContainer -- add, addListItems, remove flows' + status: completed + - id: impl-interact-cleanup + content: 'Implement sequences.spec.ts Suite E: removal and cleanup tests' + status: completed + - id: impl-interact-cache + content: 'Implement sequences.spec.ts Suite F: Interact.getSequence caching tests' + status: completed + - id: impl-interact-mql + content: 'Implement sequences.spec.ts Suite G: media query condition tests on sequences' + status: completed +isProject: false +--- + +# Sequences Feature Test Plan + +## Phase 1: Motion Package Tests + +### 1.1 Create `packages/motion/test/Sequence.spec.ts` + +Unit tests for the `Sequence` class in `[packages/motion/src/Sequence.ts](packages/motion/src/Sequence.ts)`. Follow the same `createMockAnimation` pattern from `[packages/motion/test/AnimationGroup.spec.ts](packages/motion/test/AnimationGroup.spec.ts)`. + +**Test suites:** + +- **Constructor** + - creates Sequence with empty groups array + - creates Sequence from multiple AnimationGroups + - flattens all child animations into parent `animations` array + - stores `animationGroups` reference + - defaults: delay=0, offset=0, offsetEasing=linear + - accepts custom delay, offset, and offsetEasing function + - resolves named offsetEasing string (e.g. `'quadIn'`) via `getJsEasing` + - resolves cubic-bezier offsetEasing string + - falls back to linear for invalid/unknown offsetEasing string +- **Offset calculation (calculateOffsets)** + - single group returns [0] + - linear easing with 5 groups and offset=200 produces [0, 200, 400, 600, 800] + - quadIn easing with 5 groups and offset=200 produces [0, 50, 200, 450, 800] (spec example) + - sineOut easing produces expected non-linear offsets + - floors fractional offsets via `| 0` +- **applyOffsets (via ready promise)** + - applies delay + calculated offset to each group via `group.applyOffset()` + - skips `applyOffset` when additionalDelay is 0 + - waits for all group ready promises before applying offsets +- **Inherited playback API (from AnimationGroup)** + - `play()` plays all flattened animations + - `pause()` pauses all flattened animations + - `reverse()` reverses all flattened animations + - `cancel()` cancels all flattened animations + - `setPlaybackRate()` sets rate on all flattened animations + - `playState` returns from first animation +- **onFinish (overridden)** + - calls callback when all animation groups finish + - does not call callback if any group's `finished` rejects + - logs warning on interrupted animation + - handles empty groups array + +### 1.2 Add `applyOffset` tests to `packages/motion/test/AnimationGroup.spec.ts` + +Add a new `describe('applyOffset()')` section: + +- adds offset to each animation's effect delay via `updateTiming` +- accumulates with existing delay +- skips animations with no effect +- handles empty animations array + +### 1.3 Create `packages/motion/test/getSequence.spec.ts` + +Tests for the `getSequence()` function in `[packages/motion/src/motion.ts](packages/motion/src/motion.ts)`. Must mock `getAnimation` / `getWebAnimation` as done in `[packages/motion/test/motion.spec.ts](packages/motion/test/motion.spec.ts)`. + +**Test suites:** + +- **AnimationGroupArgs[] flow** + - creates Sequence with one AnimationGroup per resolved target element + - handles a single entry with HTMLElement target + - handles a single entry with HTMLElement[] target (each element becomes its own group) + - handles a single entry with string selector target via `querySelectorAll` + - handles a single entry with null target (passed through to getAnimation) + - creates Sequence with one group per entry + - each entry independently resolves its target +- **Options forwarding** + - passes SequenceOptions (delay, offset, offsetEasing) to Sequence constructor + - passes context.reducedMotion to getAnimation +- **Edge cases** + - skips entries where getAnimation returns non-AnimationGroup + - returns Sequence with empty groups when all entries fail + +## Phase 2: Interact Package Tests + +### 2.1 Create `packages/interact/test/sequences.spec.ts` + +Integration tests for sequence handling in the interact package. Follow the mock patterns from `[packages/interact/test/web.spec.ts](packages/interact/test/web.spec.ts)` with the `@wix/motion` mock, but also mock `getSequence` to return a mock Sequence object. + +The `@wix/motion` mock needs to be extended to include: + +```typescript +getSequence: vi.fn().mockReturnValue({ + play: vi.fn(), cancel: vi.fn(), onFinish: vi.fn(), + pause: vi.fn(), reverse: vi.fn(), progress: vi.fn(), + persist: vi.fn(), isCSS: false, playState: 'idle', + ready: Promise.resolve(), animations: [], animationGroups: [], +}), +``` + +**Suite A: Config parsing (parseConfig via Interact.create)** + +- parses inline sequence on interaction with `effects` array +- parses `sequenceId` reference from `config.sequences` +- merges inline overrides onto referenced sequence +- auto-generates sequenceId when not provided +- warns when referencing unknown sequenceId +- caches sequences in `dataCache.sequences` +- stores sequence effects in `interactions[target].sequences` for cross-element targets +- does not create cross-element entry when sequence effect targets same key as source (only `_processSequences` handles it) +- handles interaction with sequences but no effects (effects array is omitted/empty) + +**Suite B: Sequence processing via `add()` -- source element** + +- creates Sequence when source element is added with viewEnter trigger +- creates Sequence when source element is added with click trigger +- passes correct AnimationGroupArgs built from effect definitions +- resolves effectId references from config.effects +- skips sequence when target controller is not yet registered +- does not duplicate sequence on re-add (caching via `addedInteractions`) +- passes pre-created Sequence as `animation` option to trigger handler +- passes selectorCondition to handler options +- silently skips unresolved sequenceId reference at runtime (`_processSequences` returns early) +- skips entire sequence when any effect target element is missing (`_buildAnimationGroupArgsFromSequence` returns null) + +**Suite C: Sequence processing via `addEffectsForTarget()` -- cross-element** + +- creates Sequence when target element is added after source +- creates Sequence when source element is added after target +- handles sequences where effects target different keys +- skips variation when interaction-level MQL does not match and falls through to next variation +- skips when source controller is not yet registered +- `addEffectsForTarget` returns true when sequences exist even without effects + +**Suite D: Sequence with listContainer** + +- creates Sequence for each list item when source has listContainer +- creates new Sequence per `addListItems` call with unique cache key (each call uses `${cacheKey}::${generateId()}`) +- handles removing list items (via `removeListItems`) and subsequent re-add +- processes sequence effects from listContainer elements +- does not create duplicate sequence when list items overlap with existing +- skips sequence when listElements provided but no effects matched the listContainer (`usedListElements` guard) +- cross-element target: creates new Sequence per `addListItems` call for target sequences + +**Suite E: Sequence removal and cleanup** + +- `remove()` cleans up sequence cache entries for the removed key +- `Interact.destroy()` clears sequenceCache +- `deleteController()` removes sequence-related `addedInteractions` entries +- `clearInteractionStateForKey` removes sequenceCache entries by key prefix (`${key}::seq::`) + +**Suite F: Interact.getSequence caching** + +- returns cached Sequence for same cacheKey +- creates new Sequence for different cacheKey +- passes sequenceOptions and animationGroupArgs to motion's `getSequence` + +**Suite G: Media query conditions on sequences** + +- skips sequence when sequence-level condition does not match +- skips individual effect within sequence when effect-level condition does not match +- sets up media query listener for sequence conditions +- sets up media query listener for effect-level conditions within sequence + +## Phase 3: Implementation Approach + +Each phase above will be implemented in order: + +1. First create all spec files with `describe`/`test` **skeletons only** (titles, no bodies) +2. Implement motion package tests (Sequence.spec.ts, applyOffset in AnimationGroup.spec.ts, getSequence.spec.ts) +3. Implement interact package sequence tests (sequences.spec.ts) suite by suite + +### Key mock patterns to reuse + +- `createMockAnimation()` from `AnimationGroup.spec.ts` for motion tests +- `vi.mock('@wix/motion', ...)` from `web.spec.ts` for interact tests, extended with `getSequence` +- `InteractionController` + `add()` helper for interact element setup +- `addListItems` import for list container tests diff --git a/packages/interact/dev/sequences-spec.md b/packages/interact/dev/sequences-spec.md index c239e72..6d8e455 100644 --- a/packages/interact/dev/sequences-spec.md +++ b/packages/interact/dev/sequences-spec.md @@ -48,11 +48,9 @@ type SequenceOptions = { /** * The SequenceConfig type */ -type SequenceConfig = SequenceOptions & ({ - effect: Effect; -} | { +type SequenceConfig = SequenceOptions & { effects: Effect[]; -}); +}; ``` ## The `Sequence.delay` @@ -119,12 +117,6 @@ const sinOut = (t) => Math.sin((t * Math.PI) / 2); - Note that `Sequence` does not have a `target`, so all of its API endpoints that involve an element target should be written accordingly, or not exist if not relevant. - In the `@eix/interact` package `Sequence`s will be created from an `InteractConfig` for every declaration inside `Interaction.sequences`. -## Add `Sequence.effect` property - -Another option for creating/declaring a `Sequence` from a single Effect on multiple elements is to use a new `effect` property as follows: - -If `effect` is specified, a `Sequence` is created from the list of generated effects of the specified `Effect` on each of the matching target elements. - # Appendix ## A CSS solution in a futuristic world where CSS math functions are widely supported diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index 15a4fd2..ecb1df4 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -4,6 +4,8 @@ import { EffectRef, Effect, Interaction, + SequenceConfig, + SequenceConfigRef, ViewEnterParams, ViewEnterHandlerModule, IInteractionController, @@ -12,7 +14,8 @@ import { import { getInterpolatedKey } from './utilities'; import { generateId } from '../utils'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; -import { registerEffects } from '@wix/motion'; +import { registerEffects, getSequence, createAnimationGroups, Sequence } from '@wix/motion'; +import type { SequenceOptions, AnimationGroupArgs, IndexedGroup } from '@wix/motion'; function _convertToKeyTemplate(key: string) { return key.replace(/\[([-\w]+)]/g, '[]'); @@ -38,9 +41,10 @@ export class Interact { static allowA11yTriggers: boolean = true; static instances: Interact[] = []; static controllerCache = new Map(); + static sequenceCache = new Map(); constructor() { - this.dataCache = { effects: {}, conditions: {}, interactions: {} }; + this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} }; this.addedInteractions = {}; this.mediaQueryListeners = new Map(); this.listInteractionsCache = {}; @@ -90,7 +94,7 @@ export class Interact { this.addedInteractions = {}; this.listInteractionsCache = {}; this.controllers.clear(); - this.dataCache = { effects: {}, conditions: {}, interactions: {} }; + this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} }; Interact.instances.splice(Interact.instances.indexOf(this), 1); } @@ -137,6 +141,14 @@ export class Interact { const interactionId = getInterpolatedKey(interactionId_, key); delete this.addedInteractions[interactionId]; }); + + const seqPrefix = `${key}::seq::`; + for (const cacheKey of Interact.sequenceCache.keys()) { + if (cacheKey.startsWith(seqPrefix)) { + Interact.sequenceCache.delete(cacheKey); + delete this.addedInteractions[cacheKey]; + } + } } setupMediaQueryListener(id: string, mql: MediaQueryList, key: string, handler: () => void) { @@ -168,6 +180,7 @@ export class Interact { }); Interact.instances.length = 0; Interact.controllerCache.clear(); + Interact.sequenceCache.clear(); } static setup(options: { @@ -216,6 +229,42 @@ export class Interact { } static registerEffects = registerEffects; + + static getSequence( + cacheKey: string, + sequenceOptions: SequenceOptions, + animationGroupArgs: AnimationGroupArgs[], + context?: { reducedMotion?: boolean }, + ): Sequence { + const cached = Interact.sequenceCache.get(cacheKey); + if (cached) return cached; + + const sequence = getSequence(sequenceOptions, animationGroupArgs, context); + Interact.sequenceCache.set(cacheKey, sequence); + + return sequence; + } + + static addToSequence( + 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 entries: IndexedGroup[] = newGroups.map((group, i) => ({ + index: indices[i] ?? cached.animationGroups.length, + group, + })); + + cached.addGroups(entries); + + return true; + } } let interactionIdCounter = 0; @@ -247,6 +296,29 @@ export function getSelector( /** * Parses the config object and caches interactions, effects, and conditions */ +function _isSequenceConfigRef( + config: SequenceConfig | SequenceConfigRef, +): config is SequenceConfigRef { + return 'sequenceId' in config && !('effects' in config); +} + +function _ensureInteractionEntry( + interactions: InteractCache['interactions'], + key: string, +): InteractCache['interactions'][string] { + if (!interactions[key]) { + interactions[key] = { + triggers: [], + effects: {}, + sequences: {}, + interactionIds: new Set(), + selectors: new Set(), + }; + } + + return interactions[key]; +} + function parseConfig(config: InteractConfig, useCutsomElement: boolean = false): InteractCache { const conditions = config.conditions || {}; const interactions: InteractCache['interactions'] = {}; @@ -254,28 +326,44 @@ function parseConfig(config: InteractConfig, useCutsomElement: boolean = false): config.interactions?.forEach((interaction_) => { const source = interaction_.key; const interactionIdx = ++interactionIdCounter; - const { effects: effects_, ...rest } = interaction_; + const { effects: effects_, sequences: sequences_, ...rest } = interaction_; if (!source) { console.error(`Interaction ${interactionIdx} is missing a key for source element.`); return; } - if (!interactions[source]) { - interactions[source] = { - triggers: [], - effects: {}, - interactionIds: new Set(), - selectors: new Set(), - }; - } + _ensureInteractionEntry(interactions, source); /* * Cache interaction trigger by source element */ - const effects = Array.from(effects_); + const effects = effects_ ? Array.from(effects_) : []; effects.reverse(); // reverse to ensure the first effect is the one that will be applied first - const interaction = { ...rest, effects }; + + // Resolve and preprocess sequences + const processedSequences = sequences_?.map((seqOrRef) => { + if (_isSequenceConfigRef(seqOrRef)) { + const resolved = config.sequences?.[seqOrRef.sequenceId]; + if (!resolved) { + console.warn(`Interact: Sequence "${seqOrRef.sequenceId}" not found in config`); + return seqOrRef; + } + return { ...resolved, ...seqOrRef } as SequenceConfig; + } + + const seq = seqOrRef as SequenceConfig; + if (!seq.sequenceId) { + seq.sequenceId = generateId(); + } + return seq; + }); + + const interaction = { + ...rest, + effects: effects.length > 0 ? effects : undefined, + sequences: processedSequences, + } as Interaction; interactions[source].triggers.push(interaction); interactions[source].selectors.add( @@ -329,27 +417,60 @@ function parseConfig(config: InteractConfig, useCutsomElement: boolean = false): /* * Cache interaction effect by target element */ - if (!interactions[target]) { - interactions[target] = { - triggers: [], - effects: { - [interactionId]: [], - }, - interactionIds: new Set(), - selectors: new Set(), - }; - } else if (!interactions[target].effects[interactionId]) { - interactions[target].effects[interactionId] = []; - interactions[target].interactionIds.add(interactionId); + const targetEntry = _ensureInteractionEntry(interactions, target); + if (!targetEntry.effects[interactionId]) { + targetEntry.effects[interactionId] = []; + targetEntry.interactionIds.add(interactionId); } - interactions[target].effects[interactionId].push({ ...rest, effect }); - interactions[target].selectors.add(getSelector(effect, { useFirstChild: useCutsomElement })); + targetEntry.effects[interactionId].push({ ...rest, effect }); + targetEntry.selectors.add(getSelector(effect, { useFirstChild: useCutsomElement })); + }); + + // Process sequence effects for selector tracking and cross-element referencing + processedSequences?.forEach((seqConfig) => { + if (!seqConfig || _isSequenceConfigRef(seqConfig)) return; + + const sequenceConfig = seqConfig as SequenceConfig; + const sequenceId = sequenceConfig.sequenceId || generateId(); + const seqEffects = sequenceConfig.effects; + + for (const effect of seqEffects) { + if (!(effect as EffectRef).effectId) { + (effect as EffectRef).effectId = generateId(); + } + + let target = effect.key; + if (!target && (effect as EffectRef).effectId) { + const referencedEffect = config.effects[(effect as EffectRef).effectId]; + if (referencedEffect) { + target = referencedEffect.key; + } + } + target = target || source; + + if (target !== source) { + const targetEntry = _ensureInteractionEntry(interactions, target); + const seqInteractionId = `${target}::seq::${sequenceId}::${interactionIdx}`; + + if (!targetEntry.sequences[seqInteractionId]) { + targetEntry.sequences[seqInteractionId] = []; + targetEntry.interactionIds.add(seqInteractionId); + } + + targetEntry.sequences[seqInteractionId].push({ + ...rest, + sequence: sequenceConfig, + }); + targetEntry.selectors.add(getSelector(effect, { useFirstChild: useCutsomElement })); + } + } }); }); return { effects: config.effects || {}, + sequences: config.sequences || {}, conditions, interactions, }; diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts index 8cbb53f..2a4865c 100644 --- a/packages/interact/src/core/add.ts +++ b/packages/interact/src/core/add.ts @@ -4,15 +4,20 @@ import type { EffectRef, InteractionParamsTypes, TransitionEffect, + TimeEffect, Interaction, InteractionTrigger, + SequenceConfig, + SequenceConfigRef, CreateTransitionCSSParams, IInteractionController, } from '../types'; -import { createTransitionCSS, getMediaQuery, getSelectorCondition } from '../utils'; +import { createTransitionCSS, getMediaQuery, getSelectorCondition, generateId } from '../utils'; import { getInterpolatedKey } from './utilities'; +import { effectToAnimationOptions } from '../handlers/utilities'; import { Interact, getSelector } from './Interact'; import TRIGGER_TO_HANDLER_MODULE_MAP from '../handlers'; +import type { AnimationGroupArgs } from '@wix/motion'; type InteractionsToApply = Array< [ @@ -26,6 +31,12 @@ type InteractionsToApply = Array< ] >; +type ListElements = { + controllerKey: string; + listContainer: string; + elements: HTMLElement[]; +}; + function _getElementsFromData( data: Interaction | Effect, root: HTMLElement, @@ -147,7 +158,7 @@ function _addInteraction( const interactionsToApply: InteractionsToApply = []; - interaction.effects.forEach((effect) => { + (interaction.effects || []).forEach((effect) => { const effectId = (effect as EffectRef).effectId; const effectOptions = { @@ -237,6 +248,303 @@ function _addInteraction( interactionsToApply.reverse().forEach((interaction) => { _applyInteraction(...interaction); }); + + _processSequences(sourceKey, sourceController, instance, interaction, elements); +} + +function _isSequenceConfigRef( + config: SequenceConfig | SequenceConfigRef, +): config is SequenceConfigRef { + return 'sequenceId' in config && !('effects' in config); +} + +/** + * Shared logic for building animation group args from a sequence config. + * Handles sequence-level and effect-level media conditions, target resolution, and element lookup. + * Returns null when the sequence should be skipped (conditions not met, missing targets, etc.). + * + * When `listElements` is provided (from addListItems), effects matching the specified controller + * and listContainer will resolve targets from the provided elements instead of querying the DOM. + * Returns null if listElements was provided but no effects matched it (avoids duplicate sequences). + */ +function _buildAnimationGroupArgsFromSequence( + sequenceConfig: SequenceConfig, + cacheKey: string, + sourceKey: string, + sourceController: IInteractionController, + instance: Interact, + options: { updateKey: string; onUpdate: () => void }, + listElements?: ListElements, +): AnimationGroupArgs[] | null { + const seqMql = getMediaQuery(sequenceConfig.conditions || [], instance.dataCache.conditions); + + if (seqMql) { + instance.setupMediaQueryListener(cacheKey, seqMql, options.updateKey, options.onUpdate); + } + + if (seqMql && !seqMql.matches) return null; + + const seqEffects: (Effect | EffectRef)[] = sequenceConfig.effects || []; + const animationGroupArgs: AnimationGroupArgs[] = []; + let usedListElements = false; + + for (const effect of seqEffects) { + const effectId = (effect as EffectRef).effectId; + const effectOptions = { + ...(effectId ? instance.dataCache.effects[effectId] || {} : {}), + ...effect, + }; + + const effectMql = getMediaQuery(effectOptions.conditions || [], instance.dataCache.conditions); + + if (effectMql) { + const effectCacheKey = `${cacheKey}::${effectId || 'eff'}`; + instance.setupMediaQueryListener( + effectCacheKey, + effectMql, + options.updateKey, + options.onUpdate, + ); + } + + if (effectMql && !effectMql.matches) continue; + + const targetKey_ = effectOptions.key; + const target = targetKey_ && getInterpolatedKey(targetKey_, sourceKey); + + let targetController; + if (target) { + targetController = Interact.getController(target); + if (!targetController) return null; + } else { + targetController = sourceController; + } + + const resolvedTargetKey = target || sourceKey; + let targetElement: HTMLElement | HTMLElement[] | null; + + if ( + listElements && + resolvedTargetKey === listElements.controllerKey && + effectOptions.listContainer === listElements.listContainer + ) { + targetElement = _queryItemElement(effectOptions, listElements.elements); + if ((targetElement as HTMLElement[]).length > 0) { + usedListElements = true; + } + } else { + targetElement = _getElementsFromData( + effectOptions, + targetController.element, + targetController.useFirstChild, + ); + } + + if (!targetElement || (Array.isArray(targetElement) && targetElement.length === 0)) return null; + + const animOptions = effectToAnimationOptions(effectOptions as TimeEffect); + animationGroupArgs.push({ target: targetElement, options: animOptions }); + } + + if (listElements && !usedListElements) return null; + + return animationGroupArgs.length > 0 ? animationGroupArgs : null; +} + +function _resolveListItemIndices( + controller: IInteractionController, + listContainer: string, + elements: HTMLElement[], +): number[] { + const useFirstChild = controller.useFirstChild; + const root = useFirstChild + ? (controller.element.firstElementChild as HTMLElement) + : controller.element; + const container = root?.querySelector(listContainer); + + if (!container) return elements.map((_, i) => i); + + const allChildren = Array.from(container.children); + + return elements.map((el) => { + const idx = allChildren.indexOf(el); + return idx >= 0 ? idx : allChildren.length; + }); +} + +function _processSequences( + sourceKey: string, + sourceController: IInteractionController, + instance: Interact, + interaction: Interaction, + elements?: HTMLElement[], +) { + interaction.sequences?.forEach((seqOrRef) => { + let sequenceConfig: SequenceConfig; + + if (_isSequenceConfigRef(seqOrRef)) { + const resolved = instance.dataCache.sequences[seqOrRef.sequenceId]; + + if (!resolved) return; + + sequenceConfig = { ...resolved, ...seqOrRef }; + } else { + sequenceConfig = seqOrRef as SequenceConfig; + } + + const sequenceId = sequenceConfig.sequenceId || generateId(); + const cacheKey = getInterpolatedKey(`${sourceKey}::seq::${sequenceId}`, sourceKey); + + if (instance.addedInteractions[cacheKey] && !elements) return; + + const listElements: ListElements | undefined = + elements && interaction.listContainer + ? { controllerKey: sourceKey, listContainer: interaction.listContainer, elements } + : undefined; + + const animationGroupArgs = _buildAnimationGroupArgsFromSequence( + sequenceConfig, + cacheKey, + sourceKey, + sourceController, + instance, + { updateKey: sourceKey, onUpdate: () => sourceController.update() }, + listElements, + ); + + if (!animationGroupArgs) return; + + if (elements && instance.addedInteractions[cacheKey]) { + const indices = _resolveListItemIndices( + sourceController, + interaction.listContainer!, + elements, + ); + + Interact.addToSequence(cacheKey, animationGroupArgs, indices, { + reducedMotion: Interact.forceReducedMotion, + }); + + return; + } + + const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { + reducedMotion: Interact.forceReducedMotion, + }); + + instance.addedInteractions[cacheKey] = true; + + const selectorCondition = getSelectorCondition( + interaction.conditions || [], + instance.dataCache.conditions, + ); + + (TRIGGER_TO_HANDLER_MODULE_MAP[interaction.trigger] as any)?.add( + sourceController.element, + sourceController.element, + {} as Effect, + interaction.params || {}, + { + reducedMotion: Interact.forceReducedMotion, + selectorCondition, + animation: sequence, + allowA11yTriggers: Interact.allowA11yTriggers, + }, + ); + }); +} + +function _processSequencesForTarget( + targetKey: string, + targetController: IInteractionController, + instance: Interact, + listContainer?: string, + elements?: HTMLElement[], +) { + const sequences = instance.get(targetKey)?.sequences || {}; + const seqInteractionIds = Object.keys(sequences); + + seqInteractionIds.forEach((seqInteractionId_) => { + const seqVariations = sequences[seqInteractionId_]; + + seqVariations.some(({ sequence: sequenceConfig, ...interaction }) => { + const interactionMql = getMediaQuery( + interaction.conditions || [], + instance.dataCache.conditions, + ); + + if (interactionMql && !interactionMql.matches) { + return false; + } + + const sourceKey = interaction.key && getInterpolatedKey(interaction.key, targetKey); + const sourceController = Interact.getController(sourceKey); + + if (!sourceController) { + return true; + } + + const sequenceId = sequenceConfig.sequenceId || generateId(); + const cacheKey = getInterpolatedKey(`${sourceKey}::seq::${sequenceId}`, sourceKey!); + + if (instance.addedInteractions[cacheKey] && !elements) { + return true; + } + + const listElements: ListElements | undefined = + elements && listContainer + ? { controllerKey: targetKey, listContainer, elements } + : undefined; + + const animationGroupArgs = _buildAnimationGroupArgsFromSequence( + sequenceConfig, + cacheKey, + sourceKey!, + sourceController, + instance, + { updateKey: targetKey, onUpdate: () => targetController.update() }, + listElements, + ); + + if (!animationGroupArgs) return true; + + if (elements && instance.addedInteractions[cacheKey]) { + const indices = _resolveListItemIndices(targetController, listContainer!, elements); + + Interact.addToSequence(cacheKey, animationGroupArgs, indices, { + reducedMotion: Interact.forceReducedMotion, + }); + + return true; + } + + const sequence = Interact.getSequence(cacheKey, sequenceConfig, animationGroupArgs, { + reducedMotion: Interact.forceReducedMotion, + }); + + instance.addedInteractions[cacheKey] = true; + + const selectorCondition = getSelectorCondition( + interaction.conditions || [], + instance.dataCache.conditions, + ); + + (TRIGGER_TO_HANDLER_MODULE_MAP[interaction.trigger] as any)?.add( + sourceController.element, + sourceController.element, + {} as Effect, + interaction.params || {}, + { + reducedMotion: Interact.forceReducedMotion, + selectorCondition, + animation: sequence, + allowA11yTriggers: Interact.allowA11yTriggers, + }, + ); + + return true; + }); + }); } function addEffectsForTarget( @@ -246,7 +554,8 @@ function addEffectsForTarget( listContainer?: string, elements?: HTMLElement[], ) { - const effects = instance.get(targetKey)?.effects || {}; + const targetData = instance.get(targetKey); + const effects = targetData?.effects || {}; const interactionIds = Object.keys(effects); const interactionsToApply: InteractionsToApply = []; @@ -353,7 +662,10 @@ function addEffectsForTarget( _applyInteraction(...interaction); }); - return interactionIds.length > 0; + _processSequencesForTarget(targetKey, targetController, instance, listContainer, elements); + + const hasSequences = Object.keys(targetData?.sequences || {}).length > 0; + return interactionIds.length > 0 || hasSequences; } /** diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 76a7b5a..09466de 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -46,7 +46,7 @@ export function generate(_config: InteractConfig, useFirstChild: boolean = false const isOnce = !interactionParams?.type || interactionParams.type === 'once'; if (isOnce) { - effects.forEach((effect) => { + effects?.forEach((effect) => { const effectData = effect?.effectId ? _config.effects[effect.effectId] || effect : effect; diff --git a/packages/interact/src/handlers/animationEnd.ts b/packages/interact/src/handlers/animationEnd.ts index 9d50d2d..62dc70c 100644 --- a/packages/interact/src/handlers/animationEnd.ts +++ b/packages/interact/src/handlers/animationEnd.ts @@ -14,14 +14,15 @@ function addAnimationEndHandler( target: HTMLElement, effect: TimeEffect, __: AnimationEndParams, - { reducedMotion, selectorCondition }: InteractOptions, + { reducedMotion, selectorCondition, animation: preCreatedAnimation }: InteractOptions, ): void { - const animation = getAnimation( - target, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - ) as AnimationGroup | null; + const animation = (preCreatedAnimation || + getAnimation( + target, + effectToAnimationOptions(effect), + undefined, + reducedMotion, + )) 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 dfcde84..052cf46 100644 --- a/packages/interact/src/handlers/effectHandlers.ts +++ b/packages/interact/src/handlers/effectHandlers.ts @@ -19,13 +19,16 @@ export function createTimeEffectHandler( reducedMotion: boolean = false, selectorCondition?: string, enterLeave?: EventTriggerConfigEnterLeave, + preCreatedAnimation?: AnimationGroup, ): ((event: Event) => void) | null { - const animation = getAnimation( - element, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - ) as AnimationGroup | null; + const animation = + preCreatedAnimation || + (getAnimation( + element, + effectToAnimationOptions(effect), + undefined, + reducedMotion, + ) as AnimationGroup | null); if (!animation) { return null; diff --git a/packages/interact/src/handlers/eventTrigger.ts b/packages/interact/src/handlers/eventTrigger.ts index 0f40271..fc816ea 100644 --- a/packages/interact/src/handlers/eventTrigger.ts +++ b/packages/interact/src/handlers/eventTrigger.ts @@ -111,7 +111,12 @@ function addEventTriggerHandler( target: HTMLElement, effect: (TimeEffect | TransitionEffect) & EffectBase, options: EventTriggerParams, - { reducedMotion, targetController, selectorCondition }: InteractOptions, + { + reducedMotion, + targetController, + selectorCondition, + animation: preCreatedAnimation, + }: InteractOptions, ) { const genericConfig = createGenericEventConfig(options.eventConfig); const isTransition = @@ -139,6 +144,7 @@ function addEventTriggerHandler( reducedMotion, selectorCondition, enterLeave, + preCreatedAnimation, ); once = (options as PointerTriggerParams).type === 'once'; } diff --git a/packages/interact/src/handlers/viewEnter.ts b/packages/interact/src/handlers/viewEnter.ts index 653fde5..9368989 100644 --- a/packages/interact/src/handlers/viewEnter.ts +++ b/packages/interact/src/handlers/viewEnter.ts @@ -137,16 +137,17 @@ function addViewEnterHandler( target: HTMLElement, effect: TimeEffect, options: ViewEnterParams = {}, - { reducedMotion, selectorCondition }: InteractOptions = {}, + { reducedMotion, selectorCondition, animation: preCreatedAnimation }: InteractOptions = {}, ) { const mergedOptions = { ...viewEnterOptions, ...options }; const type = mergedOptions.type || 'once'; - const animation = getAnimation( - target, - effectToAnimationOptions(effect), - undefined, - reducedMotion, - ) as AnimationGroup | null; + const animation = (preCreatedAnimation || + getAnimation( + target, + effectToAnimationOptions(effect), + undefined, + reducedMotion, + )) as AnimationGroup | null; // Early return if animation is null, no observer created if (!animation) { diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index c442145..d04e885 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -3,6 +3,7 @@ import type { RangeOffset, ScrubTransitionEasing, MotionAnimationOptions, + AnimationGroup, } from '@wix/motion'; export type { RangeOffset }; @@ -165,6 +166,27 @@ export type Condition = { predicate?: string; }; +export type SequenceOptionsConfig = { + delay?: number; + offset?: number; + offsetEasing?: string | ((p: number) => number); + sequenceId?: string; + conditions?: string[]; +}; + +export type SequenceConfig = SequenceOptionsConfig & { + effects: (Effect | EffectRef)[]; +}; + +export type SequenceConfigRef = { + sequenceId: string; +} & { + delay?: number; + offset?: number; + offsetEasing?: string | ((p: number) => number); + conditions?: string[]; +}; + export type InteractionTrigger = { key: string; listContainer?: string; @@ -176,11 +198,13 @@ export type InteractionTrigger = { }; export type Interaction = InteractionTrigger & { - effects: ((Effect | EffectRef) & { interactionId?: string })[]; + effects?: ((Effect | EffectRef) & { interactionId?: string })[]; + sequences?: (SequenceConfig | SequenceConfigRef)[]; }; export type InteractConfig = { effects: Record; + sequences?: Record; conditions?: Record; interactions: Interaction[]; }; @@ -242,6 +266,7 @@ export type InteractOptions = { targetController?: IInteractionController; selectorCondition?: string; allowA11yTriggers?: boolean; + animation?: AnimationGroup; }; export type InteractionHandlerModule = { @@ -277,6 +302,9 @@ export type InteractCache = { effects: { [effectId: string]: Effect; }; + sequences: { + [sequenceId: string]: SequenceConfig; + }; conditions: { [conditionId: string]: Condition; }; @@ -284,6 +312,7 @@ export type InteractCache = { [path: string]: { triggers: Interaction[]; effects: Record; + sequences: Record; interactionIds: Set; selectors: Set; }; diff --git a/packages/interact/test/sequences.spec.ts b/packages/interact/test/sequences.spec.ts new file mode 100644 index 0000000..b52f323 --- /dev/null +++ b/packages/interact/test/sequences.spec.ts @@ -0,0 +1,1757 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { Interact } from '../src/web'; +import { InteractionController } from '../src/core/InteractionController'; +import TRIGGER_TO_HANDLER_MODULE_MAP from '../src/handlers'; +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'; + +vi.mock('@wix/motion', () => { + const mockSequence = { + play: vi.fn(), + cancel: vi.fn(), + onFinish: vi.fn(), + pause: vi.fn(), + reverse: vi.fn(), + progress: vi.fn(), + persist: vi.fn(), + isCSS: false, + playState: 'idle', + ready: Promise.resolve(), + animations: [], + animationGroups: [], + addGroups: vi.fn(), + }; + + return { + getWebAnimation: vi.fn(), + getScrubScene: vi.fn(), + getEasing: vi.fn((v: string) => v), + getAnimation: vi.fn(), + registerEffects: vi.fn(), + getSequence: vi.fn().mockReturnValue(mockSequence), + createAnimationGroups: vi.fn().mockReturnValue([]), + }; +}); + +function createBaseConfig(): InteractConfig { + return { + effects: { + 'effect-source': { + keyframeEffect: { + name: 'source', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 300, + }, + 'effect-target': { + key: 'target-key', + keyframeEffect: { + name: 'target', + keyframes: [{ transform: 'translateY(10px)' }, { transform: 'translateY(0)' }], + }, + duration: 400, + }, + }, + sequences: { + 'shared-sequence': { + sequenceId: 'shared-sequence', + delay: 10, + offset: 20, + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + }, + interactions: [], + }; +} + +function createInteractElement() { + const element = document.createElement('interact-element') as HTMLElement; + const child = document.createElement('div'); + element.append(child); + return { element, child }; +} + +function addElement(element: HTMLElement, key: string) { + const controller = new InteractionController(element, key, { useFirstChild: true }); + controller.connect(key); + return controller; +} + +describe('interact sequences', () => { + beforeEach(() => { + vi.clearAllMocks(); + (globalThis as any).IntersectionObserver = class IntersectionObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + constructor() {} + }; + }); + + afterEach(() => { + Interact.destroy(); + }); + + describe('Config parsing', () => { + test('parses inline sequence on interaction with effects array', () => { + const inlineSequence: SequenceConfig = { + sequenceId: 'inline-seq', + delay: 12, + offset: 8, + effects: [{ effectId: 'effect-source', key: 'source-key' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineSequence], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const trigger = instance.dataCache.interactions['source-key'].triggers[0]; + + expect(trigger.sequences).toHaveLength(1); + expect((trigger.sequences?.[0] as SequenceConfig).effects).toEqual([ + { effectId: 'effect-source', key: 'source-key' }, + ]); + expect((trigger.sequences?.[0] as SequenceConfig).sequenceId).toBe('inline-seq'); + }); + + test('parses sequenceId reference from config.sequences', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'shared-sequence' }], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const triggerSequence = instance.dataCache.interactions['source-key'].triggers[0] + .sequences?.[0] as SequenceConfig; + + expect(triggerSequence.sequenceId).toBe('shared-sequence'); + expect(triggerSequence.delay).toBe(10); + expect(triggerSequence.offset).toBe(20); + expect(triggerSequence.effects).toEqual([{ effectId: 'effect-target', key: 'target-key' }]); + }); + + test('merges inline overrides onto referenced sequence', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'shared-sequence', delay: 99, offset: 44 }], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const triggerSequence = instance.dataCache.interactions['source-key'].triggers[0] + .sequences?.[0] as SequenceConfig; + + expect(triggerSequence.sequenceId).toBe('shared-sequence'); + expect(triggerSequence.delay).toBe(99); + expect(triggerSequence.offset).toBe(44); + // Ensure non-overridden fields still come from referenced sequence + expect(triggerSequence.effects).toEqual([{ effectId: 'effect-target', key: 'target-key' }]); + }); + + test('auto-generates sequenceId when not provided', () => { + const inlineWithoutId: SequenceConfig = { + effects: [{ effectId: 'effect-source' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineWithoutId], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const triggerSequence = instance.dataCache.interactions['source-key'].triggers[0] + .sequences?.[0] as SequenceConfig; + + expect(typeof triggerSequence.sequenceId).toBe('string'); + expect(triggerSequence.sequenceId).toBeTruthy(); + }); + + test('warns when referencing unknown sequenceId', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'missing-sequence' }], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const triggerSequence = + instance.dataCache.interactions['source-key'].triggers[0].sequences?.[0]; + + expect(warnSpy).toHaveBeenCalledWith( + 'Interact: Sequence "missing-sequence" not found in config', + ); + expect(triggerSequence).toEqual({ sequenceId: 'missing-sequence' }); + warnSpy.mockRestore(); + }); + + test('caches sequences in dataCache.sequences', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'shared-sequence' }], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + + expect(instance.dataCache.sequences).toEqual(config.sequences); + expect(instance.dataCache.sequences['shared-sequence']).toBeDefined(); + }); + + test('stores sequence effects in interactions[target].sequences for cross-element targets', () => { + const inlineSequence: SequenceConfig = { + sequenceId: 'cross-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineSequence], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const targetEntry = instance.dataCache.interactions['target-key']; + const sequenceKeys = Object.keys(targetEntry.sequences); + + expect(sequenceKeys).toHaveLength(1); + expect(sequenceKeys[0].startsWith('target-key::seq::cross-seq::')).toBe(true); + expect(targetEntry.sequences[sequenceKeys[0]]).toHaveLength(1); + expect(targetEntry.sequences[sequenceKeys[0]][0].sequence.sequenceId).toBe('cross-seq'); + expect(targetEntry.sequences[sequenceKeys[0]][0].sequence.effects).toEqual([ + { effectId: 'effect-target', key: 'target-key' }, + ]); + expect(targetEntry.sequences[sequenceKeys[0]][0].trigger).toBe('click'); + expect(targetEntry.sequences[sequenceKeys[0]][0].key).toBe('source-key'); + expect(targetEntry.interactionIds).toContain(sequenceKeys[0]); + }); + + test('resolves sequence effect target via config.effects when effect has no key', () => { + const inlineSequence: SequenceConfig = { + sequenceId: 'resolve-target-seq', + effects: [{ effectId: 'effect-target' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineSequence], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + + expect(instance.dataCache.interactions['target-key']).toBeDefined(); + const sequenceKeys = Object.keys(instance.dataCache.interactions['target-key'].sequences); + expect(sequenceKeys).toHaveLength(1); + expect(sequenceKeys[0].startsWith('target-key::seq::resolve-target-seq::')).toBe(true); + }); + + test('auto-generates effectId for sequence effects that lack one', () => { + const inlineSequence: SequenceConfig = { + sequenceId: 'gen-effectid-seq', + effects: [{ key: 'source-key' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineSequence], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const seqEffect = ( + instance.dataCache.interactions['source-key'].triggers[0].sequences?.[0] as SequenceConfig + ).effects[0] as { effectId?: string }; + + expect(typeof seqEffect.effectId).toBe('string'); + expect(seqEffect.effectId).toBeTruthy(); + }); + + test('does not create cross-element entry when sequence effect targets same key as source', () => { + const inlineSequence: SequenceConfig = { + sequenceId: 'same-target-seq', + effects: [{ effectId: 'effect-source', key: 'source-key' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineSequence], + effects: [{ effectId: 'effect-source' }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + + expect(Object.keys(instance.dataCache.interactions)).toEqual(['source-key']); + expect(Object.keys(instance.dataCache.interactions['source-key'].sequences)).toHaveLength(0); + }); + + test('handles interaction with sequences but no effects', () => { + const inlineSequence: SequenceConfig = { + sequenceId: 'no-effects-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }; + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [inlineSequence], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const trigger = instance.dataCache.interactions['source-key'].triggers[0]; + + expect(trigger.effects).toBeUndefined(); + expect(trigger.sequences).toHaveLength(1); + expect((trigger.sequences?.[0] as SequenceConfig).sequenceId).toBe('no-effects-seq'); + expect(instance.dataCache.interactions['target-key']).toBeDefined(); + }); + }); + + describe('Sequence processing via add() -- source element', () => { + test('creates Sequence when source element is added with viewEnter trigger', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'viewEnter', + key: 'source-key', + sequences: [{ sequenceId: 'view-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const { element } = createInteractElement(); + addElement(element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(getSequenceMock).toHaveBeenCalledWith( + expect.objectContaining({ sequenceId: 'view-seq' }), + expect.any(Array), + { reducedMotion: false }, + ); + }); + + test('creates Sequence when source element is added with click trigger', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'click-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const { element } = createInteractElement(); + addElement(element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(getSequenceMock).toHaveBeenCalledWith( + expect.objectContaining({ sequenceId: 'click-seq' }), + expect.any(Array), + { reducedMotion: false }, + ); + }); + + test('passes correct AnimationGroupArgs built from effect definitions', () => { + const getSequenceMock = vi.mocked(getSequence); + const config: InteractConfig = { + effects: { + 'fade-effect': { + keyframeEffect: { + name: 'fade', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 300, + }, + 'slide-effect': { + keyframeEffect: { + name: 'slide', + keyframes: [{ transform: 'translateX(-20px)' }, { transform: 'translateX(0)' }], + }, + duration: 500, + }, + }, + interactions: [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'args-seq', + delay: 25, + offset: 10, + effects: [ + { effectId: 'fade-effect', key: 'source-key' }, + { effectId: 'slide-effect', key: 'source-key' }, + ], + }, + ], + }, + ], + }; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + const [sequenceOptions, animationGroupArgs] = getSequenceMock.mock.calls[0]; + const groupArgs = Array.isArray(animationGroupArgs) + ? animationGroupArgs + : [animationGroupArgs]; + + expect(sequenceOptions).toEqual( + expect.objectContaining({ + sequenceId: 'args-seq', + delay: 25, + offset: 10, + }), + ); + expect(groupArgs).toHaveLength(2); + expect(groupArgs[0]).toEqual( + expect.objectContaining({ + target: source.child, + options: expect.objectContaining({ + duration: 300, + keyframeEffect: expect.objectContaining({ name: 'fade' }), + }), + }), + ); + expect(groupArgs[1]).toEqual( + expect.objectContaining({ + target: source.child, + options: expect.objectContaining({ + duration: 500, + keyframeEffect: expect.objectContaining({ name: 'slide' }), + }), + }), + ); + }); + + test('resolves effectId references from config.effects', () => { + const getSequenceMock = vi.mocked(getSequence); + const config: InteractConfig = { + effects: { + 'registered-effect': { + keyframeEffect: { + name: 'registered', + keyframes: [{ transform: 'scale(0)' }, { transform: 'scale(1)' }], + }, + duration: 750, + easing: 'ease-in-out', + }, + }, + interactions: [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'resolve-seq', + effects: [{ effectId: 'registered-effect' }], + }, + ], + }, + ], + }; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + const [, animationGroupArgs] = getSequenceMock.mock.calls[0]; + const groupArgs = Array.isArray(animationGroupArgs) + ? animationGroupArgs + : [animationGroupArgs]; + expect(groupArgs[0].options).toEqual( + expect.objectContaining({ + duration: 750, + easing: 'ease-in-out', + keyframeEffect: expect.objectContaining({ name: 'registered' }), + }), + ); + }); + + test('skips sequence when target controller is not yet registered', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'missing-target-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).not.toHaveBeenCalled(); + }); + + test('does not duplicate sequence on re-add (caching via addedInteractions)', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'no-dup-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const controller = addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + // Simulate a second add without clearing state (e.g. element re-observed) + controller.connected = false; + controller.connect('source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + }); + + test('passes pre-created Sequence as animation option to trigger handler', () => { + const getSequenceMock = vi.mocked(getSequence); + const mockSequence = { + play: vi.fn(), + cancel: vi.fn(), + onFinish: vi.fn(), + pause: vi.fn(), + reverse: vi.fn(), + progress: vi.fn(), + persist: vi.fn(), + isCSS: false, + playState: 'idle', + ready: Promise.resolve(), + animations: [], + animationGroups: [], + }; + getSequenceMock.mockReturnValueOnce(mockSequence as any); + const clickAddSpy = vi.spyOn(TRIGGER_TO_HANDLER_MODULE_MAP.click, 'add'); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'precreated-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + const interactionCall = clickAddSpy.mock.calls.find((call) => call[4]?.animation); + expect(interactionCall).toBeDefined(); + // Source and target are both the sourceController.element + expect(interactionCall?.[0]).toBe(source.element); + expect(interactionCall?.[1]).toBe(source.element); + expect(interactionCall?.[4]).toEqual( + expect.objectContaining({ + animation: mockSequence, + reducedMotion: false, + allowA11yTriggers: expect.any(Boolean), + }), + ); + }); + + test('passes selectorCondition to handler options', () => { + const clickAddSpy = vi.spyOn(TRIGGER_TO_HANDLER_MODULE_MAP.click, 'add'); + const config = createBaseConfig(); + config.conditions = { + activeOnly: { + type: 'selector', + predicate: '.is-active &', + }, + }; + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + conditions: ['activeOnly'], + sequences: [{ sequenceId: 'selector-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + const interactionCall = clickAddSpy.mock.calls.find((call) => call[4]?.animation); + expect(interactionCall?.[4]).toEqual( + expect.objectContaining({ + selectorCondition: '.is-active &', + }), + ); + }); + + test('silently skips unresolved sequenceId reference at runtime', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'missing-sequence-ref' }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + expect(() => addElement(source.element, 'source-key')).not.toThrow(); + + expect(getSequenceMock).not.toHaveBeenCalled(); + }); + + test('skips entire sequence when any effect target element is missing', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'partial-missing-seq', + effects: [ + { effectId: 'effect-source' }, + { effectId: 'effect-target', key: 'target-key' }, + ], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).not.toHaveBeenCalled(); + }); + }); + + describe('Sequence processing via addEffectsForTarget() -- cross-element', () => { + test('creates Sequence when target element is added after source', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'cross-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const target = createInteractElement(); + + addElement(source.element, 'source-key'); + expect(getSequenceMock).not.toHaveBeenCalled(); + + addElement(target.element, 'target-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(getSequenceMock).toHaveBeenCalledWith( + expect.objectContaining({ sequenceId: 'cross-seq' }), + expect.any(Array), + { reducedMotion: false }, + ); + }); + + test('creates Sequence when source element is added after target', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'source-after-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const target = createInteractElement(); + + addElement(target.element, 'target-key'); + expect(getSequenceMock).not.toHaveBeenCalled(); + + addElement(source.element, 'source-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(getSequenceMock).toHaveBeenCalledWith( + expect.objectContaining({ sequenceId: 'source-after-seq' }), + expect.any(Array), + { reducedMotion: false }, + ); + }); + + test('does not duplicate sequence when both orderings attempt creation', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'dedup-seq', + effects: [ + { effectId: 'effect-source', key: 'source-key' }, + { effectId: 'effect-target', key: 'target-key' }, + ], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const target = createInteractElement(); + + addElement(source.element, 'source-key'); + expect(getSequenceMock).not.toHaveBeenCalled(); + + addElement(target.element, 'target-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + }); + + test('resolves correct target elements when effects target different keys', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'multi-target-seq', + effects: [ + { effectId: 'effect-source', key: 'source-key' }, + { effectId: 'effect-target', key: 'target-key' }, + ], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const target = createInteractElement(); + + addElement(source.element, 'source-key'); + addElement(target.element, 'target-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + const [sequenceOptions, animationGroupArgs] = getSequenceMock.mock.calls[0]; + expect(sequenceOptions).toEqual(expect.objectContaining({ sequenceId: 'multi-target-seq' })); + expect(animationGroupArgs).toHaveLength(2); + expect(animationGroupArgs).toEqual([ + expect.objectContaining({ target: source.child }), + expect.objectContaining({ target: target.child }), + ]); + }); + + test('passes sequence as animation to trigger handler on source element', () => { + const getSequenceMock = vi.mocked(getSequence); + const mockSequence = { + play: vi.fn(), + cancel: vi.fn(), + onFinish: vi.fn(), + pause: vi.fn(), + reverse: vi.fn(), + progress: vi.fn(), + persist: vi.fn(), + isCSS: false, + playState: 'idle', + ready: Promise.resolve(), + animations: [], + animationGroups: [], + }; + getSequenceMock.mockReturnValueOnce(mockSequence as any); + const clickAddSpy = vi.spyOn(TRIGGER_TO_HANDLER_MODULE_MAP.click, 'add'); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'handler-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const target = createInteractElement(); + addElement(source.element, 'source-key'); + addElement(target.element, 'target-key'); + + const interactionCall = clickAddSpy.mock.calls.find((call) => call[4]?.animation); + expect(interactionCall).toBeDefined(); + expect(interactionCall?.[0]).toBe(source.element); + expect(interactionCall?.[1]).toBe(source.element); + expect(interactionCall?.[4]).toEqual(expect.objectContaining({ animation: mockSequence })); + }); + + test('skips variation when interaction-level MQL does not match and falls through to next variation', () => { + const getSequenceMock = vi.mocked(getSequence); + const originalMatchMedia = window.matchMedia; + const mqlTrue = { + matches: true, + media: '(min-width: 1px)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + const mqlFalse = { + matches: false, + media: '(min-width: 99999px)', + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as MediaQueryList; + const matchMediaMock = vi.fn((query: string) => + query.includes('99999px') ? mqlFalse : mqlTrue, + ); + Object.defineProperty(window, 'matchMedia', { + value: matchMediaMock, + writable: true, + configurable: true, + }); + const config = createBaseConfig(); + config.conditions = { + never: { type: 'media', predicate: 'min-width: 99999px' }, + always: { type: 'media', predicate: 'min-width: 1px' }, + }; + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + conditions: ['never'], + sequences: [ + { + sequenceId: 'mql-fallback-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + const target = createInteractElement(); + addElement(source.element, 'source-key'); + + const seqKey = Object.keys(instance.dataCache.interactions['target-key'].sequences)[0]; + instance.dataCache.interactions['target-key'].sequences[seqKey] = [ + { + key: 'source-key', + trigger: 'click', + conditions: ['never'], + sequence: { + sequenceId: 'mql-fallback-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + }, + { + key: 'source-key', + trigger: 'click', + conditions: ['always'], + sequence: { + sequenceId: 'mql-fallback-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + }, + ]; + + addElement(target.element, 'target-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(matchMediaMock).toHaveBeenCalled(); + Object.defineProperty(window, 'matchMedia', { + value: originalMatchMedia, + writable: true, + configurable: true, + }); + }); + + test('skips when source controller is not yet registered', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'source-missing-seq', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const target = createInteractElement(); + addElement(target.element, 'target-key'); + + expect(getSequenceMock).not.toHaveBeenCalled(); + }); + + test('addEffectsForTarget returns true when sequences exist even without effects', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'has-sequences-only', + effects: [{ effectId: 'effect-target', key: 'target-key' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const target = createInteractElement(); + const targetController = addElement(target.element, 'target-key'); + + expect(targetController.connected).toBe(true); + }); + }); + + describe('Sequence with listContainer', () => { + function createListConfig(overrides?: Partial): InteractConfig { + return { + effects: { + 'list-effect': { + key: 'list-key', + listContainer: '#my-list', + keyframeEffect: { + name: 'listFade', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 200, + }, + 'non-list-effect': { + keyframeEffect: { + name: 'nonList', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 300, + }, + }, + interactions: [], + ...overrides, + }; + } + + function createListElement(listSelector: string) { + const element = document.createElement('interact-element') as HTMLElement; + const child = document.createElement('div'); + const list = document.createElement('ul'); + list.id = listSelector.replace('#', ''); + child.append(list); + element.append(child); + return { element, child, list }; + } + + function createListItems(count: number): HTMLElement[] { + return Array.from({ length: count }, () => document.createElement('li')); + } + + test('creates Sequence for each list item when source has listContainer', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'list-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const items = createListItems(3); + items.forEach((li) => list.append(li)); + + addElement(element, 'list-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + const [seqOptions, animationGroupArgs] = getSequenceMock.mock.calls[0]; + const groupArgs = Array.isArray(animationGroupArgs) + ? animationGroupArgs + : [animationGroupArgs]; + expect(seqOptions).toEqual(expect.objectContaining({ sequenceId: 'list-seq' })); + expect(groupArgs).toHaveLength(1); + expect(groupArgs[0].target).toEqual(items); + }); + + test('addListItems inserts groups into existing cached Sequence', () => { + const getSequenceMock = vi.mocked(getSequence); + const createAnimationGroupsMock = vi.mocked(createAnimationGroups); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'list-add-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const initialItems = createListItems(2); + initialItems.forEach((li) => list.append(li)); + + const controller = addElement(element, 'list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + const cachedSequence = getSequenceMock.mock.results[0].value; + + const newItems1 = createListItems(1); + newItems1.forEach((li) => list.append(li)); + addListItems(controller, '#my-list', newItems1); + + // Should NOT create a new Sequence + expect(getSequenceMock).toHaveBeenCalledTimes(1); + // Should call createAnimationGroups for the new items + expect(createAnimationGroupsMock).toHaveBeenCalled(); + // Should call addGroups on the cached Sequence + expect(cachedSequence.addGroups).toHaveBeenCalled(); + + // Only 1 entry in cache + const cacheKeys = Array.from(Interact.sequenceCache.keys()); + const seqCacheKeys = cacheKeys.filter((k) => k.includes('list-add-seq')); + expect(seqCacheKeys.length).toBe(1); + }); + + test('handles removing list items via removeListItems and subsequent re-add', () => { + const getSequenceMock = vi.mocked(getSequence); + const createAnimationGroupsMock = vi.mocked(createAnimationGroups); + const clickRemoveSpy = vi.spyOn(TRIGGER_TO_HANDLER_MODULE_MAP.click, 'remove'); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'remove-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const items = createListItems(2); + items.forEach((li) => list.append(li)); + + const controller = addElement(element, 'list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + const cachedSequence = getSequenceMock.mock.results[0].value; + + removeListItems([items[0]]); + expect(clickRemoveSpy).toHaveBeenCalledWith(items[0]); + + const newItems = createListItems(1); + newItems.forEach((li) => list.append(li)); + createAnimationGroupsMock.mockClear(); + addListItems(controller, '#my-list', newItems); + + // Re-add should use addGroups, not create a new Sequence + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(createAnimationGroupsMock).toHaveBeenCalled(); + expect(cachedSequence.addGroups).toHaveBeenCalled(); + }); + + test('processes sequence effects from listContainer elements', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'process-list-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const items = createListItems(2); + items.forEach((li) => list.append(li)); + + addElement(element, 'list-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + const [, animationGroupArgs] = getSequenceMock.mock.calls[0]; + const groupArgs = Array.isArray(animationGroupArgs) + ? animationGroupArgs + : [animationGroupArgs]; + expect(groupArgs[0].target).toEqual(items); + expect(groupArgs[0].options).toEqual( + expect.objectContaining({ + duration: 200, + keyframeEffect: expect.objectContaining({ name: 'listFade' }), + }), + ); + }); + + test('does not create duplicate sequence when list items overlap with existing', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'no-dup-list-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const items = createListItems(2); + items.forEach((li) => list.append(li)); + + const controller = addElement(element, 'list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + controller.connected = false; + controller.connect('list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + }); + + test('skips sequence when listElements provided but no effects matched the listContainer', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'no-match-list-seq', + effects: [{ effectId: 'non-list-effect', key: 'list-key' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const items = createListItems(2); + items.forEach((li) => list.append(li)); + + const controller = addElement(element, 'list-key'); + getSequenceMock.mockClear(); + + const newItems = createListItems(1); + newItems.forEach((li) => list.append(li)); + addListItems(controller, '#my-list', newItems); + + expect(getSequenceMock).not.toHaveBeenCalled(); + }); + + test('cross-element target: addListItems inserts groups into existing cached Sequence', () => { + const getSequenceMock = vi.mocked(getSequence); + const createAnimationGroupsMock = vi.mocked(createAnimationGroups); + const config: InteractConfig = { + effects: { + 'cross-list-effect': { + key: 'target-list-key', + listContainer: '#target-list', + keyframeEffect: { + name: 'crossListFade', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 250, + }, + }, + interactions: [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'cross-list-seq', + effects: [ + { + effectId: 'cross-list-effect', + key: 'target-list-key', + listContainer: '#target-list', + }, + ], + }, + ], + }, + ], + }; + + Interact.create(config, { useCutsomElement: false }); + + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + const { element: targetEl, list: targetList } = createListElement('#target-list'); + const initialItems = createListItems(2); + initialItems.forEach((li) => targetList.append(li)); + + const targetController = addElement(targetEl, 'target-list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + const cachedSequence = getSequenceMock.mock.results[0].value; + createAnimationGroupsMock.mockClear(); + + const newItems = createListItems(1); + newItems.forEach((li) => targetList.append(li)); + addListItems(targetController, '#target-list', newItems); + + // Should NOT create a new Sequence + expect(getSequenceMock).toHaveBeenCalledTimes(1); + // Should call addGroups on the cached Sequence + expect(cachedSequence.addGroups).toHaveBeenCalled(); + expect(createAnimationGroupsMock).toHaveBeenCalled(); + + // Only 1 entry in cache + const cacheKeys = Array.from(Interact.sequenceCache.keys()); + const crossSeqKeys = cacheKeys.filter((k) => k.includes('cross-list-seq')); + expect(crossSeqKeys.length).toBe(1); + }); + + test('addListItems inserts groups at correct DOM indices when items are added in the middle', () => { + const getSequenceMock = vi.mocked(getSequence); + const createAnimationGroupsMock = vi.mocked(createAnimationGroups); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'idx-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const initialItems = createListItems(3); + initialItems.forEach((li) => list.append(li)); + + const controller = addElement(element, 'list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + const cachedSequence = getSequenceMock.mock.results[0].value; + createAnimationGroupsMock.mockClear(); + + const newItem = createListItems(1)[0]; + // Insert at DOM position 1 (between existing items 0 and 1) + list.insertBefore(newItem, initialItems[1]); + addListItems(controller, '#my-list', [newItem]); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(cachedSequence.addGroups).toHaveBeenCalled(); + }); + + test('addListItems inserts multiple groups at correct indices when adding multiple items at different positions', () => { + const getSequenceMock = vi.mocked(getSequence); + const createAnimationGroupsMock = vi.mocked(createAnimationGroups); + const config = createListConfig({ + interactions: [ + { + trigger: 'click', + key: 'list-key', + listContainer: '#my-list', + sequences: [ + { + sequenceId: 'multi-idx-seq', + effects: [{ effectId: 'list-effect', key: 'list-key', listContainer: '#my-list' }], + }, + ], + }, + ], + }); + + Interact.create(config, { useCutsomElement: false }); + const { element, list } = createListElement('#my-list'); + const initialItems = createListItems(3); + initialItems.forEach((li) => list.append(li)); + + const controller = addElement(element, 'list-key'); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + const cachedSequence = getSequenceMock.mock.results[0].value; + createAnimationGroupsMock.mockClear(); + + const newItemA = createListItems(1)[0]; + const newItemB = createListItems(1)[0]; + // Insert newItemA at position 0, newItemB at the end + list.insertBefore(newItemA, initialItems[0]); + list.append(newItemB); + addListItems(controller, '#my-list', [newItemA, newItemB]); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(cachedSequence.addGroups).toHaveBeenCalled(); + expect(createAnimationGroupsMock).toHaveBeenCalled(); + }); + }); + + describe('Sequence removal and cleanup', () => { + test('remove() cleans up sequence cache entries for the removed key', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'cleanup-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + + const cacheKeysBefore = Array.from(Interact.sequenceCache.keys()); + expect(cacheKeysBefore.some((k) => k.startsWith('source-key::seq::'))).toBe(true); + + const controller = Interact.getController('source-key')!; + controller.disconnect({ removeFromCache: true }); + + const cacheKeysAfter = Array.from(Interact.sequenceCache.keys()); + expect(cacheKeysAfter.some((k) => k.startsWith('source-key::seq::'))).toBe(false); + }); + + test('Interact.destroy() clears sequenceCache', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'destroy-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(Interact.sequenceCache.size).toBeGreaterThan(0); + + Interact.destroy(); + + expect(Interact.sequenceCache.size).toBe(0); + }); + + test('deleteController() removes sequence-related addedInteractions entries', () => { + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'delete-ctrl-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + const hadSeqEntry = Object.keys(instance.addedInteractions).some((k) => + k.startsWith('source-key::seq::'), + ); + expect(hadSeqEntry).toBe(true); + + instance.deleteController('source-key'); + + const hasSeqEntry = Object.keys(instance.addedInteractions).some((k) => + k.startsWith('source-key::seq::'), + ); + expect(hasSeqEntry).toBe(false); + }); + + test('clearInteractionStateForKey removes sequenceCache entries by key prefix', () => { + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [{ sequenceId: 'clear-state-seq', effects: [{ effectId: 'effect-source' }] }], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect( + Array.from(Interact.sequenceCache.keys()).some((k) => k.startsWith('source-key::seq::')), + ).toBe(true); + expect( + Object.keys(instance.addedInteractions).some((k) => k.startsWith('source-key::seq::')), + ).toBe(true); + + instance.clearInteractionStateForKey('source-key'); + + expect( + Array.from(Interact.sequenceCache.keys()).some((k) => k.startsWith('source-key::seq::')), + ).toBe(false); + expect( + Object.keys(instance.addedInteractions).some((k) => k.startsWith('source-key::seq::')), + ).toBe(false); + }); + }); + + describe('Interact.getSequence caching', () => { + test('returns cached Sequence for same cacheKey', () => { + const getSequenceMock = vi.mocked(getSequence); + const sequenceOptions = { sequenceId: 'cache-hit', delay: 0, offset: 100 }; + const animationGroupArgs: any[] = [{ target: null, options: {} }]; + + const first = Interact.getSequence('key-A', sequenceOptions, animationGroupArgs); + const second = Interact.getSequence('key-A', sequenceOptions, animationGroupArgs); + + expect(first).toBe(second); + expect(getSequenceMock).toHaveBeenCalledTimes(1); + }); + + test('creates new Sequence for different cacheKey', () => { + const getSequenceMock = vi.mocked(getSequence); + const mockSeqA = { play: vi.fn(), animations: [] } as any; + const mockSeqB = { play: vi.fn(), animations: [] } as any; + getSequenceMock.mockReturnValueOnce(mockSeqA).mockReturnValueOnce(mockSeqB); + const sequenceOptions = { sequenceId: 'cache-miss', delay: 0, offset: 50 }; + const animationGroupArgs: any[] = [{ target: null, options: {} }]; + + const first = Interact.getSequence('key-B', sequenceOptions, animationGroupArgs); + const second = Interact.getSequence('key-C', sequenceOptions, animationGroupArgs); + + expect(first).toBe(mockSeqA); + expect(second).toBe(mockSeqB); + expect(first).not.toBe(second); + expect(getSequenceMock).toHaveBeenCalledTimes(2); + }); + + test('passes sequenceOptions and animationGroupArgs to motion getSequence', () => { + const getSequenceMock = vi.mocked(getSequence); + const sequenceOptions = { sequenceId: 'fwd-args', delay: 15, offset: 200 }; + const target = {} as HTMLElement; + const animationGroupArgs: any[] = [ + { target, options: { duration: 300, keyframeEffect: { name: 'fade' } } }, + ]; + const context = { reducedMotion: true }; + + Interact.getSequence('key-D', sequenceOptions, animationGroupArgs, context); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + expect(getSequenceMock).toHaveBeenCalledWith(sequenceOptions, animationGroupArgs, context); + }); + }); + + describe('Media query conditions on sequences', () => { + let mockMQLs: Map; + + function mockMatchMedia(matchingQueries: string[] = []) { + mockMQLs = new Map(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn((query: string) => { + const matches = matchingQueries.includes(query); + const mql = { + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + } as unknown as MediaQueryList; + + mockMQLs.set(query, mql); + return mql; + }), + }); + } + + test('skips sequence when sequence-level condition does not match', () => { + mockMatchMedia([]); + const getSequenceMock = vi.mocked(getSequence); + const config = createBaseConfig(); + config.conditions = { + wideOnly: { type: 'media', predicate: 'min-width: 1024px' }, + }; + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'mql-seq', + conditions: ['wideOnly'], + effects: [{ effectId: 'effect-source' }], + }, + ], + }, + ]; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).not.toHaveBeenCalled(); + }); + + test('skips individual effect within sequence when effect-level condition does not match', () => { + mockMatchMedia(['(min-width: 1px)']); + const getSequenceMock = vi.mocked(getSequence); + const config: InteractConfig = { + effects: { + 'always-effect': { + keyframeEffect: { + name: 'always', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 300, + }, + 'conditional-effect': { + keyframeEffect: { + name: 'conditional', + keyframes: [{ transform: 'scale(0)' }, { transform: 'scale(1)' }], + }, + duration: 400, + conditions: ['wideOnly'], + }, + }, + conditions: { + wideOnly: { type: 'media', predicate: 'min-width: 99999px' }, + }, + interactions: [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'partial-mql-seq', + effects: [ + { effectId: 'always-effect', key: 'source-key' }, + { effectId: 'conditional-effect', key: 'source-key', conditions: ['wideOnly'] }, + ], + }, + ], + }, + ], + }; + + Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + expect(getSequenceMock).toHaveBeenCalledTimes(1); + const [, animationGroupArgs] = getSequenceMock.mock.calls[0]; + const groupArgs = Array.isArray(animationGroupArgs) + ? animationGroupArgs + : [animationGroupArgs]; + expect(groupArgs).toHaveLength(1); + expect(groupArgs[0].options).toEqual( + expect.objectContaining({ + duration: 300, + keyframeEffect: expect.objectContaining({ name: 'always' }), + }), + ); + }); + + test('sets up media query listener for sequence conditions', () => { + mockMatchMedia(['(min-width: 1024px)']); + const config = createBaseConfig(); + config.conditions = { + wideOnly: { type: 'media', predicate: 'min-width: 1024px' }, + }; + config.interactions = [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'listener-seq', + conditions: ['wideOnly'], + effects: [{ effectId: 'effect-source' }], + }, + ], + }, + ]; + + const instance = Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + const listenerKeys = Array.from(instance.mediaQueryListeners.keys()); + const seqListenerKey = listenerKeys.find( + (k) => k.includes('seq') && k.includes('listener-seq'), + ); + expect(seqListenerKey).toBeDefined(); + + const listener = instance.mediaQueryListeners.get(seqListenerKey!); + expect(listener).toBeDefined(); + expect(listener!.mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + + test('sets up media query listener for effect-level conditions within sequence', () => { + mockMatchMedia(['(min-width: 768px)']); + const config: InteractConfig = { + effects: { + 'cond-effect': { + keyframeEffect: { + name: 'condEffect', + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + duration: 300, + conditions: ['tabletUp'], + }, + }, + conditions: { + tabletUp: { type: 'media', predicate: 'min-width: 768px' }, + }, + interactions: [ + { + trigger: 'click', + key: 'source-key', + sequences: [ + { + sequenceId: 'eff-listener-seq', + effects: [{ effectId: 'cond-effect', key: 'source-key', conditions: ['tabletUp'] }], + }, + ], + }, + ], + }; + + const instance = Interact.create(config, { useCutsomElement: false }); + const source = createInteractElement(); + addElement(source.element, 'source-key'); + + const listenerKeys = Array.from(instance.mediaQueryListeners.keys()); + const effectListenerKey = listenerKeys.find( + (k) => k.includes('eff-listener-seq') && k.includes('cond-effect'), + ); + expect(effectListenerKey).toBeDefined(); + + const listener = instance.mediaQueryListeners.get(effectListenerKey!); + expect(listener).toBeDefined(); + expect(listener!.mql.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + }); + }); +}); diff --git a/packages/motion/src/AnimationGroup.ts b/packages/motion/src/AnimationGroup.ts index eb2750e..a037d09 100644 --- a/packages/motion/src/AnimationGroup.ts +++ b/packages/motion/src/AnimationGroup.ts @@ -114,4 +114,28 @@ export class AnimationGroup { get playState() { return this.animations[0]?.playState; } + + applyOffset(offset: number) { + for (const animation of this.animations) { + const effect = animation.effect; + + if (effect) { + const timing = effect.getTiming(); + + effect.updateTiming({ + delay: (timing.delay || 0) + offset, + }); + } + } + } + + setDelay(delay: number) { + for (const animation of this.animations) { + const effect = animation.effect; + + if (effect) { + effect.updateTiming({ delay }); + } + } + } } diff --git a/packages/motion/src/Sequence.ts b/packages/motion/src/Sequence.ts new file mode 100644 index 0000000..ff251af --- /dev/null +++ b/packages/motion/src/Sequence.ts @@ -0,0 +1,101 @@ +import { AnimationGroup } from './AnimationGroup'; +import type { IndexedGroup, SequenceOptions } from './types'; +import { linear } from './easings'; +import { getJsEasing } from './utils'; + +/** + * @class Sequence + * + * Manages multiple AnimationGroups as a coordinated timeline with staggered delays. + * Extends AnimationGroup to inherit the playback control API while delegating + * to child AnimationGroup instances. + */ +export class Sequence extends AnimationGroup { + animationGroups: AnimationGroup[]; + delay: number; + offset: number; + offsetEasing: (p: number) => number; + private baseDelays: number[]; + + constructor(animationGroups: AnimationGroup[], options: SequenceOptions = {}) { + const allAnimations = animationGroups.flatMap((group) => [...group.animations]); + super(allAnimations); + + this.animationGroups = animationGroups; + this.delay = options.delay ?? 0; + this.offset = options.offset ?? 0; + this.offsetEasing = + typeof options.offsetEasing === 'function' + ? options.offsetEasing + : (getJsEasing(options.offsetEasing) ?? linear); + + this.baseDelays = animationGroups.map((g) => this.getGroupBaseDelay(g)); + this.applyOffsets(); + this.ready = Promise.all(animationGroups.map((g) => g.ready)).then(() => {}); + } + + /** + * Calculates stagger delay offsets for each animation group using the formula: + * easing(i / last) * last * offset + * where i is the group index and last is the index of the final group. + */ + private calculateOffsets(): number[] { + const count = this.animationGroups.length; + if (count <= 1) return [0]; + + const last = count - 1; + + return Array.from( + { length: count }, + (_, i) => (this.offsetEasing(i / last) * last * this.offset) | 0, + ); + } + + private getGroupBaseDelay(group: AnimationGroup): number { + return (group.animations[0]?.effect?.getTiming().delay as number) || 0; + } + + private applyOffsets(): void { + const offsets = this.calculateOffsets(); + + this.animationGroups.forEach((group, index) => { + const absoluteDelay = (this.baseDelays[index] || 0) + this.delay + offsets[index]; + group.setDelay(absoluteDelay); + }); + } + + /** + * Inserts new AnimationGroups at specified indices, then recalculates + * stagger offsets for all groups. Each entry specifies the target index + * in the animationGroups array where the group should be inserted. + */ + addGroups(entries: IndexedGroup[]): void { + if (entries.length === 0) return; + + const sorted = [...entries].sort((a, b) => b.index - a.index); + + for (const { index, group } of sorted) { + const clampedIndex = Math.min(index, this.animationGroups.length); + this.animationGroups.splice(clampedIndex, 0, group); + this.baseDelays.splice(clampedIndex, 0, this.getGroupBaseDelay(group)); + + const flatAnimations = [...group.animations]; + const insertAt = this.animationGroups + .slice(0, clampedIndex) + .reduce((sum, g) => sum + g.animations.length, 0); + this.animations.splice(insertAt, 0, ...flatAnimations); + } + + this.applyOffsets(); + this.ready = Promise.all(this.animationGroups.map((g) => g.ready)).then(() => {}); + } + + async onFinish(callback: () => void): Promise { + try { + await Promise.all(this.animationGroups.map((group) => group.finished)); + callback(); + } catch (_error) { + console.warn('animation was interrupted - aborting onFinish callback - ', _error); + } + } +} diff --git a/packages/motion/src/motion.ts b/packages/motion/src/motion.ts index be14ef0..35e321b 100644 --- a/packages/motion/src/motion.ts +++ b/packages/motion/src/motion.ts @@ -8,8 +8,11 @@ import type { ScrubScrollScene, ScrubPointerScene, PointerMoveAxis, + SequenceOptions, + AnimationGroupArgs, } from './types'; import { AnimationGroup } from './AnimationGroup'; +import { Sequence } from './Sequence'; import { getEasing, getJsEasing } from './utils'; import { getWebAnimation } from './api/webAnimations'; import { getCSSAnimation } from './api/cssAnimations'; @@ -211,6 +214,59 @@ function getAnimation( return getWebAnimation(target, animationOptions, trigger, { reducedMotion }); } +function resolveTargets( + target: HTMLElement | HTMLElement[] | string | null, +): (HTMLElement | null)[] { + if (target === null) return [null]; + if (typeof target === 'string') { + return Array.from(document.querySelectorAll(target)); + } + if (Array.isArray(target)) return target; + + return [target]; +} + +/** + * Creates AnimationGroup instances from AnimationGroupArgs without wrapping them in a Sequence. + */ +function createAnimationGroups( + animationGroupArgs: AnimationGroupArgs[], + context?: Record, +): AnimationGroup[] { + const groups: AnimationGroup[] = []; + + for (const { target, options: animationGroupOptions } of animationGroupArgs) { + const elements = resolveTargets(target); + + for (const element of elements) { + const result = getAnimation( + element, + animationGroupOptions, + undefined, + context?.reducedMotion, + ); + + if (result instanceof AnimationGroup) { + groups.push(result); + } + } + } + + return groups; +} + +/** + * Creates a Sequence that coordinates multiple AnimationGroups with staggered delays. + */ +function getSequence( + options: SequenceOptions, + animationGroups: AnimationGroupArgs[], + context?: Record, +): Sequence { + const groups = createAnimationGroups(animationGroups, context); + return new Sequence(groups, options); +} + export { getCSSAnimation, getWebAnimation, @@ -219,7 +275,9 @@ export { getScrubScene, prepareAnimation, getAnimation, + getSequence, + createAnimationGroups, getEasing, }; -export type { AnimationGroup }; +export type { AnimationGroup, Sequence }; diff --git a/packages/motion/src/types.ts b/packages/motion/src/types.ts index a9ec3c6..97c82db 100644 --- a/packages/motion/src/types.ts +++ b/packages/motion/src/types.ts @@ -264,3 +264,20 @@ export type EffectModule = | ScrollEffectModule | MouseEffectModule | WebAnimationEffectFactory<'scrub'>; + +export type SequenceOptions = { + delay?: number; + offset?: number; + offsetEasing?: string | ((p: number) => number); +}; + +export type AnimationGroupArgs = { + target: HTMLElement | HTMLElement[] | string | null; + options: AnimationOptions; + context?: Record; +}; + +export type IndexedGroup = { + index: number; + group: import('./AnimationGroup').AnimationGroup; +}; diff --git a/packages/motion/src/utils.ts b/packages/motion/src/utils.ts index 78e3dbb..ab6dd03 100644 --- a/packages/motion/src/utils.ts +++ b/packages/motion/src/utils.ts @@ -1,4 +1,5 @@ import { cssEasings, jsEasings } from './easings'; + export function getCssUnits(unit: 'percentage' | string) { return unit === 'percentage' ? '%' : unit || 'px'; } @@ -7,8 +8,180 @@ export function getEasing(easing?: keyof typeof cssEasings | string): string { return easing ? cssEasings[easing as keyof typeof cssEasings] || easing : cssEasings.linear; } +function cubicBezierEasing(x1: number, y1: number, x2: number, y2: number): (t: number) => number { + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + const cy = 3 * y1; + const by = 3 * (y2 - y1) - cy; + const ay = 1 - cy - by; + + const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t; + const sampleY = (t: number) => ((ay * t + by) * t + cy) * t; + const sampleDX = (t: number) => (3 * ax * t + 2 * bx) * t + cx; + + function solveT(x: number): number { + let t = x; + + for (let i = 0; i < 8; i++) { + const dx = sampleX(t) - x; + + if (Math.abs(dx) < 1e-7) return t; + + const d = sampleDX(t); + + if (Math.abs(d) < 1e-6) break; + + t -= dx / d; + } + // Bisection fallback + let lo = 0, + hi = 1; + t = (lo + hi) / 2; + + while (hi - lo > 1e-7) { + const xMid = sampleX(t); + if (Math.abs(xMid - x) < 1e-7) return t; + if (x > xMid) lo = t; + else hi = t; + t = (lo + hi) / 2; + } + + return t; + } + + return (t: number) => { + if (t <= 0) return 0; + if (t >= 1) return 1; + return sampleY(solveT(t)); + }; +} + +function parseCubicBezier(str: string): ((t: number) => number) | undefined { + const m = str.match( + /^cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)$/, + ); + + if (!m) return undefined; + + const x1 = parseFloat(m[1]); + const y1 = parseFloat(m[2]); + const x2 = parseFloat(m[3]); + const y2 = parseFloat(m[4]); + + if ([x1, y1, x2, y2].some(isNaN)) return undefined; + + return cubicBezierEasing(x1, y1, x2, y2); +} + +function parseCssLinear(str: string): ((t: number) => number) | undefined { + const m = str.match(/^linear\((.+)\)$/); + if (!m) return undefined; + + const parts = m[1] + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + if (parts.length === 0) return undefined; + + type Stop = { output: number; pos: number | null }; + const stops: Stop[] = []; + + for (const part of parts) { + const tokens = part.split(/\s+/); + const output = parseFloat(tokens[0]); + + if (isNaN(output)) return undefined; + + const pcts: number[] = []; + + for (let i = 1; i < tokens.length; i++) { + if (tokens[i].endsWith('%')) { + const v = parseFloat(tokens[i]) / 100; + if (isNaN(v)) return undefined; + pcts.push(v); + } + } + + if (pcts.length === 0) { + stops.push({ output, pos: null }); + } else if (pcts.length === 1) { + stops.push({ output, pos: pcts[0] }); + } else { + // Two percentages: creates a plateau between the two positions + stops.push({ output, pos: pcts[0] }); + stops.push({ output, pos: pcts[1] }); + } + } + + if (stops.length === 0) return undefined; + if (stops[0].pos === null) stops[0].pos = 0; + if (stops[stops.length - 1].pos === null) stops[stops.length - 1].pos = 1; + + // Distribute positions for stops without an explicit position + let i = 0; + + while (i < stops.length) { + if (stops[i].pos === null) { + const start = i - 1; + let end = i; + + while (end < stops.length && stops[end].pos === null) end++; + + const startPos = stops[start].pos!; + const endPos = stops[end].pos!; + const span = end - start; + + for (let k = start + 1; k < end; k++) { + stops[k].pos = startPos + ((endPos - startPos) * (k - start)) / span; + } + + i = end + 1; + } else { + i++; + } + } + + // Clamp: each stop must be no earlier than the previous one + for (let j = 1; j < stops.length; j++) { + if (stops[j].pos! < stops[j - 1].pos!) stops[j].pos = stops[j - 1].pos; + } + + const resolved = stops as Array<{ output: number; pos: number }>; + + return (t: number) => { + if (t <= resolved[0].pos) return resolved[0].output; + + const last = resolved[resolved.length - 1]; + + if (t >= last.pos) return last.output; + + let lo = 0, + hi = resolved.length - 1; + + while (lo < hi - 1) { + const mid = (lo + hi) >>> 1; + + if (resolved[mid].pos <= t) lo = mid; + else hi = mid; + } + + const a = resolved[lo]; + const b = resolved[hi]; + if (b.pos === a.pos) return b.output; + return a.output + ((b.output - a.output) * (t - a.pos)) / (b.pos - a.pos); + }; +} + export function getJsEasing( easing?: keyof typeof jsEasings | string, ): ((t: number) => number) | undefined { - return easing ? jsEasings[easing as keyof typeof jsEasings] : undefined; + if (!easing) return undefined; + + const named = jsEasings[easing as keyof typeof jsEasings]; + + if (named) return named; + + return parseCubicBezier(easing) ?? parseCssLinear(easing) ?? jsEasings.linear; } diff --git a/packages/motion/test/AnimationGroup.spec.ts b/packages/motion/test/AnimationGroup.spec.ts index 15c7ce6..dfaf3fe 100644 --- a/packages/motion/test/AnimationGroup.spec.ts +++ b/packages/motion/test/AnimationGroup.spec.ts @@ -758,6 +758,67 @@ describe('AnimationGroup', () => { }); }); + describe('applyOffset()', () => { + test('adds offset to each animation effect delay via updateTiming', () => { + const updateTiming1 = vi.fn(); + const updateTiming2 = vi.fn(); + const mockAnimation1 = createMockAnimation({ + effect: { + getTiming: vi.fn().mockReturnValue({ delay: 0 }), + updateTiming: updateTiming1, + } as any, + }); + const mockAnimation2 = createMockAnimation({ + effect: { + getTiming: vi.fn().mockReturnValue({ delay: 10 }), + updateTiming: updateTiming2, + } as any, + }); + const animationGroup = new AnimationGroup([mockAnimation1, mockAnimation2]); + + animationGroup.applyOffset(25); + + expect(updateTiming1).toHaveBeenCalledWith({ delay: 25 }); + expect(updateTiming2).toHaveBeenCalledWith({ delay: 35 }); + }); + + test('accumulates with existing delay value', () => { + const updateTiming = vi.fn(); + const mockAnimation = createMockAnimation({ + effect: { + getTiming: vi.fn().mockReturnValue({ delay: 120 }), + updateTiming, + } as any, + }); + const animationGroup = new AnimationGroup([mockAnimation]); + + animationGroup.applyOffset(30); + + expect(updateTiming).toHaveBeenCalledWith({ delay: 150 }); + }); + + test('skips animations with no effect', () => { + const updateTiming = vi.fn(); + const withEffect = createMockAnimation({ + effect: { + getTiming: vi.fn().mockReturnValue({ delay: 5 }), + updateTiming, + } as any, + }); + const withoutEffect = createMockAnimation({ effect: null as any }); + const animationGroup = new AnimationGroup([withEffect, withoutEffect]); + + expect(() => animationGroup.applyOffset(20)).not.toThrow(); + expect(updateTiming).toHaveBeenCalledWith({ delay: 25 }); + }); + + test('handles empty animations array', () => { + const animationGroup = new AnimationGroup([]); + + expect(() => animationGroup.applyOffset(20)).not.toThrow(); + }); + }); + describe('onFinish()', () => { test('should execute callback when all animations finish', async () => { const callback = vi.fn(); @@ -1100,4 +1161,49 @@ describe('AnimationGroup', () => { expect(mockAnimation2.currentTime).toBe(1000); }); }); + + describe('setDelay()', () => { + const createAnimationWithUpdateTiming = (overrides: Partial = {}): Animation => + createMockAnimation({ + effect: { + getComputedTiming: vi.fn().mockReturnValue({ progress: 0.5 }), + getTiming: vi.fn().mockReturnValue({ delay: 0, duration: 1000 }), + updateTiming: vi.fn(), + } as any, + ...overrides, + }); + + test('sets absolute delay on all animations', () => { + const mockAnimation1 = createAnimationWithUpdateTiming(); + const mockAnimation2 = createAnimationWithUpdateTiming(); + const group = new AnimationGroup([mockAnimation1, mockAnimation2]); + + group.setDelay(300); + + expect(mockAnimation1.effect!.updateTiming).toHaveBeenCalledWith({ delay: 300 }); + expect(mockAnimation2.effect!.updateTiming).toHaveBeenCalledWith({ delay: 300 }); + }); + + test('overwrites any previously set delay', () => { + const mockAnimation = createAnimationWithUpdateTiming(); + const group = new AnimationGroup([mockAnimation]); + + group.setDelay(100); + group.setDelay(500); + + expect(mockAnimation.effect!.updateTiming).toHaveBeenLastCalledWith({ delay: 500 }); + }); + + test('is idempotent (calling twice with same value produces same result)', () => { + const mockAnimation = createAnimationWithUpdateTiming(); + const group = new AnimationGroup([mockAnimation]); + + group.setDelay(200); + group.setDelay(200); + + const calls = (mockAnimation.effect!.updateTiming as ReturnType).mock.calls; + expect(calls[calls.length - 1]).toEqual([{ delay: 200 }]); + expect(calls[calls.length - 2]).toEqual([{ delay: 200 }]); + }); + }); }); diff --git a/packages/motion/test/Sequence.spec.ts b/packages/motion/test/Sequence.spec.ts new file mode 100644 index 0000000..e5112ab --- /dev/null +++ b/packages/motion/test/Sequence.spec.ts @@ -0,0 +1,526 @@ +import { describe, expect, test, vi } from 'vitest'; +import { AnimationGroup } from '../src/AnimationGroup'; +import { linear } from '../src/easings'; +import { Sequence } from '../src/Sequence'; +import { getJsEasing } from '../src/utils'; + +(globalThis as any).CSSAnimation = class CSSAnimation {}; + +const createMockAnimation = (overrides: Partial = {}): Animation => + ({ + id: '', + currentTime: 0, + playState: 'idle' as AnimationPlayState, + ready: Promise.resolve(undefined as any), + finished: Promise.resolve(undefined as any), + effect: { + getComputedTiming: vi.fn().mockReturnValue({ progress: 0.5 }), + getTiming: vi.fn().mockReturnValue({ delay: 0, duration: 1000, iterations: 1 }), + updateTiming: vi.fn(), + } as any, + play: vi.fn(), + pause: vi.fn(), + cancel: vi.fn(), + reverse: vi.fn(), + playbackRate: 1, + ...overrides, + }) as Animation; + +function createGroup( + options: { + animations?: Animation[]; + ready?: Promise; + finished?: Promise; + } = {}, +) { + const animations = options.animations ?? [createMockAnimation()]; + const group = new AnimationGroup( + animations, + options.ready ? { measured: options.ready } : undefined, + ); + + if (options.finished) { + Object.defineProperty(group, 'finished', { + get: () => options.finished, + configurable: true, + }); + } + + return group; +} + +describe('Sequence', () => { + describe('Constructor', () => { + test('creates Sequence with empty groups array', () => { + const sequence = new Sequence([]); + + expect(sequence.animationGroups).toEqual([]); + expect(sequence.animations).toEqual([]); + }); + + test('creates Sequence from multiple AnimationGroups', () => { + const group1 = createGroup(); + const group2 = createGroup(); + + const sequence = new Sequence([group1, group2]); + + expect(sequence.animationGroups).toEqual([group1, group2]); + }); + + test('flattens all child animations into parent animations array', () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const a3 = createMockAnimation(); + const group1 = createGroup({ animations: [a1] }); + const group2 = createGroup({ animations: [a2, a3] }); + + const sequence = new Sequence([group1, group2]); + + expect(sequence.animations).toEqual([a1, a2, a3]); + }); + + test('stores animationGroups reference', () => { + const groups = [createGroup(), createGroup()]; + + const sequence = new Sequence(groups); + + expect(sequence.animationGroups).toBe(groups); + }); + + test('defaults delay=0, offset=0, offsetEasing=linear', () => { + const sequence = new Sequence([createGroup()]); + + expect(sequence.delay).toBe(0); + expect(sequence.offset).toBe(0); + expect(sequence.offsetEasing).toBe(linear); + }); + + test('accepts custom delay, offset, and offsetEasing function', () => { + const offsetEasing = (p: number) => p ** 3; + const sequence = new Sequence([createGroup()], { delay: 30, offset: 120, offsetEasing }); + + expect(sequence.delay).toBe(30); + expect(sequence.offset).toBe(120); + expect(sequence.offsetEasing).toBe(offsetEasing); + }); + + test("resolves named offsetEasing string via getJsEasing (e.g. 'quadIn')", () => { + const sequence = new Sequence([createGroup()], { offsetEasing: 'quadIn' }); + const quadIn = getJsEasing('quadIn'); + + expect(quadIn).toBeDefined(); + expect(sequence.offsetEasing(0.5)).toBe(quadIn!(0.5)); + }); + + test('resolves cubic-bezier offsetEasing string', () => { + const sequence = new Sequence([createGroup()], { + offsetEasing: 'cubic-bezier(0.25, 0.1, 0.25, 1)', + }); + + expect(sequence.offsetEasing(0.5)).not.toBe(0.5); + }); + + test('falls back to linear for invalid or unknown offsetEasing string', () => { + const sequence = new Sequence([createGroup()], { offsetEasing: 'not-a-real-easing' }); + + expect(sequence.offsetEasing(0.25)).toBe(linear(0.25)); + expect(sequence.offsetEasing(0.75)).toBe(linear(0.75)); + }); + }); + + describe('Offset calculation (calculateOffsets)', () => { + test('single group returns [0]', () => { + const sequence = new Sequence([createGroup()], { offset: 200 }); + + expect((sequence as any).calculateOffsets()).toEqual([0]); + }); + + test('linear easing with 5 groups and offset=200 produces [0, 200, 400, 600, 800]', () => { + const groups = Array.from({ length: 5 }, () => createGroup()); + const sequence = new Sequence(groups, { offset: 200, offsetEasing: 'linear' }); + + expect((sequence as any).calculateOffsets()).toEqual([0, 200, 400, 600, 800]); + }); + + test('quadIn easing with 5 groups and offset=200 produces [0, 50, 200, 450, 800]', () => { + const groups = Array.from({ length: 5 }, () => createGroup()); + const sequence = new Sequence(groups, { offset: 200, offsetEasing: 'quadIn' }); + + expect((sequence as any).calculateOffsets()).toEqual([0, 50, 200, 450, 800]); + }); + + test('sineOut easing produces expected non-linear offsets', () => { + const groups = Array.from({ length: 5 }, () => createGroup()); + const sequence = new Sequence(groups, { offset: 200, offsetEasing: 'sineOut' }); + + expect((sequence as any).calculateOffsets()).toEqual([0, 306, 565, 739, 800]); + }); + + test('floors fractional offsets', () => { + const groups = [createGroup(), createGroup(), createGroup()]; + const sequence = new Sequence(groups, { offset: 99, offsetEasing: (p) => p * 0.5 }); + + expect((sequence as any).calculateOffsets()).toEqual([0, 49, 99]); + }); + }); + + describe('applyOffsets (synchronous in constructor)', () => { + test('applies absolute delay (baseDelay + delay + offset) to each group via setDelay', () => { + const groups = [createGroup(), createGroup(), createGroup()]; + const spies = groups.map((group) => vi.spyOn(group, 'setDelay')); + new Sequence(groups, { delay: 100, offset: 50, offsetEasing: 'linear' }); + + expect(spies[0]).toHaveBeenCalledWith(100); + expect(spies[1]).toHaveBeenCalledWith(150); + expect(spies[2]).toHaveBeenCalledWith(200); + }); + + test('sets delay 0 on all groups when delay and offset are both 0', () => { + const groups = [createGroup(), createGroup()]; + const spies = groups.map((group) => vi.spyOn(group, 'setDelay')); + new Sequence(groups, { delay: 0, offset: 0 }); + + expect(spies[0]).toHaveBeenCalledWith(0); + expect(spies[1]).toHaveBeenCalledWith(0); + }); + + test('applyOffsets is idempotent (calling twice produces same result)', () => { + const groups = [createGroup(), createGroup(), createGroup()]; + const sequence = new Sequence(groups, { delay: 50, offset: 100, offsetEasing: 'linear' }); + + const getDelays = () => groups.map((g) => g.animations[0]?.effect?.getTiming().delay); + const delaysAfterFirst = getDelays(); + + (sequence as any).applyOffsets(); + const delaysAfterSecond = getDelays(); + + expect(delaysAfterFirst).toEqual(delaysAfterSecond); + }); + + test('ready resolves after all group ready promises settle', async () => { + let resolveFirst!: () => void; + let resolveSecond!: () => void; + const firstReady = new Promise((r) => { + resolveFirst = r; + }); + const secondReady = new Promise((r) => { + resolveSecond = r; + }); + + const group1 = createGroup({ ready: firstReady }); + const group2 = createGroup({ ready: secondReady }); + const sequence = new Sequence([group1, group2], { delay: 10 }); + + let resolved = false; + const readyPromise = sequence.ready.then(() => { + resolved = true; + }); + + await Promise.resolve(); + expect(resolved).toBe(false); + + resolveFirst(); + await Promise.resolve(); + expect(resolved).toBe(false); + + resolveSecond(); + await readyPromise; + expect(resolved).toBe(true); + }); + }); + + describe('addGroups', () => { + test('inserts groups at specified indices', () => { + const g1 = createGroup(); + const g2 = createGroup(); + const sequence = new Sequence([g1, g2]); + + const gNew = createGroup(); + sequence.addGroups([{ index: 1, group: gNew }]); + + expect(sequence.animationGroups).toEqual([g1, gNew, g2]); + }); + + test('appends groups when index equals length (end of sequence)', () => { + const g1 = createGroup(); + const sequence = new Sequence([g1]); + + const gNew = createGroup(); + sequence.addGroups([{ index: 1, group: gNew }]); + + expect(sequence.animationGroups).toEqual([g1, gNew]); + }); + + test('inserts flattened animations at correct position in animations array', () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const g1 = createGroup({ animations: [a1] }); + const g2 = createGroup({ animations: [a2] }); + const sequence = new Sequence([g1, g2]); + + const aNew = createMockAnimation(); + const gNew = createGroup({ animations: [aNew] }); + sequence.addGroups([{ index: 1, group: gNew }]); + + expect(sequence.animations).toEqual([a1, aNew, a2]); + }); + + test('recalculates offsets for all groups (existing + new) using setDelay', () => { + const createStatefulMockAnimation = (): Animation => { + let currentDelay = 0; + return { + id: '', + currentTime: 0, + playState: 'idle' as AnimationPlayState, + ready: Promise.resolve(undefined as any), + finished: Promise.resolve(undefined as any), + effect: { + getComputedTiming: vi.fn().mockReturnValue({ progress: 0.5 }), + getTiming: vi.fn(() => ({ delay: currentDelay, duration: 1000, iterations: 1 })), + updateTiming: vi.fn((opts: { delay?: number }) => { + if (opts.delay !== undefined) currentDelay = opts.delay; + }), + } as any, + play: vi.fn(), + pause: vi.fn(), + cancel: vi.fn(), + reverse: vi.fn(), + playbackRate: 1, + } as unknown as Animation; + }; + + const createStatefulGroup = () => { + const anim = createStatefulMockAnimation(); + return new AnimationGroup([anim]); + }; + + const groups = [createStatefulGroup(), createStatefulGroup()]; + const sequence = new Sequence(groups, { delay: 0, offset: 100, offsetEasing: 'linear' }); + + const gNew = createStatefulGroup(); + const setDelaySpy = vi.spyOn(gNew, 'setDelay'); + sequence.addGroups([{ index: 1, group: gNew }]); + + // 3 groups, linear, offset=100: [0, 100, 200] + expect(setDelaySpy).toHaveBeenCalledWith(100); + + const allDelays = sequence.animationGroups.map( + (g) => (g.animations[0]?.effect?.getTiming().delay as number) || 0, + ); + expect(allDelays).toEqual([0, 100, 200]); + }); + + test('updates ready promise to include new groups', async () => { + const g1 = createGroup(); + const sequence = new Sequence([g1]); + + let newReady = false; + const newReadyPromise = new Promise((r) => { + setTimeout(() => { + newReady = true; + r(); + }, 0); + }); + const gNew = createGroup({ ready: newReadyPromise }); + sequence.addGroups([{ index: 1, group: gNew }]); + + await sequence.ready; + expect(newReady).toBe(true); + }); + + test('with empty array is a no-op', () => { + const g1 = createGroup(); + const sequence = new Sequence([g1], { offset: 100 }); + const groupsBefore = [...sequence.animationGroups]; + const animsBefore = [...sequence.animations]; + + sequence.addGroups([]); + + expect(sequence.animationGroups).toEqual(groupsBefore); + expect(sequence.animations).toEqual(animsBefore); + }); + + test('preserves existing base delays when recalculating after insertion', () => { + const a1 = createMockAnimation({ + effect: { + getComputedTiming: vi.fn().mockReturnValue({ progress: 0.5 }), + getTiming: vi.fn().mockReturnValue({ delay: 50, duration: 1000, iterations: 1 }), + updateTiming: vi.fn(), + } as any, + }); + const g1 = createGroup({ animations: [a1] }); + const g2 = createGroup(); + const sequence = new Sequence([g1, g2], { delay: 0, offset: 100, offsetEasing: 'linear' }); + + // g1 baseDelay=50, g2 baseDelay=0. Offsets: [0, 100]. Delays: [50, 100] + expect(a1.effect!.updateTiming).toHaveBeenLastCalledWith({ delay: 50 }); + + const gNew = createGroup(); + sequence.addGroups([{ index: 2, group: gNew }]); + + // 3 groups. Offsets: [0, 100, 200]. Delays: [50, 100, 200] + expect(a1.effect!.updateTiming).toHaveBeenLastCalledWith({ delay: 50 }); + }); + + test('handles multiple insertions at different indices in correct order', () => { + const g1 = createGroup(); + const g2 = createGroup(); + const g3 = createGroup(); + const sequence = new Sequence([g1, g2, g3]); + + const gA = createGroup(); + const gB = createGroup(); + sequence.addGroups([ + { index: 0, group: gA }, + { index: 2, group: gB }, + ]); + + // Sorted descending: insert gB at 2 first -> [g1, g2, gB, g3], then gA at 0 -> [gA, g1, g2, gB, g3] + expect(sequence.animationGroups).toEqual([gA, g1, g2, gB, g3]); + }); + + test('clamps index to animationGroups.length when index exceeds bounds', () => { + const g1 = createGroup(); + const sequence = new Sequence([g1]); + + const gNew = createGroup(); + sequence.addGroups([{ index: 999, group: gNew }]); + + expect(sequence.animationGroups).toEqual([g1, gNew]); + }); + }); + + describe('Inherited playback API (from AnimationGroup)', () => { + test('play() plays all flattened animations', async () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const sequence = new Sequence([ + createGroup({ animations: [a1] }), + createGroup({ animations: [a2] }), + ]); + + await sequence.play(); + + expect(a1.play).toHaveBeenCalledTimes(1); + expect(a2.play).toHaveBeenCalledTimes(1); + }); + + test('pause() pauses all flattened animations', () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const sequence = new Sequence([ + createGroup({ animations: [a1] }), + createGroup({ animations: [a2] }), + ]); + + sequence.pause(); + + expect(a1.pause).toHaveBeenCalledTimes(1); + expect(a2.pause).toHaveBeenCalledTimes(1); + }); + + test('reverse() reverses all flattened animations', async () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const sequence = new Sequence([ + createGroup({ animations: [a1] }), + createGroup({ animations: [a2] }), + ]); + + await sequence.reverse(); + + expect(a1.reverse).toHaveBeenCalledTimes(1); + expect(a2.reverse).toHaveBeenCalledTimes(1); + }); + + test('cancel() cancels all flattened animations', () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const sequence = new Sequence([ + createGroup({ animations: [a1] }), + createGroup({ animations: [a2] }), + ]); + + sequence.cancel(); + + expect(a1.cancel).toHaveBeenCalledTimes(1); + expect(a2.cancel).toHaveBeenCalledTimes(1); + }); + + test('setPlaybackRate() sets rate on all flattened animations', () => { + const a1 = createMockAnimation(); + const a2 = createMockAnimation(); + const sequence = new Sequence([ + createGroup({ animations: [a1] }), + createGroup({ animations: [a2] }), + ]); + + sequence.setPlaybackRate(1.5); + + expect(a1.playbackRate).toBe(1.5); + expect(a2.playbackRate).toBe(1.5); + }); + + test('playState returns from first animation', () => { + const playing = createMockAnimation({ playState: 'running' }); + const idle = createMockAnimation({ playState: 'idle' }); + const sequence = new Sequence([ + createGroup({ animations: [playing] }), + createGroup({ animations: [idle] }), + ]); + + expect(sequence.playState).toBe('running'); + }); + }); + + describe('onFinish (overridden)', () => { + test('calls callback when all animation groups finish', async () => { + const group1 = createGroup({ finished: Promise.resolve(undefined) }); + const group2 = createGroup({ finished: Promise.resolve(undefined) }); + const sequence = new Sequence([group1, group2]); + const callback = vi.fn(); + + await sequence.onFinish(callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + test("does not call callback if any group's finished promise rejects", async () => { + const group1 = createGroup({ finished: Promise.resolve(undefined) }); + const group2 = createGroup({ finished: Promise.reject(new Error('interrupted')) }); + const sequence = new Sequence([group1, group2]); + const callback = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await sequence.onFinish(callback); + + expect(callback).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('logs warning on interrupted animation', async () => { + const error = new Error('interrupted'); + const group = createGroup({ finished: Promise.reject(error) }); + const sequence = new Sequence([group]); + const callback = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await sequence.onFinish(callback); + + expect(warnSpy).toHaveBeenCalledWith( + 'animation was interrupted - aborting onFinish callback - ', + error, + ); + warnSpy.mockRestore(); + }); + + test('handles empty groups array', async () => { + const sequence = new Sequence([]); + const callback = vi.fn(); + + await sequence.onFinish(callback); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/motion/test/getSequence.spec.ts b/packages/motion/test/getSequence.spec.ts new file mode 100644 index 0000000..4c46b02 --- /dev/null +++ b/packages/motion/test/getSequence.spec.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { AnimationGroup } from '../src/AnimationGroup'; +import { getSequence, createAnimationGroups } from '../src/motion'; +import type { AnimationGroupArgs, SequenceOptions } from '../src/types'; + +vi.mock('../src/api/webAnimations', () => ({ + getWebAnimation: vi.fn(), +})); + +import { getWebAnimation } from '../src/api/webAnimations'; + +(globalThis as any).CSSAnimation = class CSSAnimation {}; + +const mockedGetWebAnimation = vi.mocked(getWebAnimation); + +const createAnimationGroupArgs = ( + target: AnimationGroupArgs['target'], + effectId = 'effect-id', +): AnimationGroupArgs => ({ + target, + options: { + keyframeEffect: { + name: effectId, + keyframes: [{ opacity: 0 }, { opacity: 1 }], + }, + effectId, + } as any, +}); + +const createGroup = () => new AnimationGroup([]); + +describe('getSequence()', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + (HTMLElement.prototype as any).getAnimations = vi.fn(() => []); + }); + + describe('AnimationGroupArgs[] flow', () => { + test('creates Sequence with one AnimationGroup per resolved target element across multiple entries', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + const el3 = document.createElement('div'); + const group1 = createGroup(); + const group2 = createGroup(); + const group3 = createGroup(); + mockedGetWebAnimation + .mockReturnValueOnce(group1) + .mockReturnValueOnce(group2) + .mockReturnValueOnce(group3); + + const sequence = getSequence({}, [ + createAnimationGroupArgs(el1, 'effect-a'), + createAnimationGroupArgs([el2, el3], 'effect-b'), + ]); + + expect(sequence).toBeInstanceOf(AnimationGroup); + expect(sequence.animationGroups).toEqual([group1, group2, group3]); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(3); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 1, + el1, + expect.objectContaining({ effectId: 'effect-a' }), + undefined, + { reducedMotion: false }, + ); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 2, + el2, + expect.objectContaining({ effectId: 'effect-b' }), + undefined, + { reducedMotion: false }, + ); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 3, + el3, + expect.objectContaining({ effectId: 'effect-b' }), + undefined, + { reducedMotion: false }, + ); + }); + + test('handles a single entry with HTMLElement target', () => { + const element = document.createElement('button'); + const group = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group); + + const sequence = getSequence({}, [ + createAnimationGroupArgs(element, 'single-element-effect'), + ]); + + expect(sequence.animationGroups).toEqual([group]); + expect(mockedGetWebAnimation).toHaveBeenCalledOnce(); + expect(mockedGetWebAnimation).toHaveBeenCalledWith( + element, + expect.objectContaining({ effectId: 'single-element-effect' }), + undefined, + { reducedMotion: false }, + ); + }); + + test('handles a single entry with HTMLElement[] target where each element becomes its own group', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + const group1 = createGroup(); + const group2 = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group1).mockReturnValueOnce(group2); + + const sequence = getSequence({}, [createAnimationGroupArgs([el1, el2], 'array-effect')]); + + expect(sequence.animationGroups).toEqual([group1, group2]); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(2); + }); + + test('handles a single entry with string selector target via querySelectorAll', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + el1.className = 'card'; + el2.className = 'card'; + document.body.append(el1, el2); + + const group1 = createGroup(); + const group2 = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group1).mockReturnValueOnce(group2); + + const sequence = getSequence({}, [createAnimationGroupArgs('.card', 'selector-effect')]); + + expect(sequence.animationGroups).toEqual([group1, group2]); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(2); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 1, + el1, + expect.objectContaining({ effectId: 'selector-effect' }), + undefined, + { reducedMotion: false }, + ); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 2, + el2, + expect.objectContaining({ effectId: 'selector-effect' }), + undefined, + { reducedMotion: false }, + ); + }); + + test('handles a single entry with null target and passes it through to getAnimation', () => { + const group = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group); + + const sequence = getSequence({}, [createAnimationGroupArgs(null, 'null-target-effect')]); + + expect(sequence.animationGroups).toEqual([group]); + expect(mockedGetWebAnimation).toHaveBeenCalledOnce(); + expect(mockedGetWebAnimation).toHaveBeenCalledWith( + null, + expect.objectContaining({ effectId: 'null-target-effect' }), + undefined, + { reducedMotion: false }, + ); + }); + + test('creates Sequence with one group per entry', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + const group1 = createGroup(); + const group2 = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group1).mockReturnValueOnce(group2); + + const sequence = getSequence({}, [ + createAnimationGroupArgs(el1, 'entry-1'), + createAnimationGroupArgs(el2, 'entry-2'), + ]); + + expect(sequence.animationGroups).toEqual([group1, group2]); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(2); + }); + + test('resolves each entry target independently', () => { + const item1 = document.createElement('div'); + item1.className = 'item-a'; + const item2 = document.createElement('div'); + item2.className = 'item-b'; + document.body.append(item1, item2); + + const group1 = createGroup(); + const group2 = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group1).mockReturnValueOnce(group2); + + const sequence = getSequence({}, [ + createAnimationGroupArgs('.item-a', 'a-effect'), + createAnimationGroupArgs('.item-b', 'b-effect'), + ]); + + expect(sequence.animationGroups).toEqual([group1, group2]); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 1, + item1, + expect.objectContaining({ effectId: 'a-effect' }), + undefined, + { reducedMotion: false }, + ); + expect(mockedGetWebAnimation).toHaveBeenNthCalledWith( + 2, + item2, + expect.objectContaining({ effectId: 'b-effect' }), + undefined, + { reducedMotion: false }, + ); + }); + }); + + describe('Options forwarding', () => { + test('passes SequenceOptions (delay, offset, offsetEasing) to Sequence constructor', () => { + const element = document.createElement('div'); + const group = createGroup(); + const options: SequenceOptions = { + delay: 100, + offset: 50, + offsetEasing: 'quadIn', + }; + mockedGetWebAnimation.mockReturnValueOnce(group); + + const sequence = getSequence(options, [createAnimationGroupArgs(element, 'opts-effect')]); + + expect(sequence.delay).toBe(100); + expect(sequence.offset).toBe(50); + expect(sequence.offsetEasing(0.5)).toBe(0.25); + }); + + test('passes context.reducedMotion to getAnimation', () => { + const element = document.createElement('div'); + const group = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group); + + getSequence({}, [createAnimationGroupArgs(element, 'reduced-motion-effect')], { + reducedMotion: true, + }); + + expect(mockedGetWebAnimation).toHaveBeenCalledWith( + element, + expect.objectContaining({ effectId: 'reduced-motion-effect' }), + undefined, + { reducedMotion: true }, + ); + }); + }); + + describe('Edge cases', () => { + test('skips entries where getAnimation returns non-AnimationGroup', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + const validGroup = createGroup(); + mockedGetWebAnimation + .mockReturnValueOnce({ play: vi.fn() } as any) + .mockReturnValueOnce(validGroup); + + const sequence = getSequence({}, [ + createAnimationGroupArgs([el1, el2], 'mixed-results-effect'), + ]); + + expect(sequence.animationGroups).toEqual([validGroup]); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(2); + }); + + test('returns Sequence with empty groups when all entries fail', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + mockedGetWebAnimation.mockReturnValue(null); + + const sequence = getSequence({}, [createAnimationGroupArgs([el1, el2], 'all-fail-effect')]); + + expect(sequence.animationGroups).toEqual([]); + expect(sequence.animations).toEqual([]); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(2); + }); + }); + + describe('createAnimationGroups()', () => { + test('creates AnimationGroup array from AnimationGroupArgs without creating Sequence', () => { + const el1 = document.createElement('div'); + const el2 = document.createElement('div'); + const group1 = createGroup(); + const group2 = createGroup(); + mockedGetWebAnimation.mockReturnValueOnce(group1).mockReturnValueOnce(group2); + + const groups = createAnimationGroups([ + createAnimationGroupArgs(el1, 'effect-a'), + createAnimationGroupArgs(el2, 'effect-b'), + ]); + + expect(groups).toEqual([group1, group2]); + expect(groups).toBeInstanceOf(Array); + expect(mockedGetWebAnimation).toHaveBeenCalledTimes(2); + }); + + test('returns empty array when all entries produce non-AnimationGroup results', () => { + const el1 = document.createElement('div'); + mockedGetWebAnimation.mockReturnValue(null); + + const groups = createAnimationGroups([createAnimationGroupArgs(el1, 'fail-effect')]); + + expect(groups).toEqual([]); + }); + }); +}); diff --git a/packages/motion/test/utils.spec.ts b/packages/motion/test/utils.spec.ts new file mode 100644 index 0000000..77ba715 --- /dev/null +++ b/packages/motion/test/utils.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest'; +import { getJsEasing } from '../src/utils'; +import { jsEasings } from '../src/easings'; + +describe('utils/getJsEasing()', () => { + test('returns named JS easing function', () => { + const easing = getJsEasing('quadIn'); + + expect(easing).toBeTypeOf('function'); + expect(easing?.(0.5)).toBeCloseTo(0.25, 6); + }); + + test('parses cubic-bezier() string and evaluates endpoints', () => { + const easing = getJsEasing('cubic-bezier(0.25, 0.1, 0.25, 1)'); + + expect(easing).toBeTypeOf('function'); + expect(easing?.(0)).toBeCloseTo(0, 6); + expect(easing?.(1)).toBeCloseTo(1, 6); + }); + + test('cubic-bezier(0, 0, 1, 1) behaves like linear', () => { + const easing = getJsEasing('cubic-bezier(0, 0, 1, 1)'); + + expect(easing).toBeTypeOf('function'); + expect(easing?.(0.1)).toBeCloseTo(0.1, 6); + expect(easing?.(0.5)).toBeCloseTo(0.5, 6); + expect(easing?.(0.9)).toBeCloseTo(0.9, 6); + }); + + test('parses linear() with implicit stop positions', () => { + const easing = getJsEasing('linear(0, 1)'); + + expect(easing).toBeTypeOf('function'); + expect(easing?.(0)).toBeCloseTo(0, 6); + expect(easing?.(0.25)).toBeCloseTo(0.25, 6); + expect(easing?.(0.75)).toBeCloseTo(0.75, 6); + expect(easing?.(1)).toBeCloseTo(1, 6); + }); + + test('parses linear() with explicit stop positions', () => { + const easing = getJsEasing('linear(0, 0.5 50%, 1)'); + + expect(easing).toBeTypeOf('function'); + expect(easing?.(0.25)).toBeCloseTo(0.25, 6); + expect(easing?.(0.5)).toBeCloseTo(0.5, 6); + expect(easing?.(0.75)).toBeCloseTo(0.75, 6); + }); + + test('parses linear() plateau with two percentages on a stop', () => { + const easing = getJsEasing('linear(0, 1 40% 60%, 0)'); + + expect(easing).toBeTypeOf('function'); + expect(easing?.(0.5)).toBeCloseTo(1, 6); + expect(easing?.(0.8)).toBeCloseTo(0.5, 6); + }); + + test('returns linear for invalid cubic-bezier() string', () => { + expect(getJsEasing('cubic-bezier(0.1, 0.2, 0.3)')).toBe(jsEasings.linear); + }); + + test('returns linear easing for invalid linear() string', () => { + expect(getJsEasing('linear(foo, bar)')).toBe(jsEasings.linear); + }); +});