diff --git a/apps/playground/src/interact/InteractManager.ts b/apps/playground/src/interact/InteractManager.ts index df49cbca..8f31227a 100644 --- a/apps/playground/src/interact/InteractManager.ts +++ b/apps/playground/src/interact/InteractManager.ts @@ -1,4 +1,5 @@ import { Interact } from '@wix/interact/web'; +import { generate } from '@wix/interact'; import type { InteractConfig } from '@wix/interact'; import { store } from '../store/PlaygroundStore'; import { getAllPresets } from './preset-registry'; @@ -7,6 +8,8 @@ interface IInteractElement extends HTMLElement { connect(): void; } +let generatedSheet: CSSStyleSheet | null = null; + let presetsRegistered = false; function ensurePresets(): void { @@ -36,6 +39,7 @@ export function pauseInteract(): void { currentInstance = null; cancelStaleAnimations(); } + removeGeneratedCSS(); } export function resumeInteract(): void { @@ -57,6 +61,31 @@ function cancelStaleAnimations(): void { }); } +function removeGeneratedCSS(): void { + const root = stageElement?.shadowRoot; + if (!root || !generatedSheet) return; + root.adoptedStyleSheets = root.adoptedStyleSheets.filter((s) => s !== generatedSheet); + generatedSheet = null; +} + +function injectGeneratedCSS(css: string): void { + const root = stageElement?.shadowRoot; + if (!root) return; + + if (!css) { + removeGeneratedCSS(); + return; + } + + if (generatedSheet) { + generatedSheet.replaceSync(css); + } else { + generatedSheet = new CSSStyleSheet(); + generatedSheet.replaceSync(css); + root.adoptedStyleSheets = [generatedSheet, ...root.adoptedStyleSheets]; + } +} + function apply(config: InteractConfig): void { if (paused) return; @@ -77,6 +106,8 @@ function apply(config: InteractConfig): void { if (validConfig.interactions.length === 0) return; + injectGeneratedCSS(generate(validConfig)); + currentInstance = Interact.create(validConfig); reconnectShadowElements(); diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts index ad718606..2ecac712 100644 --- a/packages/interact/src/core/add.ts +++ b/packages/interact/src/core/add.ts @@ -692,7 +692,7 @@ function addInteraction( key: targetKey, effectId: (effect as Effect).effectId!, transition: (effect as StateEffect).transition, - properties: (effect as StateEffect).transitionProperties, + transitionProperties: (effect as StateEffect).transitionProperties, childSelector: getSelector(effect, { asCombinator: true, addItemFilter: true, diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index eebd77fc..587934ec 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -1,111 +1,547 @@ -import { getSelectorCondition, applySelectorCondition } from '../utils'; -import { Effect, InteractConfig, TimeEffect } from '../types'; +import type { + InteractConfig, + Interaction, + ResolvedEffect, + ResolvedSequence, + Condition, + ListPropertyName, + ListCustomProps, + CSSCoordinatedLists, + CSSRuleData, +} from '../types'; +import { + kebabCustomProp, + camelToKebabCase, + transitionEffectToTransitionsList, + getFullPredicateByType, + getSelectorCondition, +} from '../utils'; import { getSelector } from './Interact'; +import { resolveEffectForCSS, resolveSequenceForCSS } from './resolvers'; +import { getElementHash, getUniqueEncodedHash } from './utilities'; +import { keyframesToCSS, CSSRuleToString, buildListsRule } from './cssUtils'; +import { effectToAnimationOptions } from '../handlers/utilities'; +import { getCSSAnimation, MotionKeyframeEffect, TriggerVariant } from '@wix/motion'; -const buildSelector = ( +export const DEFAULT_INITIAL = [ + { name: 'visibility', value: 'hidden' }, + { name: 'transform', value: 'none' }, + { name: 'translate', value: 'none' }, + { name: 'scale', value: 'none' }, + { name: 'rotate', value: 'none' }, +]; + +const LIST_ANIMATION_PROPERTY_NAMES = [ + 'animation', + 'animation-composition', + 'animation-timeline', + 'animation-range', +] as const satisfies readonly ListPropertyName[]; + +type AnimationPropertyName = (typeof LIST_ANIMATION_PROPERTY_NAMES)[number]; + +const LIST_PROPERTY_NAMES: ListPropertyName[] = ['transition', ...LIST_ANIMATION_PROPERTY_NAMES]; + +const LIST_PROPERTY_NAMES_MOTION: Record = { + animation: 'animation', + 'animation-composition': 'composition', + 'animation-timeline': 'animationTimeline', + 'animation-range': 'animationRange', +}; + +const LIST_PROPERTY_FALLBACKS: Record = { + animation: 'none', + 'animation-composition': 'replace', + transition: '_', + 'animation-timeline': 'auto', + 'animation-range': 'normal', +}; + +// ----- Map Updaters ----- + +function accumulateUsedProperties( + map: Map>, + targetHash: string, + props: ListPropertyName[], +) { + const existing = map.get(targetHash); + if (existing) { + props.forEach((p) => existing.add(p)); + } else { + map.set(targetHash, new Set(props)); + } +} + +function pushToTargetCustomPropsLists( + targetToLists: Map, + targetHash: string, + customProps: ListCustomProps, + usedProperties?: Set, +): void { + const { key, childSelector } = customProps; + const propertyNames = usedProperties + ? LIST_PROPERTY_NAMES.filter((n) => usedProperties.has(n)) + : LIST_PROPERTY_NAMES; + + if (!targetToLists.has(targetHash)) { + targetToLists.set(targetHash, { key, childSelector, properties: {} }); + } + const { properties } = targetToLists.get(targetHash)!; + for (const name of propertyNames) { + if (!properties[name]) { + properties[name] = { fallback: LIST_PROPERTY_FALLBACKS[name], varNames: [] }; + } + properties[name]!.varNames.push(customProps[name]); + } +} + +function buildCustomProps( + indices: (string | number)[], + encodedHash: string, +): Record { + return LIST_PROPERTY_NAMES.reduce( + (acc, name) => { + acc[name] = kebabCustomProp([name, ...indices, encodedHash]); + return acc; + }, + {} as Record, + ); +} + +function getInteractionCustomPropsForTarget( + targetHash: string, key: string, - effect: Effect, - conditionSelector: string | undefined, - useFirstChild: boolean, -): string => { - const escapedKey = key.replace(/"/g, "'"); + interactionIdx: number, + targetToCustomProps: Map, + childSelector?: string, +): ListCustomProps { + if (!targetToCustomProps.has(targetHash)) { + targetToCustomProps.set(targetHash, { + key, + childSelector, + ...buildCustomProps([interactionIdx], getUniqueEncodedHash(targetHash)), + }); + } + + return targetToCustomProps.get(targetHash)!; +} + +function generateSequenceCustomProps( + targetHash: string, + interactionIdx: number, + index: number, +): Record { + return buildCustomProps([interactionIdx, index], getUniqueEncodedHash(targetHash)); +} + +// ----- Parsers ----- + +function triggerToCSS( + interaction: Interaction, + configConditions: Record, + triggerId: string, + useFirstChild: boolean = true, +): CSSRuleData { + const { key, conditions } = interaction; + + const media = getFullPredicateByType(conditions, configConditions, 'media'); + const selectorCondition = getSelectorCondition(conditions, configConditions); - let baseSelector = `[data-interact-key="${escapedKey}"]`; + const childSelector = getSelector(interaction, { + asCombinator: true, + useFirstChild, + addItemFilter: true, + }); + + return { + key, + media, + selectorCondition, + childSelector, + // invalidating earlier cascaded custom properties affected from earlier transitionEffects + // to implement same-interaction-cascade + declarations: [ + { + name: 'view-timeline', + value: `--${triggerId}`, + }, + ], + }; +} + +function effectToCSS( + effect: ResolvedEffect, + configConditions: Record, + customProps: ListCustomProps, + trigger: TriggerVariant, + childSelector?: string, +): { + rules: CSSRuleData[]; + keyframes: MotionKeyframeEffect[]; + usedProperties: ListPropertyName[]; +} { + const { + key, + effectId, + conditions, + namedEffect, + keyframeEffect, + transition, + transitionProperties, + initial, + } = effect; + + const media = getFullPredicateByType(conditions, configConditions, 'media'); + const selectorCondition = getSelectorCondition(conditions, configConditions); + + const rules: CSSRuleData[] = [ + { + key, + media, + selectorCondition, + childSelector, + declarations: [], + }, + ]; + let keyframes: MotionKeyframeEffect[] = []; - const elementSelector = getSelector(effect, { asCombinator: true, useFirstChild }); + const { declarations } = rules[0]; - if (elementSelector) { - baseSelector = `${baseSelector} ${elementSelector}`; + let usedProperties: ListPropertyName[] = []; + + if (namedEffect || keyframeEffect) { + usedProperties = [...LIST_ANIMATION_PROPERTY_NAMES]; + + const animationOptions = effectToAnimationOptions(effect); + const cssAnimations = getCSSAnimation(null, animationOptions, trigger).filter( + (anim) => anim.name, + ); + + // accumulate keyframes + keyframes = cssAnimations.map((anim) => ({ + name: anim.name as string, + keyframes: anim.keyframes, + })); + + // declare custom parameters + declarations.push( + ...cssAnimations.flatMap(({ custom }) => + Object.entries(custom || {}) + .filter(([_, value]) => value !== undefined) + .map(([key, value]) => ({ name: key, value: value as string | number })), + ), + ); + + const animationDeclarations = LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ + name: customProps[propertyName], + value: + cssAnimations + .map((animation) => { + const name = LIST_PROPERTY_NAMES_MOTION[propertyName]; + return (animation as Record)[name]; + }) + .join(', ') || LIST_PROPERTY_FALLBACKS[propertyName], + })); + + if (initial) { + // declare animation and composition custom properties with initial dependent on data-motion-enter + rules.push({ + key, + media, + selectorCondition, + childSelector, + declarations: animationDeclarations.concat(DEFAULT_INITIAL), + addInitialSelector: true, + }); + } else { + // declare animation and composition custom properties + declarations.push(...animationDeclarations); + } + } else if (transition || transitionProperties) { + usedProperties = ['transition']; + + const properties = transition?.styleProperties || transitionProperties || []; + const transitions = transitionEffectToTransitionsList(effect); + + // declaring transition custom property + declarations.push({ + name: customProps.transition, + value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, + }); + + // adding state rule using custom properties that could be overriden to implement + // same-interaction-cascade + rules.push({ + key, + media, + selectorCondition, + childSelector, + states: [effectId], + declarations: properties, + }); + } else { + // setting off animation, composition and transition custom properties + declarations.push( + ...LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ + name: customProps[propertyName], + value: LIST_PROPERTY_FALLBACKS[propertyName], + })), + ); } - if (conditionSelector) { - baseSelector = applySelectorCondition(baseSelector, conditionSelector); + return { rules: rules.filter((r) => r.declarations.length), keyframes, usedProperties }; +} + +function parseEffect( + configConditions: Record, + interactionIdx: number, + effect: ResolvedEffect, + targetToCustomProps: Map, + keyframesMap: Map, + trigger: TriggerVariant, + useFirstChild: boolean = true, + sequenceCustomProps?: Record, + precomputedTargetHash?: string, +): { rules: CSSRuleData[]; usedProperties: ListPropertyName[] } { + const { key } = effect; + const targetHash = precomputedTargetHash ?? getElementHash(effect); + const childSelector = getSelector(effect, { + asCombinator: true, + useFirstChild, + addItemFilter: true, + }); + + // get existing custom-property names for coordinated-list for this target and interaction + // or generate them if it is first time this interaction uses this target + const customProps = getInteractionCustomPropsForTarget( + targetHash, + key, + interactionIdx, + targetToCustomProps, + childSelector, + ); + + // in case effect is part of a sequence, we use different custom-proprties names to not override + // the entire interaction, instead we generate unique-per-effect name to allow effects to live together + const localCustomProps = { ...customProps }; + if (sequenceCustomProps) { + Object.assign(localCustomProps, sequenceCustomProps); } - return baseSelector; -}; + // process effect into css-rules and keyframes + const { rules, keyframes, usedProperties } = effectToCSS( + effect, + configConditions, + localCustomProps, + trigger, + childSelector, + ); + + // update keyframes map + keyframes.forEach(({ name, keyframes }) => keyframesMap.set(name, keyframes)); + + return { rules, usedProperties }; +} + +function parseSequence( + configConditions: Record, + interactionIdx: number, + sequence: ResolvedSequence, + targetToCustomProps: Map, + keyframesMap: Map, + trigger: TriggerVariant, + useFirstChild: boolean = true, + targetUsedProperties?: Map>, +): CSSRuleData[] { + // in a similar manner to how we treat different interactions and use lists to concatenate them + // instead of overriding, we use the same mechanism to allow all of the effects of a sequence to + // exist together on the same target - + // targetHash to lists of custom-properties for each coordinated-list type property + // to be populated when parsing effects + const targetToSequenceLists = new Map(); + const targetSequenceIndex = new Map(); + + const cssRules: CSSRuleData[] = []; + + for (const effect of sequence.effects) { + const targetHash = getElementHash(effect); + const { key } = effect; + const childSelector = getSelector(effect, { + asCombinator: true, + useFirstChild, + addItemFilter: true, + }); + + const index = targetSequenceIndex.get(targetHash) || 0; + targetSequenceIndex.set(targetHash, index + 1); -export function generate(_config: InteractConfig, useFirstChild: boolean = false): string { - const css: string[] = []; - const processedSelectors = new Set(); + const seqCustomProps = generateSequenceCustomProps(targetHash, interactionIdx, index); - _config.interactions.forEach( - ({ - key: interactionKey, - selector: interactionSelector, - listContainer: interactionListContainer, - listItemSelector: interactionListItemSelector, + const { rules, usedProperties } = parseEffect( + configConditions, + interactionIdx, + effect, + targetToCustomProps, + keyframesMap, trigger, - effects, - conditions: interactionConditions, - }) => { - const isViewEnter = trigger === 'viewEnter'; - if (isViewEnter) { - effects?.forEach((effect) => { - const effectData = effect?.effectId ? _config.effects[effect.effectId] || effect : effect; - const isOnce = - !(effectData as TimeEffect).triggerType || - (effectData as TimeEffect).triggerType === 'once'; - if (!isOnce) return; - const { - key: effectKey, - selector: effectSelector, - listContainer: effectListContainer, - listItemSelector: effectListItemSelector, - conditions: effectConditions, - } = effectData; - - const sameKey = !effectKey || effectKey === interactionKey; - if (!sameKey) return; - - const sameSelector = - (!effectSelector && !interactionSelector) || effectSelector === interactionSelector; - if (!sameSelector) return; - - const sameListcontainer = - (!effectListContainer && !interactionListContainer) || - effectListContainer === interactionListContainer; - if (!sameListcontainer) return; - - const sameListItemSelector = - (!effectListItemSelector && !interactionListItemSelector) || - effectListItemSelector === interactionListItemSelector; - if (!sameListItemSelector) return; - - const configConditions = _config.conditions || {}; - const effectConditionSelector = getSelectorCondition(effectConditions, configConditions); - const interactionConditionSelector = getSelectorCondition( - interactionConditions, - configConditions, - ); - const sameConditionSelector = - (!effectConditionSelector && !interactionConditionSelector) || - effectConditionSelector === interactionConditionSelector; - if (!sameConditionSelector) return; - - const selector = buildSelector( - interactionKey, - effectData, - interactionConditionSelector, - useFirstChild, - ); - - if (!processedSelectors.has(selector)) { - processedSelectors.add(selector); - css.push(`@media (prefers-reduced-motion: no-preference) { - ${selector}:not([data-interact-enter]) { - visibility: hidden; - transform: none; - translate: none; - scale: none; - rotate: none; - } - }`); - } - }); - } - }, + useFirstChild, + seqCustomProps, + targetHash, + ); + cssRules.push(...rules); + + const usedSet = new Set(usedProperties); + + pushToTargetCustomPropsLists( + targetToSequenceLists, + targetHash, + { key, childSelector, ...seqCustomProps }, + usedSet, + ); + + if (targetUsedProperties) { + accumulateUsedProperties(targetUsedProperties, targetHash, usedProperties); + } + } + + const { conditions } = sequence; + + targetToSequenceLists.forEach((lists, targetHash) => { + const customProps = targetToCustomProps.get(targetHash)!; + + // for each target add rule with sequence-conditions for the coordinated lists from interactions targeting it + // here we use the interaction's custom-properties to set the lists as values for them instead of + // directly into the actual coordinated-list type property, to provide cascading in the array of sequences + cssRules.push(buildListsRule(lists, customProps, conditions, configConditions)); + }); + + return cssRules; +} + +function parseInteraction( + config: InteractConfig, + interaction: Interaction, + interactionIdx: number, + targetToLists: Map, + keyframesMap: Map, + useFirstChild: boolean = true, +): CSSRuleData[] { + const { effects = [], sequences = [] } = interaction; + const configConditions = config.conditions || {}; + + // targetHash to custom-property per each coordinated-list type property for current interaction + // to be populated when parsing the effects (since it is per target). + // Each interaction uses a single custom-property for each coordinated-list type property, + // to provide cascading in the array of effects - e.g. effects in the interaction array with exact same target + // will populate the same per-interaction custom-property (e.g. `--animation-${interactionIdx}-${targetUniqueSuffix}`) + // and the last one will be applied. + const targetToCustomProps = new Map(); + + const targetUsedProperties = new Map>(); + + const resolvedEffects = effects + .map((effect) => resolveEffectForCSS(effect, interaction, config)) + .filter((effect) => effect !== null); + + const cssRules = []; + + const { trigger } = interaction; + const motionTrigger = { + trigger: camelToKebabCase(trigger), + id: ['trigger', interactionIdx].join('-'), + componentId: '', + } as TriggerVariant; + if (trigger === 'viewProgress') { + cssRules.push(triggerToCSS(interaction, configConditions, motionTrigger.id, useFirstChild)); + } + + for (const effect of resolvedEffects) { + const targetHash = getElementHash(effect); + const { rules, usedProperties } = parseEffect( + configConditions, + interactionIdx, + effect, + targetToCustomProps, + keyframesMap, + motionTrigger, + useFirstChild, + ); + cssRules.push(...rules); + + accumulateUsedProperties(targetUsedProperties, targetHash, usedProperties); + } + + const resolvedSequences = sequences + .map((sequence) => resolveSequenceForCSS(sequence, interaction, config)) + .filter((sequence) => sequence !== null); + + cssRules.push( + ...resolvedSequences.flatMap((sequence) => + parseSequence( + configConditions, + interactionIdx, + sequence, + targetToCustomProps, + keyframesMap, + motionTrigger, + useFirstChild, + targetUsedProperties, + ), + ), ); + // after processing all of the effects, we add to the lists of custom-properties per target + // the new interaction's custom-property names + targetToCustomProps.forEach((customProps, targetHash) => { + pushToTargetCustomPropsLists( + targetToLists, + targetHash, + customProps, + targetUsedProperties.get(targetHash), + ); + }); + + return cssRules; +} + +// ----- EndPoints ----- + +export function _generate( + config: InteractConfig, + useFirstChild: boolean = true, +): { + cssRules: CSSRuleData[]; + keyframes: Map; +} { + // targetHash to lists of custom-properties for each coordinated-list type property + // to be populated when parsing interactions + const targetToLists = new Map(); + const keyframes = new Map(); + + const cssRules = config.interactions.flatMap((interaction, interactionIdx) => + parseInteraction(config, interaction, interactionIdx, targetToLists, keyframes, useFirstChild), + ); + + // for each target add unconditional rule for the coordinated lists from interactions targeting it + targetToLists.forEach((lists) => { + cssRules.push(buildListsRule(lists)); + }); + + return { keyframes, cssRules }; +} +/** + * Generates CSS for animations from an InteractConfig. + * + * @param config - The interact configuration containing effects and interactions + * @returns string containing all of the CSS rules needed for time-based animations + */ +export function generate(config: InteractConfig): string { + const { cssRules, keyframes } = _generate(config); + + const css = [ + ...[...keyframes.entries()].map(([name, keyframes]) => keyframesToCSS(name, keyframes)), + ...cssRules.map(CSSRuleToString), + ]; + return css.join('\n'); } diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts new file mode 100644 index 00000000..2d13fe16 --- /dev/null +++ b/packages/interact/src/core/cssUtils.ts @@ -0,0 +1,174 @@ +import type { + Condition, + ListPropertyName, + ListCustomProps, + CSSCoordinatedLists, + CSSRuleData, +} from '../types'; +import { + roundNumber, + getFullPredicateByType, + getSelectorCondition, + applySelectorCondition, +} from '../utils'; + +export function keyframePropertyToCSS(key: string): string { + if (key === 'cssFloat') { + return 'float'; + } + if (key === 'easing') { + return 'animation-timing-function'; + } + if (key === 'cssOffset') { + return 'offset'; + } + if (key === 'composite') { + return 'animation-composition'; + } + return key.replace(/([A-Z])/g, '-$1').toLowerCase(); +} + +export function interpolateKeyframesOffsets(keyframes: Keyframe[]): Keyframe[] { + if (!keyframes.length) { + return []; + } + + const result = keyframes.map((kf) => ({ ...kf })); + + // Set first and last if not present + if (result[0].offset === undefined) { + result[0].offset = 0; + } + if (result[result.length - 1].offset === undefined) { + result[result.length - 1].offset = 1; + } + + // Find segments between defined offsets and interpolate + let lastDefinedIndex = 0, + currentOffset = result[0].offset as number; + for (let i = 1; i < result.length; i++) { + if (result[i].offset !== undefined) { + const endOffset = result[i].offset as number; + + if (endOffset < currentOffset) { + console.error('Offsets must be monotonically non-decreasing'); + return []; + } else if (endOffset > 1) { + console.error('Offsets must be in the range [0,1]'); + return []; + } + const gap = i - lastDefinedIndex; + + for (let j = lastDefinedIndex + 1; j < i; j++) { + const progress = (j - lastDefinedIndex) / gap; + result[j].offset = currentOffset + (endOffset - currentOffset) * progress; + } + + lastDefinedIndex = i; + currentOffset = endOffset; + } + } + + return result; +} + +export function keyframeObjectToKeyframeCSS(keyframeObj: Keyframe, percentage: number): string { + const properties = Object.entries(keyframeObj) + .filter(([key, value]) => key !== 'offset' && value !== undefined && value !== null) + .map(([key, value]) => { + const cssKey = keyframePropertyToCSS(key); + return `${cssKey}: ${value};`; + }) + .join('\n'); + return `${percentage}% {\n${properties}\n}`; +} + +export function keyframesToCSS(name: string, keyframes: Keyframe[]): string { + const interpolated = interpolateKeyframesOffsets(keyframes); + if (!interpolated.length) { + return ''; + } + + let keyframeBlocks = interpolated + .map((kf) => { + const offset = kf.offset as number; + const percentage = roundNumber(offset * 100); + + return keyframeObjectToKeyframeCSS(kf, percentage); + }) + .join('\n'); + + return `@keyframes ${name} {\n${keyframeBlocks}\n}`; +} + +export function CSSRuleToString(rule: CSSRuleData): string { + const { key, childSelector, declarations, media, states, selectorCondition, addInitialSelector } = + rule; + if (!declarations.length) { + return ''; + } + + let selector = `[data-interact-key="${key}"]${ + addInitialSelector ? ':is(:not([data-interact-enter]))' : '' + }`; + + // maybe nesting is simpler? - no need for `:is` only adding `&` before every option + if (states && states.length) { + const statesSelector = states + .map((state) => `:state(${state}), :--${state}, [data-interact-effect~="${state}"]`) + .join(', '); + selector = `${selector}:is(${statesSelector})`; + } + + // here nesting might be confusing due to spaces already being handled? + if (childSelector) { + selector = `${selector} ${childSelector}`; + } + + // maybe nesting is simpler? - + // equivalent to `baseSelector { ${applySelectorCondition('&', selectorCondition)} { ... } }` + if (selectorCondition) { + selector = applySelectorCondition(selector, selectorCondition); + } + + const declarationsStr = declarations.map(({ name, value }) => `${name}: ${value};`).join('\n'); + const cssRule = `${selector} {\n${declarationsStr}\n}`; + + return media ? `@media ${media} {\n${cssRule}\n}` : cssRule; +} + +export function buildListsRule( + lists: CSSCoordinatedLists, + customProps?: ListCustomProps, + conditions?: string[], + configConditions?: Record, +): CSSRuleData { + const { key, childSelector, properties } = lists; + + const declarations = Object.entries(properties) + .filter( + (entry: [string, { fallback: string; varNames: string[] }]) => + entry[1] && entry[1].varNames.length, + ) + .map(([name, { fallback, varNames }]) => ({ + name, + value: varNames.map((n) => `var(${n}, ${fallback})`).join(', '), + })); + + const rule: CSSRuleData = { key, childSelector, declarations }; + + // option to assign into custom-properties instead of directly into the actual css properties + if (customProps) { + rule.declarations.forEach((declaration) => { + declaration.name = customProps[declaration.name as ListPropertyName]; + }); + } + + // option to add conditions to the rules + if (conditions) { + rule.media = getFullPredicateByType(conditions, configConditions || {}, 'media'); + rule.selectorCondition = getSelectorCondition(conditions, configConditions || {}); + } + + return rule; +} diff --git a/packages/interact/src/core/resolvers.ts b/packages/interact/src/core/resolvers.ts new file mode 100644 index 00000000..4f7c92cd --- /dev/null +++ b/packages/interact/src/core/resolvers.ts @@ -0,0 +1,156 @@ +import { MotionKeyframeEffect, NamedEffect, getJsEasing } from '@wix/motion'; +import type { + InteractConfig, + Effect, + EffectRef, + EffectBase, + StateEffect, + Interaction, + ResolvedEffect, + ResolvedSequence, + SequenceConfig, + SequenceConfigRef, + TimeAnimationTriggerType, +} from '../types'; +import { isTemplatedKey, generateId, calculateSequenceEffectsOffsets } from '../utils'; +import { shouldUseInitial } from './utilities'; + +export function resolveEffectForCSS( + effect: Effect | EffectRef, + interaction: Interaction, + config: InteractConfig, +): ResolvedEffect | null { + const { effects = {}, conditions: configConditions = {} } = config; + const { key: interactionKey, trigger } = interaction; + const isPointerMove = trigger === 'pointerMove'; + + // ensuring the original refernce of the effect has an id (required for states) + if (!effect.effectId) { + effect.effectId = generateId(); + } + const { effectId } = effect; + + const fullEffect: EffectBase & + StateEffect & { + triggerType?: TimeAnimationTriggerType; + namedEffect?: NamedEffect; + customEffect?: (element: Element, progress: any) => void; + keyframeEffect?: MotionKeyframeEffect; + } = { ...(effects[effectId] || {}), ...effect }; + + let { key, conditions, triggerType } = fullEffect; + + if (!key) { + if (!interactionKey) { + return null; + } + key = interactionKey; + } + if (isTemplatedKey(key)) { + // should probably find a way to support those + return null; + } + // TODO: handle here any key escaping if needed + + conditions = [ + ...new Set((conditions || []).filter((condition: string) => configConditions[condition])), + ]; + + if (!triggerType) { + triggerType = trigger === 'hover' || trigger === 'click' ? 'alternate' : 'once'; + } + + const { namedEffect, customEffect, keyframeEffect, transition, transitionProperties, ...rest } = { + ...fullEffect, + key, + conditions, + effectId, + triggerType, + }; + + const initial = shouldUseInitial(interaction, rest); + + if (namedEffect) { + // With the 2D nature of pointerMove namedEffects, there is no easy way to mimic the + // behavior with CSSAnimations. + return isPointerMove || !namedEffect.type ? null : { namedEffect, initial, ...rest }; + } else if (keyframeEffect) { + // Need to verify validity of name for CSS? + if (!keyframeEffect.name) { + const canUseEffectId = effectId && !(effects[effectId] && 'keyframeEffect' in effect); + keyframeEffect.name = canUseEffectId ? effectId : generateId(); + } + return { keyframeEffect, initial, ...rest }; + } else if (customEffect) { + return isPointerMove ? null : { initial, ...rest }; + } else if (transition) { + return { transition, initial, ...rest }; + } else { + return transitionProperties ? { transitionProperties, initial, ...rest } : { initial, ...rest }; + } +} + +export function resolveSequenceForCSS( + sequence: SequenceConfig | SequenceConfigRef, + interaction: Interaction, + config: InteractConfig, +): ResolvedSequence | null { + const { sequences = {}, conditions: configConditions = {} } = config; + + // required? + if (!sequence.sequenceId) { + sequence.sequenceId = generateId(); + } + + const { sequenceId } = sequence; + const fullSequence = { ...(sequences[sequenceId] || {}), ...sequence }; + + let { + effects, + conditions, + triggerType, + delay = 0, + offset = 0, + offsetEasing = 'linear', + } = fullSequence; + + if (!triggerType) { + triggerType = + interaction.trigger === 'hover' || interaction.trigger === 'click' ? 'alternate' : 'once'; + } + + conditions = [ + ...new Set((conditions || []).filter((condition: string) => configConditions[condition])), + ]; + // resolving effects and cascading the conditions from sequence + const resolvedEffects = effects.map((effect) => { + if (!effect.conditions) { + effect.conditions = [...conditions]; + } else { + effect.conditions.push(...conditions); + } + return resolveEffectForCSS({ ...effect, triggerType }, interaction, config); + }); + + // resolving offsets + if (!(typeof offsetEasing === 'function')) { + offsetEasing = getJsEasing(offsetEasing) || ((x) => x); + } + calculateSequenceEffectsOffsets(resolvedEffects, delay, offset, offsetEasing); + + // removing unsupported effects and the whole sequence if all are unsupported + const filteredEffects = resolvedEffects.filter((effect) => effect !== null); + if (!filteredEffects.length) { + return null; + } + + return { + sequenceId, + triggerType, + conditions, + delay, + offset, + offsetEasing, + effects: filteredEffects, + }; +} diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index b521a287..fc5e3406 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -1,3 +1,5 @@ +import type { Interaction, ElementIdentifier, TimeAnimationTriggerType } from '../types'; + export function _processKeysForInterpolation(key: string) { return [...key.matchAll(/\[([-\w]+)]/g)].map(([_, _instanceKey]) => _instanceKey); } @@ -13,3 +15,30 @@ export function getInterpolatedKey(template: string, key: string) { }) : template; } + +export function shouldUseInitial( + interaction: Interaction, + effect: ElementIdentifier & { triggerType: TimeAnimationTriggerType }, +) { + return ( + interaction.trigger === 'viewEnter' && + effect.triggerType === 'once' && + getElementHash(interaction) === getElementHash(effect) + ); +} + +export function getElementHash(elementIdentifier: ElementIdentifier): string { + const { key, listContainer, listItemSelector, selector } = elementIdentifier; + return `${key}\0${listContainer || ''}\0${listItemSelector || ''}\0${selector || ''}`; +} + +export function getUniqueEncodedHash(hash: string): string { + let h1 = 0; + let h2 = 0; + for (let i = 0; i < hash.length; i++) { + const ch = hash.charCodeAt(i); + h1 = ((h1 << 5) - h1 + ch) | 0; + h2 = ((h2 << 3) ^ (h2 >>> 2) ^ ch) | 0; + } + return ((h1 >>> 0) * 0x100000 + ((h2 >>> 0) % 0x100000)).toString(36); +} diff --git a/packages/interact/src/handlers/viewProgress.ts b/packages/interact/src/handlers/viewProgress.ts index 54738951..478a35db 100644 --- a/packages/interact/src/handlers/viewProgress.ts +++ b/packages/interact/src/handlers/viewProgress.ts @@ -1,5 +1,10 @@ import type { AnimationGroup, ScrubScrollScene } from '@wix/motion'; -import { getWebAnimation, getScrubScene } from '@wix/motion'; +import { + getWebAnimation, + getElementCSSAnimation, + prepareAnimation, + getScrubScene, +} from '@wix/motion'; import { Scroll } from 'fizban'; import type { ViewEnterParams, ScrubEffect, HandlerObjectMap, InteractOptions } from '../types'; import { @@ -34,17 +39,31 @@ function addViewProgressHandler( const effectOptions = effectToAnimationOptions(effect); let cleanup; if ('ViewTimeline' in window) { - // Use ViewTimeline for modern browsers - const animationGroup = getWebAnimation(target, effectOptions, triggerParams); + const cssAnimation = getElementCSSAnimation(target, effectOptions); - if (animationGroup) { - animationGroup.play(); + if (cssAnimation) { + cssAnimation.ready = new Promise((resolve) => { + prepareAnimation(target, effectOptions, resolve); + }); + cssAnimation.play(); cleanup = () => { - (animationGroup as AnimationGroup).ready.then(() => { - animationGroup.cancel(); + cssAnimation.ready.then(() => { + cssAnimation.cancel(); }); }; + } else { + const animationGroup = getWebAnimation(target, effectOptions, triggerParams); + + if (animationGroup) { + animationGroup.play(); + + cleanup = () => { + (animationGroup as AnimationGroup).ready.then(() => { + animationGroup.cancel(); + }); + }; + } } } else { const scene = getScrubScene(target, effectOptions, triggerParams); diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index 94e95f67..a2d3f71d 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -1,5 +1,5 @@ import type { TriggerType, TriggerParams } from './triggers'; -import type { Effect, EffectRef, TimeAnimationTriggerType } from './effects'; +import type { Effect, EffectRef, EffectProperty, TimeAnimationTriggerType } from './effects'; export type Condition = { type: 'media' | 'container' | 'selector'; @@ -49,3 +49,28 @@ export type InteractConfig = { conditions?: Record; interactions: Interaction[]; }; + +export type ElementIdentifier = { + key: string; + listContainer?: string; + listItemSelector?: string; + selector?: string; +}; + +export type ResolvedEffect = ElementIdentifier & + EffectProperty & { + effectId: string; + conditions: string[]; + triggerType: TimeAnimationTriggerType; + initial: boolean; + }; + +export type ResolvedSequence = { + sequenceId: string; + triggerType: TimeAnimationTriggerType; + delay: number; + offset: number; + offsetEasing: (p: number) => number; + conditions: string[]; + effects: ResolvedEffect[]; +}; diff --git a/packages/interact/src/types/css.ts b/packages/interact/src/types/css.ts new file mode 100644 index 00000000..c1160b4b --- /dev/null +++ b/packages/interact/src/types/css.ts @@ -0,0 +1,27 @@ +export type ListPropertyName = + | 'animation' + | 'transition' + | 'animation-composition' + | 'animation-timeline' + | 'animation-range'; + +export type CSSCoordinatedLists = { + key: string; + childSelector?: string; + properties: Partial>; +}; + +export type ListCustomProps = { + key: string; + childSelector?: string; +} & Record; + +export type CSSRuleData = { + key: string; + childSelector?: string; + declarations: { name: string; value: string | number }[]; + media?: string; + states?: string[]; + selectorCondition?: string; + addInitialSelector?: boolean; +}; diff --git a/packages/interact/src/types/effects.ts b/packages/interact/src/types/effects.ts index 7a0d2eda..43dd3d21 100644 --- a/packages/interact/src/types/effects.ts +++ b/packages/interact/src/types/effects.ts @@ -93,3 +93,17 @@ export type Effect = EffectBase & (TimeEffect | ScrubEffect | StateEffect); export type AnimationOptions = MotionAnimationOptions & EffectEffectProperty; + +export type OneOf> = + | { + [K in keyof T]: Pick & Partial, never>>; + }[keyof T] + | Partial>; + +export type EffectProperty = OneOf<{ + namedEffect: NamedEffect; + keyframeEffect: MotionKeyframeEffect; + transition: TransitionOptions & { styleProperties: StyleProperty[] }; + transitionProperties: TransitionProperty[]; + customEffect: (element: Element, progress: any) => void; +}>; diff --git a/packages/interact/src/types/index.ts b/packages/interact/src/types/index.ts index fb197b7a..ddff07fc 100644 --- a/packages/interact/src/types/index.ts +++ b/packages/interact/src/types/index.ts @@ -3,5 +3,6 @@ export * from './effects'; export * from './config'; export * from './controller'; export * from './handlers'; +export * from './css'; export * from './internal'; import './global'; diff --git a/packages/interact/src/types/internal.ts b/packages/interact/src/types/internal.ts index 2fcb764d..f6c5f263 100644 --- a/packages/interact/src/types/internal.ts +++ b/packages/interact/src/types/internal.ts @@ -26,7 +26,7 @@ export type CreateTransitionCSSParams = { key: string; effectId: string; transition?: StateEffect['transition']; - properties?: TransitionProperty[]; + transitionProperties?: TransitionProperty[]; childSelector?: string; selectorCondition?: string; useFirstChild?: boolean; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 5afe1225..3fb39a26 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -1,5 +1,37 @@ import { getEasing } from '@wix/motion'; -import type { Condition, CreateTransitionCSSParams } from './types'; +import type { Condition, CreateTransitionCSSParams, StateEffect } from './types'; + +export function roundNumber(num: number, precision = 2): number { + return parseFloat(num.toFixed(precision)); +} + +export function isTemplatedKey(key: string) { + return /\[]/g.test(key); +} + +export function kebabCustomProp(args: (string | number)[]) { + return `--${args.join('-')}`; +} + +export function camelToKebabCase(property: string): string { + return property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); +} + +export function calculateSequenceEffectsOffsets( + effects: ((any & { delay?: number }) | null)[], + delay: number, + offset: number, + offsetEasing: (p: number) => number, +): void { + const maxIndex = effects.length - 1; + + effects.forEach((effect, index) => { + if (effect) { + const safeOffset = index ? (offsetEasing(index / maxIndex) * maxIndex * offset) | 0 : 0; + effect.delay = delay + safeOffset + (effect.delay || 0); + } + }); +} /** * Applies a selector condition predicate to a base selector. @@ -23,14 +55,8 @@ export function generateId() { ); } -export function createTransitionCSS({ - key, - effectId, - transition, - properties, - childSelector = '> :first-child', - selectorCondition, -}: CreateTransitionCSSParams): string[] { +export function transitionEffectToTransitionsList(transitionEffect: StateEffect) { + let { transition, transitionProperties } = transitionEffect; let transitions: string[] = []; if (transition?.styleProperties) { @@ -56,11 +82,9 @@ export function createTransitionCSS({ ); } } - - properties = transition.styleProperties; } else { transitions = - properties + transitionProperties ?.filter((property) => property.duration) .map( (property) => @@ -70,8 +94,28 @@ export function createTransitionCSS({ ) || []; } + return transitions; +} + +// TODO: createTransitionCSS overlaps with effectToCSS's transition branch and could be +// consolidated once the runtime path migrates to the CSS generation pipeline. +export function createTransitionCSS({ + key, + effectId, + transition, + transitionProperties, + childSelector = '> :first-child', + selectorCondition, +}: CreateTransitionCSSParams): string[] { + const transitions: string[] = transitionEffectToTransitionsList({ + transition, + transitionProperties, + }); + const styleProperties = - properties?.map((property) => `${property.name}: ${property.value};`) || []; + (transition?.styleProperties || transitionProperties)?.map( + (property) => `${property.name}: ${property.value};`, + ) || []; const escapedKey = key.replace(/"/g, "'"); // Build selectors, applying condition if present @@ -106,20 +150,28 @@ export function createTransitionCSS({ return result; } -export function getMediaQuery( +export function getFullPredicateByType( conditionNames: string[] | undefined, conditions: Record, + type: 'media' | 'container', ) { const conditionContent = (conditionNames || []) .filter((conditionName) => { - return conditions[conditionName]?.type === 'media' && conditions[conditionName].predicate; + return conditions[conditionName]?.type === type && conditions[conditionName].predicate; }) .map((conditionName) => { return conditions[conditionName].predicate; }) .join(') and ('); - const condition = conditionContent && `(${conditionContent})`; + return conditionContent && `(${conditionContent})`; +} + +export function getMediaQuery( + conditionNames: string[] | undefined, + conditions: Record, +) { + const condition = getFullPredicateByType(conditionNames, conditions, 'media'); const mql = condition && window.matchMedia(condition); return mql; @@ -129,11 +181,12 @@ export function getSelectorCondition( conditionNames: string[] | undefined, conditions: Record, ): string | undefined { - for (const name of conditionNames || []) { - const condition = conditions[name]; - if (condition?.type === 'selector' && condition.predicate) { - return condition.predicate; - } - } - return; + return (conditionNames || []) + .filter((conditionName) => { + return conditions[conditionName]?.type === 'selector' && conditions[conditionName].predicate; + }) + .map((conditionName) => { + return `:is(${conditions[conditionName].predicate})`; + }) + .join(''); } diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 8d05dea0..24cef88a 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { generate } from '../src/core/css'; -import { InteractConfig } from '../src/types'; +import { generate, _generate, DEFAULT_INITIAL } from '../src/core/css'; +import type { InteractConfig, CSSRuleData } from '../src/types'; describe('css.generate', () => { describe('filtering logic', () => { @@ -11,7 +11,7 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn', triggerType: 'once' }], + effects: [{ effectId: 'fadeIn', triggerType: 'once', namedEffect: { type: 'fadeIn' } }], }, ], }; @@ -29,7 +29,7 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; @@ -47,14 +47,16 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn', triggerType: 'repeat' }], + effects: [ + { effectId: 'fadeIn', triggerType: 'repeat', namedEffect: { type: 'fadeIn' } }, + ], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); it('should NOT generate CSS for viewEnter with type state', () => { @@ -64,14 +66,16 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn', triggerType: 'state' }], + effects: [ + { effectId: 'fadeIn', triggerType: 'state', namedEffect: { type: 'fadeIn' } }, + ], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); it('should NOT generate CSS for non-viewEnter triggers', () => { @@ -81,19 +85,19 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'click', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, { key: 'my-element-2', trigger: 'hover', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); }); @@ -106,7 +110,7 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; @@ -123,7 +127,7 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ key: 'my-element', effectId: 'fadeIn' }], + effects: [{ key: 'my-element', effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; @@ -140,14 +144,16 @@ describe('css.generate', () => { { key: 'source-element', trigger: 'viewEnter', - effects: [{ key: 'target-element', effectId: 'fadeIn' }], + effects: [ + { key: 'target-element', effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }, + ], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); }); @@ -159,7 +165,7 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; @@ -177,7 +183,9 @@ describe('css.generate', () => { key: 'my-element', trigger: 'viewEnter', selector: '.inner', - effects: [{ selector: '.inner', effectId: 'fadeIn' }], + effects: [ + { selector: '.inner', effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }, + ], }, ], }; @@ -195,14 +203,16 @@ describe('css.generate', () => { key: 'my-element', trigger: 'viewEnter', selector: '.source-inner', - effects: [{ selector: '.target-inner', effectId: 'fadeIn' }], + effects: [ + { selector: '.target-inner', effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }, + ], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); it('should NOT generate CSS when interaction has selector but effect does not', () => { @@ -213,14 +223,14 @@ describe('css.generate', () => { key: 'my-element', trigger: 'viewEnter', selector: '.inner', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); }); @@ -232,7 +242,7 @@ describe('css.generate', () => { { key: 'my-element', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }], + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], }, ], }; @@ -250,7 +260,9 @@ describe('css.generate', () => { key: 'my-element', trigger: 'viewEnter', listContainer: '.list', - effects: [{ listContainer: '.list', effectId: 'fadeIn' }], + effects: [ + { listContainer: '.list', effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }, + ], }, ], }; @@ -268,14 +280,20 @@ describe('css.generate', () => { key: 'my-element', trigger: 'viewEnter', listContainer: '.source-list', - effects: [{ listContainer: '.target-list', effectId: 'fadeIn' }], + effects: [ + { + listContainer: '.target-list', + effectId: 'fadeIn', + namedEffect: { type: 'fadeIn' }, + }, + ], }, ], }; const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); it('should generate CSS with listContainer and listItemSelector', () => { @@ -287,7 +305,14 @@ describe('css.generate', () => { trigger: 'viewEnter', listContainer: '.list', listItemSelector: 'li', - effects: [{ listContainer: '.list', listItemSelector: 'li', effectId: 'fadeIn' }], + effects: [ + { + listContainer: '.list', + listItemSelector: 'li', + effectId: 'fadeIn', + namedEffect: { type: 'fadeIn' }, + }, + ], }, ], }; @@ -311,6 +336,7 @@ describe('css.generate', () => { listContainer: '.gallery-grid', listItemSelector: '.caption', effectId: 'fadeIn', + namedEffect: { type: 'fadeIn' }, }, ], }, @@ -319,196 +345,1232 @@ describe('css.generate', () => { const result = generate(config); - expect(result).toBe(''); + expect(result).not.toContain('visibility: hidden'); }); }); + }); - describe('selectorCondition matching', () => { - it('should generate CSS when both have no conditions', () => { - const config: InteractConfig = { - effects: {}, - interactions: [ - { - key: 'my-element', - trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }], - }, - ], - }; + describe('multiple interactions/effects', () => { + it('should generate CSS for multiple matching interactions', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'element-1', + trigger: 'viewEnter', + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], + }, + { + key: 'element-2', + trigger: 'viewEnter', + effects: [{ effectId: 'slideIn', namedEffect: { type: 'slideIn' } }], + }, + ], + }; - const result = generate(config); + const result = generate(config); - expect(result).toContain('[data-interact-key="my-element"]'); - }); + expect(result).toContain('[data-interact-key="element-1"]'); + expect(result).toContain('[data-interact-key="element-2"]'); + }); - it('should generate CSS when effect condition matches interaction condition', () => { - const config: InteractConfig = { - effects: {}, - conditions: { - mobile: { type: 'selector', predicate: ':not(.desktop)' }, + it('should only generate CSS for matching effects, not all effects', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'my-element', + trigger: 'viewEnter', + effects: [ + { effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }, + { key: 'other-element', effectId: 'slideIn', namedEffect: { type: 'slideIn' } }, + ], }, - interactions: [ - { - key: 'my-element', - trigger: 'viewEnter', - conditions: ['mobile'], - effects: [{ conditions: ['mobile'], effectId: 'fadeIn' }], - }, - ], - }; + ], + }; - const result = generate(config); + const result = generate(config); - expect(result).toContain('[data-interact-key="my-element"]'); - expect(result).toContain(':not(.desktop)'); - }); + const matches = result.match(/visibility: hidden/g); + expect(matches).toHaveLength(1); + }); + }); +}); - it('should NOT generate CSS when effect condition differs from interaction condition', () => { - const config: InteractConfig = { - effects: {}, - conditions: { - mobile: { type: 'selector', predicate: ':not(.desktop)' }, - tablet: { type: 'selector', predicate: ':not(.phone)' }, +const isAnimationProp = (name: string) => /^--animation-\d/.test(name); +const isCompositionProp = (name: string) => /^--animation-composition-/.test(name); +const isTransitionProp = (name: string) => /^--transition-/.test(name); +const isTimelineProp = (name: string) => /^--animation-timeline-/.test(name); +const isRangeProp = (name: string) => /^--animation-range-/.test(name); + +function findDecl( + declarations: CSSRuleData['declarations'], + predicate: (d: { name: string; value: string | number }) => boolean, +) { + return declarations.find(predicate); +} + +describe('css._generate', () => { + describe('effectToCSS - namedEffect / keyframeEffect branch', () => { + it('should produce separate initial rule with DEFAULT_INITIAL for viewEnter + once + keyframeEffect', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + effectId: 'kf1', + duration: 500, + keyframeEffect: { + name: 'myAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, - interactions: [ - { - key: 'my-element', - trigger: 'viewEnter', - conditions: ['mobile'], - effects: [{ conditions: ['tablet'], effectId: 'fadeIn' }], - }, - ], - }; + ], + }; - const result = generate(config); + const { cssRules } = _generate(config); - expect(result).toBe(''); + const initialRule = cssRules.find((r) => r.addInitialSelector)!; + expect(initialRule).toBeDefined(); + + DEFAULT_INITIAL.forEach(({ name, value }) => { + const decl = initialRule.declarations.find((d) => d.name === name); + expect(decl, `expected DEFAULT_INITIAL declaration: ${name}`).toBeDefined(); + expect(decl!.value).toBe(value); }); - it('should apply condition with & replacement pattern', () => { - const config: InteractConfig = { - effects: {}, - conditions: { - hovered: { type: 'selector', predicate: '&:hover' }, + const animDeclOnInitial = findDecl(initialRule.declarations, (d) => isAnimationProp(d.name)); + expect(animDeclOnInitial).toBeDefined(); + expect(animDeclOnInitial!.value).toContain('myAnim'); + }); + + it('should inline animation declarations when initial is false (click trigger)', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 500, + keyframeEffect: { + name: 'myAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, - interactions: [ - { - key: 'my-element', - trigger: 'viewEnter', - conditions: ['hovered'], - effects: [{ conditions: ['hovered'], effectId: 'fadeIn' }], - }, - ], - }; + ], + }; - const result = generate(config); + const { cssRules } = _generate(config); - expect(result).toContain('[data-interact-key="my-element"]:hover'); - }); + expect(cssRules.every((r) => !r.addInitialSelector)).toBe(true); + + const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; + expect(effectRule).toBeDefined(); + }); + + it('should not produce an initial rule for namedEffect with non-viewEnter trigger', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'hover', + effects: [{ effectId: 'e1', duration: 300, namedEffect: { type: 'fadeIn' } }], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect(cssRules.every((r) => !r.addInitialSelector)).toBe(true); }); }); - describe('multiple interactions/effects', () => { - it('should generate CSS for multiple matching interactions', () => { + describe('effectToCSS - transition branch', () => { + it('should set transition custom prop for a transition effect', () => { const config: InteractConfig = { effects: {}, interactions: [ { - key: 'element-1', - trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }], + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'trans1', + transition: { + styleProperties: [{ name: 'opacity', value: '1' }], + duration: 500, + }, + }, + ], }, + ], + }; + + const { cssRules } = _generate(config); + + const effectRule = cssRules.find( + (r) => !r.states && r.declarations.some((d) => isTransitionProp(d.name)), + )!; + expect(effectRule).toBeDefined(); + + const transDecl = findDecl(effectRule.declarations, (d) => isTransitionProp(d.name)); + expect(transDecl).toBeDefined(); + expect(String(transDecl!.value)).toContain('opacity'); + expect(String(transDecl!.value)).toContain('500ms'); + }); + + it('should produce a state rule with direct declarations for transition', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ { - key: 'element-2', - trigger: 'viewEnter', - effects: [{ effectId: 'slideIn' }], + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'trans1', + transition: { + styleProperties: [{ name: 'opacity', value: '1' }], + duration: 500, + }, + }, + ], }, ], }; - const result = generate(config); + const { cssRules } = _generate(config); - expect(result).toContain('[data-interact-key="element-1"]'); - expect(result).toContain('[data-interact-key="element-2"]'); + const stateRule = cssRules.find((r) => r.states?.includes('trans1'))!; + expect(stateRule).toBeDefined(); + + const opacityDecl = stateRule.declarations.find((d) => d.name === 'opacity'); + expect(opacityDecl).toBeDefined(); + expect(opacityDecl!.value).toBe('1'); }); - it('should deduplicate CSS for multiple effects with same selector', () => { + it('should produce a state rule for transitionProperties (alternative syntax)', () => { const config: InteractConfig = { effects: {}, interactions: [ { - key: 'my-element', - trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }, { effectId: 'scaleIn' }], + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'trans2', + transitionProperties: [{ name: 'color', value: 'red', duration: 300 }], + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const stateRule = cssRules.find((r) => r.states?.includes('trans2'))!; + expect(stateRule).toBeDefined(); + + const colorDecl = stateRule.declarations.find((d) => d.name === 'color'); + expect(colorDecl).toBeDefined(); + expect(colorDecl!.value).toBe('red'); + }); + }); + + describe('effectToCSS - viewProgress (scroll-driven) branch', () => { + it('should set animation-timeline and animation-range custom props for viewProgress keyframeEffect', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [ + { + effectId: 'scroll1', + rangeStart: { name: 'entry', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'exit', offset: { value: 100, unit: 'percentage' } }, + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; + expect(effectRule).toBeDefined(); + + const timelineDecl = findDecl(effectRule.declarations, (d) => isTimelineProp(d.name)); + expect(timelineDecl).toBeDefined(); + expect(String(timelineDecl!.value)).toContain('--trigger-0'); + + const rangeDecl = findDecl(effectRule.declarations, (d) => isRangeProp(d.name)); + expect(rangeDecl).toBeDefined(); + expect(String(rangeDecl!.value)).toContain('entry'); + expect(String(rangeDecl!.value)).toContain('exit'); + }); + + it('should not produce an initial rule for viewProgress trigger', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [ + { + effectId: 'scroll1', + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect(cssRules.every((r) => !r.addInitialSelector)).toBe(true); + }); + + it('should emit auto duration in animation shorthand for viewProgress (SSR-safe)', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [ + { + effectId: 'scroll1', + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], }, ], }; const result = generate(config); - const matches = result.match(/@media \(prefers-reduced-motion: no-preference\)/g); - expect(matches).toHaveLength(1); + expect(result).toContain('parallax auto'); + expect(result).not.toContain('99.99ms'); }); - it('should only generate CSS for matching effects, not all effects', () => { + it('should include timeline and range in coordinated list when viewProgress and click target same element', () => { const config: InteractConfig = { effects: {}, interactions: [ { - key: 'my-element', + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + { + key: 'el', + trigger: 'viewProgress', + effects: [ + { + effectId: 'scroll1', + rangeStart: { name: 'entry', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'exit', offset: { value: 100, unit: 'percentage' } }, + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const coordListRule = cssRules.find( + (r) => + r.declarations.some((d) => d.name === 'animation-timeline') && + String(r.declarations.find((d) => d.name === 'animation-timeline')?.value).includes( + '), var(', + ), + ); + expect(coordListRule).toBeDefined(); + + const rangeListDecl = coordListRule!.declarations.find((d) => d.name === 'animation-range'); + expect(rangeListDecl).toBeDefined(); + expect(String(rangeListDecl!.value)).toContain('), var('); + }); + + it('should set timeline to none and range to normal for non-viewProgress keyframeEffect', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; + + const timelineDecl = findDecl(effectRule.declarations, (d) => isTimelineProp(d.name)); + expect(timelineDecl!.value).toBe('auto'); + + const rangeDecl = findDecl(effectRule.declarations, (d) => isRangeProp(d.name)); + expect(rangeDecl!.value).toBe('normal'); + }); + + it('should include timeline and range custom props on initial rule for viewEnter', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', trigger: 'viewEnter', - effects: [{ effectId: 'fadeIn' }, { key: 'other-element', effectId: 'slideIn' }], + effects: [ + { + effectId: 'kf1', + duration: 500, + keyframeEffect: { + name: 'myAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const initialRule = cssRules.find((r) => r.addInitialSelector)!; + expect(initialRule).toBeDefined(); + + const timelineDecl = findDecl(initialRule.declarations, (d) => isTimelineProp(d.name)); + expect(timelineDecl).toBeDefined(); + expect(timelineDecl!.value).toBe('auto'); + + const rangeDecl = findDecl(initialRule.declarations, (d) => isRangeProp(d.name)); + expect(rangeDecl).toBeDefined(); + expect(rangeDecl!.value).toBe('normal'); + }); + + it('should produce a view-timeline rule for viewProgress trigger', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [ + { + effectId: 'scroll1', + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const viewTimelineRule = cssRules.find((r) => + r.declarations.some((d) => d.name === 'view-timeline'), + ); + expect(viewTimelineRule).toBeDefined(); + expect(viewTimelineRule!.declarations.find((d) => d.name === 'view-timeline')!.value).toBe( + '--trigger-0', + ); + }); + + it('should use matching ids between view-timeline and animation-timeline for viewProgress', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + effects: [ + { + effectId: 'scroll1', + rangeStart: { name: 'entry', offset: { value: 0, unit: 'percentage' } }, + rangeEnd: { name: 'exit', offset: { value: 100, unit: 'percentage' } }, + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const viewTimelineRule = cssRules.find((r) => + r.declarations.some((d) => d.name === 'view-timeline'), + )!; + const triggerId = viewTimelineRule.declarations.find( + (d) => d.name === 'view-timeline', + )!.value; + + const effectRule = cssRules.find((r) => r.declarations.some((d) => isTimelineProp(d.name)))!; + const timelineValue = String( + effectRule.declarations.find((d) => isTimelineProp(d.name))!.value, + ); + expect(timelineValue).toContain(triggerId); + }); + + it('should not produce a view-timeline rule for non-viewProgress triggers', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const viewTimelineRule = cssRules.find((r) => + r.declarations.some((d) => d.name === 'view-timeline'), + ); + expect(viewTimelineRule).toBeUndefined(); + }); + + it('should propagate conditions to view-timeline rule for viewProgress', () => { + const config: InteractConfig = { + effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + }, + interactions: [ + { + key: 'el', + trigger: 'viewProgress', + conditions: ['desktop'], + effects: [ + { + effectId: 'scroll1', + keyframeEffect: { + name: 'parallax', + keyframes: [ + { transform: 'translateY(50px)' }, + { transform: 'translateY(-50px)' }, + ], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const viewTimelineRule = cssRules.find((r) => + r.declarations.some((d) => d.name === 'view-timeline'), + )!; + expect(viewTimelineRule).toBeDefined(); + expect(viewTimelineRule.media).toContain('min-width: 1024px'); + }); + }); + + describe('effectToCSS - no effect property', () => { + it('should set all custom properties to off values when effect has no animation or transition', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ effectId: 'empty1' }], + }, + ], + }; + + const { cssRules } = _generate(config); + + const effectRule = cssRules.find( + (r) => + r.declarations.some((d) => isAnimationProp(d.name) && d.value === 'none') && + r.declarations.some((d) => isCompositionProp(d.name) && d.value === 'replace'), + ); + expect(effectRule).toBeDefined(); + + const timelineDecl = findDecl(effectRule!.declarations, (d) => isTimelineProp(d.name)); + expect(timelineDecl).toBeDefined(); + expect(timelineDecl!.value).toBe('auto'); + + const rangeDecl = findDecl(effectRule!.declarations, (d) => isRangeProp(d.name)); + expect(rangeDecl).toBeDefined(); + expect(rangeDecl!.value).toBe('normal'); + }); + + it('should produce no keyframes for an effect with no animation', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ effectId: 'empty1' }], }, ], }; const result = generate(config); - const matches = result.match(/@media \(prefers-reduced-motion: no-preference\)/g); - expect(matches).toHaveLength(1); + expect(result).not.toContain('@keyframes'); }); }); - describe('effectId resolution', () => { - it('should resolve effect data from config.effects when effectId is provided', () => { + describe('conditions flowing through to rules', () => { + it('should set media on rules when effect has a media condition', () => { + const config: InteractConfig = { + effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + conditions: ['desktop'], + keyframeEffect: { + name: 'fadeAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; + + expect(effectRule.media).toContain('min-width: 1024px'); + }); + + it('should set selectorCondition on rules when effect has a selector condition', () => { const config: InteractConfig = { - effects: { - customFade: { - selector: '.content', - effectId: 'customFade', + effects: {}, + conditions: { + visible: { type: 'selector', predicate: '.is-visible' }, + }, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + conditions: ['visible'], + keyframeEffect: { + name: 'fadeAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, + ], + }; + + const { cssRules } = _generate(config); + const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; + + expect(effectRule.selectorCondition).toContain('.is-visible'); + }); + + it('should set both media and selectorCondition when both condition types are present', () => { + const config: InteractConfig = { + effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + visible: { type: 'selector', predicate: '.is-visible' }, }, interactions: [ { - key: 'my-element', - trigger: 'viewEnter', - selector: '.content', - effects: [{ effectId: 'customFade' }], + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + conditions: ['desktop', 'visible'], + keyframeEffect: { + name: 'fadeAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, ], }; - const result = generate(config); + const { cssRules } = _generate(config); + const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; - expect(result).toContain('[data-interact-key="my-element"] .content'); + expect(effectRule.media).toContain('min-width: 1024px'); + expect(effectRule.selectorCondition).toContain('.is-visible'); }); - it('should use effect properties directly when effectId is not in config.effects', () => { + it('should propagate media condition to the state rule for transitions', () => { const config: InteractConfig = { effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + }, interactions: [ { - key: 'my-element', + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'trans1', + conditions: ['desktop'], + transition: { + styleProperties: [{ name: 'opacity', value: '1' }], + duration: 500, + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const stateRule = cssRules.find((r) => r.states?.includes('trans1'))!; + + expect(stateRule.media).toContain('min-width: 1024px'); + }); + + it('should propagate conditions to the initial rule for viewEnter + once', () => { + const config: InteractConfig = { + effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + }, + interactions: [ + { + key: 'el', trigger: 'viewEnter', - selector: '.content', - effects: [{ effectId: 'unknownEffect', selector: '.content' }], + effects: [ + { + effectId: 'kf1', + duration: 300, + conditions: ['desktop'], + keyframeEffect: { + name: 'fadeAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + const initialRule = cssRules.find((r) => r.addInitialSelector)!; + + expect(initialRule).toBeDefined(); + expect(initialRule.media).toContain('min-width: 1024px'); + }); + }); + + describe('sequences', () => { + it('should produce unique custom prop names per effect in a sequence', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + ], + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const animRules = cssRules.filter( + (r) => + r.declarations.some((d) => isAnimationProp(d.name)) && + !r.declarations.some((d) => String(d.value).includes('var(')), + ); + expect(animRules.length).toBeGreaterThanOrEqual(2); + + const animPropNames = animRules.flatMap((r) => + r.declarations.filter((d) => isAnimationProp(d.name)).map((d) => d.name), + ); + const uniqueNames = new Set(animPropNames); + expect(uniqueNames.size).toBe(animPropNames.length); + + const timelinePropNames = animRules.flatMap((r) => + r.declarations.filter((d) => isTimelineProp(d.name)).map((d) => d.name), + ); + expect(new Set(timelinePropNames).size).toBe(timelinePropNames.length); + + const rangePropNames = animRules.flatMap((r) => + r.declarations.filter((d) => isRangeProp(d.name)).map((d) => d.name), + ); + expect(new Set(rangePropNames).size).toBe(rangePropNames.length); + }); + + it('should add a coordinated-list rule per target in a sequence', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + ], + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const seqCoordListRule = cssRules.find((r) => + r.declarations.some((d) => isAnimationProp(d.name) && String(d.value).includes('var(')), + ); + expect(seqCoordListRule).toBeDefined(); + + const timelineDecl = seqCoordListRule!.declarations.find( + (d) => isTimelineProp(d.name) && String(d.value).includes('var('), + ); + expect(timelineDecl).toBeDefined(); + + const rangeDecl = seqCoordListRule!.declarations.find( + (d) => isRangeProp(d.name) && String(d.value).includes('var('), + ); + expect(rangeDecl).toBeDefined(); + }); + + it('should apply sequence-level conditions to the coordinated-list rule', () => { + const config: InteractConfig = { + effects: {}, + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + }, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + conditions: ['desktop'], + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const seqListRules = cssRules.filter( + (r) => + r.declarations.some((d) => String(d.value).includes('var(')) && + r.declarations.some( + (d) => isAnimationProp(d.name) || isCompositionProp(d.name) || isTransitionProp(d.name), + ), + ); + const conditionedRule = seqListRules.find((r) => r.media); + expect(conditionedRule).toBeDefined(); + expect(conditionedRule!.media).toContain('min-width: 1024px'); + }); + }); + + describe('cross-interaction coordinated lists', () => { + it('should produce coordinated list with two custom properties when two interactions target the same element', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + { + key: 'el', + trigger: 'hover', + effects: [ + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const coordListRule = cssRules.find( + (r) => + r.declarations.some((d) => d.name === 'animation') && + String(r.declarations.find((d) => d.name === 'animation')?.value).includes('), var('), + ); + expect(coordListRule).toBeDefined(); + + const timelineDecl = coordListRule!.declarations.find((d) => d.name === 'animation-timeline'); + expect(timelineDecl).toBeDefined(); + expect(String(timelineDecl!.value)).toContain('), var('); + + const rangeDecl = coordListRule!.declarations.find((d) => d.name === 'animation-range'); + expect(rangeDecl).toBeDefined(); + expect(String(rangeDecl!.value)).toContain('), var('); + }); + + it('should produce separate coordinated-list rules for different targets', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el-a', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + { + key: 'el-b', + trigger: 'click', + effects: [ + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const coordListRules = cssRules.filter( + (r) => + r.declarations.some((d) => d.name === 'animation') && + String(r.declarations.find((d) => d.name === 'animation')?.value).includes('var('), + ); + expect(coordListRules.length).toBe(2); + + const keys = coordListRules.map((r) => r.key); + expect(keys).toContain('el-a'); + expect(keys).toContain('el-b'); + }); + }); + + describe('_generate endpoint edge cases', () => { + it('should return empty cssRules for empty interactions array', () => { + const config: InteractConfig = { + effects: {}, + interactions: [], + }; + + const { cssRules, keyframes } = _generate(config); + + expect(cssRules).toEqual([]); + expect(keyframes).toEqual(new Map()); + }); + + it('should produce no rules for interaction with empty effects and sequences', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [], + sequences: [], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect(cssRules).toEqual([]); + }); + + it('should use > :first-child child selector when useFirstChild is true (default)', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ effectId: 'e1' }], + }, + ], + }; + + const { cssRules } = _generate(config, true); + + const ruleWithChild = cssRules.find((r) => r.childSelector === '> :first-child'); + expect(ruleWithChild).toBeDefined(); + }); + + it('should not use > :first-child child selector when useFirstChild is false', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ effectId: 'e1' }], + }, + ], + }; + + const { cssRules } = _generate(config, false); + + const ruleWithFirstChild = cssRules.find((r) => r.childSelector === '> :first-child'); + expect(ruleWithFirstChild).toBeUndefined(); + }); + + it('should include rules from both effects and sequences in the same interaction', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'directAnim', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + sequences: [ + { + effects: [ + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'seqAnim', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + ], + }, + ], }, ], }; const result = generate(config); - expect(result).toContain('[data-interact-key="my-element"] .content'); + expect(result).toContain('directAnim'); + expect(result).toContain('seqAnim'); + }); + + it('should use same custom prop names for multiple effects on the same target in one interaction (cascade override)', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const effectRules = cssRules.filter((r) => + r.declarations.some((d) => isAnimationProp(d.name)), + ); + expect(effectRules.length).toBeGreaterThanOrEqual(2); + + const animPropNames = effectRules.map( + (r) => r.declarations.find((d) => isAnimationProp(d.name))!.name, + ); + expect(new Set(animPropNames).size).toBe(1); + + const coordListRules = cssRules.filter( + (r) => + r.declarations.some((d) => d.name === 'animation') && + String(r.declarations.find((d) => d.name === 'animation')?.value).includes('var('), + ); + expect(coordListRules).toHaveLength(1); }); }); }); diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts new file mode 100644 index 00000000..c62f0cb2 --- /dev/null +++ b/packages/interact/test/cssUtils.spec.ts @@ -0,0 +1,355 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + keyframePropertyToCSS, + interpolateKeyframesOffsets, + keyframeObjectToKeyframeCSS, + keyframesToCSS, + CSSRuleToString, + buildListsRule, +} from '../src/core/cssUtils'; +import type { CSSCoordinatedLists, ListCustomProps, CSSRuleData } from '../types'; + +describe('keyframePropertyToCSS', () => { + it('should convert cssFloat to float', () => { + expect(keyframePropertyToCSS('cssFloat')).toBe('float'); + }); + + it('should convert easing to animation-timing-function', () => { + expect(keyframePropertyToCSS('easing')).toBe('animation-timing-function'); + }); + + it('should convert cssOffset to offset', () => { + expect(keyframePropertyToCSS('cssOffset')).toBe('offset'); + }); + + it('should convert composite to animation-composition', () => { + expect(keyframePropertyToCSS('composite')).toBe('animation-composition'); + }); + + it('should convert camelCase to kebab-case', () => { + expect(keyframePropertyToCSS('backgroundColor')).toBe('background-color'); + expect(keyframePropertyToCSS('borderTopWidth')).toBe('border-top-width'); + expect(keyframePropertyToCSS('marginLeft')).toBe('margin-left'); + }); + + it('should leave lowercase properties unchanged', () => { + expect(keyframePropertyToCSS('opacity')).toBe('opacity'); + expect(keyframePropertyToCSS('color')).toBe('color'); + expect(keyframePropertyToCSS('transform')).toBe('transform'); + }); +}); + +describe('interpolateKeyframesOffsets', () => { + it('should return empty array for empty input', () => { + expect(interpolateKeyframesOffsets([])).toEqual([]); + }); + + it('should set first offset to 0 and last to 1 when missing', () => { + const result = interpolateKeyframesOffsets([{ opacity: '0' }, { opacity: '1' }]); + expect(result[0].offset).toBe(0); + expect(result[1].offset).toBe(1); + }); + + it('should preserve existing offsets', () => { + const result = interpolateKeyframesOffsets([ + { offset: 0.2, opacity: '0' }, + { offset: 0.8, opacity: '1' }, + ]); + expect(result[0].offset).toBe(0.2); + expect(result[1].offset).toBe(0.8); + }); + + it('should interpolate undefined offsets between defined ones', () => { + const result = interpolateKeyframesOffsets([ + { offset: 0, opacity: '0' }, + { opacity: '0.5' }, + { offset: 1, opacity: '1' }, + ]); + expect(result[0].offset).toBe(0); + expect(result[1].offset).toBe(0.5); + expect(result[2].offset).toBe(1); + }); + + it('should interpolate multiple undefined offsets evenly', () => { + const result = interpolateKeyframesOffsets([ + { offset: 0, opacity: '0' }, + { opacity: '0.25' }, + { opacity: '0.5' }, + { opacity: '0.75' }, + { offset: 1, opacity: '1' }, + ]); + expect(result[1].offset).toBeCloseTo(0.25); + expect(result[2].offset).toBeCloseTo(0.5); + expect(result[3].offset).toBeCloseTo(0.75); + }); + + it('should handle a single keyframe (first-wins: offset becomes 0)', () => { + const result = interpolateKeyframesOffsets([{ opacity: '1' }]); + expect(result).toHaveLength(1); + expect(result[0].offset).toBe(0); + }); + + it('should return empty array and log error for decreasing offsets', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = interpolateKeyframesOffsets([ + { offset: 0.8, opacity: '0' }, + { offset: 0.2, opacity: '1' }, + ]); + expect(result).toEqual([]); + expect(spy).toHaveBeenCalledWith('Offsets must be monotonically non-decreasing'); + spy.mockRestore(); + }); + + it('should return empty array and log error for offset > 1', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = interpolateKeyframesOffsets([ + { offset: 0, opacity: '0' }, + { offset: 1.5, opacity: '1' }, + ]); + expect(result).toEqual([]); + expect(spy).toHaveBeenCalledWith('Offsets must be in the range [0,1]'); + spy.mockRestore(); + }); + + it('should not mutate the original keyframes', () => { + const original = [{ opacity: '0' }, { opacity: '1' }]; + interpolateKeyframesOffsets(original); + expect(original[0]).not.toHaveProperty('offset'); + expect(original[1]).not.toHaveProperty('offset'); + }); +}); + +describe('keyframeObjectToKeyframeCSS', () => { + it('should convert a keyframe object to a CSS block at the given percentage', () => { + const result = keyframeObjectToKeyframeCSS({ opacity: '0', transform: 'scale(0.5)' }, 0); + const expected1 = '0% {\nopacity: 0;\ntransform: scale(0.5);\n}'; + const expected2 = '0% {\ntransform: scale(0.5);\nopacity: 0;\n}'; + expect(result === expected1 || result === expected2).toBe(true); + }); + + it('should filter out the offset property', () => { + const result = keyframeObjectToKeyframeCSS({ offset: 0, opacity: '1' }, 0); + expect(result).not.toContain('offset'); + }); + + it('should filter out undefined and null values', () => { + const result = keyframeObjectToKeyframeCSS( + { opacity: '1', transform: undefined, color: null }, + 50, + ); + const expected = '50% {\nopacity: 1;\n}'; + expect(result).toEqual(expected); + }); + + it('should convert camelCase properties to kebab-case', () => { + const result = keyframeObjectToKeyframeCSS({ backgroundColor: 'red' }, 100); + expect(result).toContain('background-color: red;'); + }); +}); + +describe('keyframesToCSS', () => { + it('should generate a full @keyframes block', () => { + const result = keyframesToCSS('fadeIn', [ + { offset: 0, opacity: '0' }, + { offset: 1, opacity: '1' }, + ]); + const expected = '@keyframes fadeIn {\n0% {\nopacity: 0;\n}\n100% {\nopacity: 1;\n}\n}'; + expect(result).toEqual(expected); + }); + + it('should return empty string for empty keyframes', () => { + expect(keyframesToCSS('empty', [])).toBe(''); + }); + + it('should return empty string when interpolation fails', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = keyframesToCSS('bad', [ + { offset: 0.8, opacity: '0' }, + { offset: 0.2, opacity: '1' }, + ]); + expect(result).toBe(''); + spy.mockRestore(); + }); + + it('should interpolate offsets and convert to percentages', () => { + const result = keyframesToCSS('slide', [ + { transform: 'translateX(-100px)' }, + { transform: 'translateX(0)' }, + ]); + expect(result).toContain('0% {'); + expect(result).toContain('100% {'); + }); +}); + +describe('CSSRuleToString', () => { + it('should generate a simple rule with key and declarations', () => { + const rule: CSSRuleData = { + key: 'my-el', + declarations: [{ name: 'opacity', value: '0' }], + }; + const result = CSSRuleToString(rule); + const expected = '[data-interact-key="my-el"] {\nopacity: 0;\n}'; + expect(result).toEqual(expected); + }); + + it('should return empty string when declarations are empty', () => { + const rule: CSSRuleData = { key: 'my-el', declarations: [] }; + expect(CSSRuleToString(rule)).toBe(''); + }); + + it('should append childSelector', () => { + const rule: CSSRuleData = { + key: 'my-el', + childSelector: '.inner', + declarations: [{ name: 'color', value: 'red' }], + }; + const expected = '[data-interact-key="my-el"] .inner {\ncolor: red;\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + + it('should add :is(:not([data-interact-enter])) when addInitialSelector is true', () => { + const rule: CSSRuleData = { + key: 'my-el', + addInitialSelector: true, + declarations: [{ name: 'opacity', value: '0' }], + }; + const expected = + '[data-interact-key="my-el"]:is(:not([data-interact-enter])) {\nopacity: 0;\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + + it('should add state selectors when states are provided', () => { + const rule: CSSRuleData = { + key: 'my-el', + states: ['active'], + declarations: [{ name: 'opacity', value: '1' }], + }; + const expected = + '[data-interact-key="my-el"]:is(:state(active), :--active, [data-interact-effect~="active"]) {\nopacity: 1;\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + + it('should apply selectorCondition', () => { + const rule: CSSRuleData = { + key: 'my-el', + selectorCondition: ':is(.visible)', + declarations: [{ name: 'opacity', value: '1' }], + }; + const expected = '[data-interact-key="my-el"]:is(.visible) {\nopacity: 1;\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + + it('should wrap in @media when media is provided', () => { + const rule: CSSRuleData = { + key: 'my-el', + media: '(min-width: 768px)', + declarations: [{ name: 'display', value: 'block' }], + }; + const expected = + '@media (min-width: 768px) {\n[data-interact-key="my-el"] {\ndisplay: block;\n}\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); + + it('should combine all options together', () => { + const rule: CSSRuleData = { + key: 'my-el', + childSelector: '.child', + addInitialSelector: true, + states: ['hover'], + media: '(min-width: 1024px)', + declarations: [ + { name: 'opacity', value: '1' }, + { name: 'color', value: 'blue' }, + ], + }; + const expected = + '@media (min-width: 1024px) {\n[data-interact-key="my-el"]:is(:not([data-interact-enter])):is(:state(hover), :--hover, [data-interact-effect~="hover"]) .child {\nopacity: 1;\ncolor: blue;\n}\n}'; + expect(CSSRuleToString(rule)).toEqual(expected); + }); +}); + +describe('buildListsRule', () => { + const baseLists: CSSCoordinatedLists = { + key: 'my-el', + properties: { + animation: { + fallback: 'none', + varNames: ['--anim-1', '--anim-2'], + }, + transition: { + fallback: '_', + varNames: ['--trans-1'], + }, + 'animation-composition': { + fallback: 'replace', + varNames: ['--comp-1'], + }, + }, + }; + + it('should build a rule with var() declarations for each prop', () => { + const rule = buildListsRule(baseLists); + expect(rule.key).toBe('my-el'); + expect(rule.declarations).toHaveLength(3); + + const animDecl = rule.declarations.find((d) => d.name === 'animation'); + expect(animDecl?.value).toBe('var(--anim-1, none), var(--anim-2, none)'); + + const compositionDecl = rule.declarations.find((d) => d.name === 'animation-composition'); + expect(compositionDecl?.value).toBe('var(--comp-1, replace)'); + + const transDecl = rule.declarations.find((d) => d.name === 'transition'); + expect(transDecl?.value).toBe('var(--trans-1, _)'); + }); + + it('should include childSelector when present', () => { + const lists: CSSCoordinatedLists = { ...baseLists, childSelector: '.target' }; + const rule = buildListsRule(lists); + expect(rule.childSelector).toBe('.target'); + }); + + it('should rename declarations when customProps mapping is provided', () => { + const customProps = { + key: 'my-el', + childSelector: undefined, + animation: '--my-anim', + transition: '--my-trans', + 'animation-composition': '--my-comp', + } as ListCustomProps; + + const rule = buildListsRule(baseLists, customProps); + const names = rule.declarations.map((d) => d.name); + expect(names).toContain('--my-anim'); + expect(names).toContain('--my-trans'); + expect(names).toContain('--my-comp'); + expect(names).not.toContain('animation'); + expect(names).not.toContain('transition'); + expect(names).not.toContain('animation-composition'); + }); + + it('should add media condition when conditions with media type are provided', () => { + const conditions = ['desktop']; + const configConditions = { + desktop: { type: 'media' as const, predicate: 'min-width: 1024px' }, + }; + const rule = buildListsRule(baseLists, undefined, conditions, configConditions); + expect(rule.media).toBe('(min-width: 1024px)'); + expect(rule.selectorCondition).toBeFalsy(); + }); + + it('should add selectorCondition when conditions with selector type are provided', () => { + const conditions = ['visible']; + const configConditions = { + visible: { type: 'selector' as const, predicate: '.is-visible' }, + }; + const rule = buildListsRule(baseLists, undefined, conditions, configConditions); + expect(rule.selectorCondition).toBe(':is(.is-visible)'); + expect(rule.media).toBeFalsy(); + }); + + it('should have no media or selectorCondition when no conditions are given', () => { + const rule = buildListsRule(baseLists); + expect(rule.media).toBeUndefined(); + expect(rule.selectorCondition).toBeUndefined(); + }); +}); diff --git a/packages/interact/test/mini.spec.ts b/packages/interact/test/mini.spec.ts index 6e60604f..4cf13683 100644 --- a/packages/interact/test/mini.spec.ts +++ b/packages/interact/test/mini.spec.ts @@ -22,6 +22,8 @@ vi.mock('@wix/motion', () => { playState: 'idle', ready: Promise.resolve(), }), + getElementCSSAnimation: vi.fn().mockReturnValue(null), + prepareAnimation: vi.fn(), getScrubScene: vi.fn().mockReturnValue({}), getEasing: vi.fn().mockImplementation((v) => v), getAnimation: vi.fn().mockImplementation((target, options, trigger, reducedMotion) => { diff --git a/packages/interact/test/resolvers.spec.ts b/packages/interact/test/resolvers.spec.ts new file mode 100644 index 00000000..b132549a --- /dev/null +++ b/packages/interact/test/resolvers.spec.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from 'vitest'; +import { resolveEffectForCSS, resolveSequenceForCSS } from '../src/core/resolvers'; +import type { TriggerType, Effect, EffectRef } from '../src/types'; + +const EMPTY_CONFIG = { + effects: {}, + interactions: [], +}; +const BASE_INTERACTION = { + key: 'interactionKey', + trigger: 'viewEnter' as const, +}; +const BASE_CONDITION = { + type: 'media' as const, + predicate: '(min-width: 100px)', +}; +const BASE_SEQUENCE = { effects: [{}] }; + +describe('css resolvers', () => { + describe('effect', () => { + describe('key', () => { + it('should use effect key if exists', () => { + const result = resolveEffectForCSS({ key: 'effectKey' }, BASE_INTERACTION, EMPTY_CONFIG); + expect(result?.key).toBe('effectKey'); + }); + it('should return null if key is a template', () => { + expect(resolveEffectForCSS({ key: 'key[]' }, BASE_INTERACTION, EMPTY_CONFIG)).toBeNull(); + }); + it('should inherit key from interaction if does not exist on effect', () => { + const result = resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG); + expect(result?.key).toBe(BASE_INTERACTION.key); + }); + it('should return null if both interaction and effect have no key', () => { + expect(resolveEffectForCSS({}, { ...BASE_INTERACTION, key: '' }, EMPTY_CONFIG)).toBeNull(); + }); + }); + + describe('ElementIdentifier', () => { + it('should use selector refinements from effect', () => { + const result = resolveEffectForCSS( + { + key: 'effectKey', + selector: '.selector', + listContainer: '.listContainer', + listItemSelector: '.itemSelector', + } as Effect, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect(result).toMatchObject({ + selector: '.selector', + listContainer: '.listContainer', + listItemSelector: '.itemSelector', + }); + }); + }); + + describe('effectId', () => { + it('should use data from referenced effect if effectId exists', () => { + const result = resolveEffectForCSS({ effectId: 'effectId' }, BASE_INTERACTION, { + interactions: [], + effects: { effectId: { namedEffect: { type: 'FadeIn' } } }, + }); + expect(result).toMatchObject({ effectId: 'effectId' }); + expect(result).toHaveProperty('namedEffect'); + }); + it('should generate id if effectId does not exist', () => { + expect(resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG)?.effectId).toBeTruthy(); + }); + }); + + describe('conditions', () => { + it('should create empty array if conditions is undefined', () => { + expect(resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG)?.conditions).toEqual([]); + }); + it('should filter duplications from conditions', () => { + const result = resolveEffectForCSS( + { conditions: ['condition', 'condition'] }, + BASE_INTERACTION, + { interactions: [], effects: {}, conditions: { condition: BASE_CONDITION } }, + ); + expect(result?.conditions).toEqual(['condition']); + }); + it('should filter non-existing condition names from conditions', () => { + const result = resolveEffectForCSS( + { conditions: ['condition', 'garbage'] }, + BASE_INTERACTION, + { interactions: [], effects: {}, conditions: { condition: BASE_CONDITION } }, + ); + expect(result?.conditions).toEqual(['condition']); + }); + }); + + describe('triggerType', () => { + it('should default to once for viewEnter trigger', () => { + expect(resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG)?.triggerType).toBe('once'); + }); + it('should default to alternate for hover trigger', () => { + expect( + resolveEffectForCSS({}, { ...BASE_INTERACTION, trigger: 'hover' }, EMPTY_CONFIG) + ?.triggerType, + ).toBe('alternate'); + }); + it('should default to alternate for click trigger', () => { + expect( + resolveEffectForCSS({}, { ...BASE_INTERACTION, trigger: 'click' }, EMPTY_CONFIG) + ?.triggerType, + ).toBe('alternate'); + }); + it('should respect explicit triggerType regardless of trigger', () => { + expect( + resolveEffectForCSS( + { triggerType: 'repeat' } as unknown as Effect, + { ...BASE_INTERACTION, trigger: 'hover' }, + EMPTY_CONFIG, + )?.triggerType, + ).toBe('repeat'); + }); + }); + + describe('initial', () => { + it('should be false when trigger is not viewEnter', () => { + const triggers: TriggerType[] = ['click', 'hover', 'viewProgress', 'pointerMove']; + triggers.forEach((trigger) => { + expect( + resolveEffectForCSS({}, { ...BASE_INTERACTION, trigger }, EMPTY_CONFIG)?.initial, + ).toBe(false); + }); + }); + it('should be false when trigger is viewEnter and type is different than once', () => { + expect( + resolveEffectForCSS( + { triggerType: 'repeat' } as unknown as Effect, + BASE_INTERACTION, + EMPTY_CONFIG, + )?.initial, + ).toBe(false); + }); + it('should be true when trigger is viewEnter and type is once', () => { + expect( + resolveEffectForCSS( + { triggerType: 'once' } as unknown as Effect, + BASE_INTERACTION, + EMPTY_CONFIG, + )?.initial, + ).toBe(true); + }); + it('should be true when trigger is viewEnter and type is undefined (default to once)', () => { + expect(resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG)?.initial).toBe(true); + }); + }); + + describe('EffectProperty', () => { + it('should have exactly one type of effect property if more are provided', () => { + const result = resolveEffectForCSS( + { + namedEffect: { type: 'FadeIn' }, + keyframeEffect: { name: 'kf', keyframes: [{}] }, + customEffect: () => {}, + transition: { styleProperties: [] }, + transitionProperties: [], + }, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect(result).toHaveProperty('namedEffect'); + expect(result).not.toHaveProperty('keyframeEffect'); + expect(result).not.toHaveProperty('customEffect'); + expect(result).not.toHaveProperty('transition'); + expect(result).not.toHaveProperty('transitionProperties'); + }); + it('should return no effect property if none are provided', () => { + const result = resolveEffectForCSS({}, BASE_INTERACTION, EMPTY_CONFIG); + expect(result).not.toHaveProperty('namedEffect'); + expect(result).not.toHaveProperty('keyframeEffect'); + expect(result).not.toHaveProperty('customEffect'); + expect(result).not.toHaveProperty('transition'); + expect(result).not.toHaveProperty('transitionProperties'); + }); + it('should return null for namedEffect with no type', () => { + expect( + resolveEffectForCSS( + { namedEffect: {} } as unknown as Effect, + BASE_INTERACTION, + EMPTY_CONFIG, + ), + ).toBeNull(); + }); + it('should return null for pointerMove with namedEffect', () => { + expect( + resolveEffectForCSS( + { namedEffect: { type: 'BlurMouse' } }, + { ...BASE_INTERACTION, trigger: 'pointerMove' }, + EMPTY_CONFIG, + ), + ).toBeNull(); + }); + it('should return null for pointerMove with customEffect', () => { + expect( + resolveEffectForCSS( + { customEffect: () => {} }, + { ...BASE_INTERACTION, trigger: 'pointerMove' }, + EMPTY_CONFIG, + ), + ).toBeNull(); + }); + it('should use effectId as keyframes name if name does not exist and has reference', () => { + const result = resolveEffectForCSS({ effectId: 'effectId' }, BASE_INTERACTION, { + interactions: [], + effects: { + effectId: { keyframeEffect: { name: '', keyframes: [{}] } }, + }, + }); + expect(result).toMatchObject({ keyframeEffect: { name: 'effectId' } }); + }); + it('should use effectId as keyframes name if name does not exist and has no reference', () => { + const result = resolveEffectForCSS( + { effectId: 'effectId', keyframeEffect: { name: '', keyframes: [{}] } }, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect(result).toMatchObject({ keyframeEffect: { name: 'effectId' } }); + }); + it('should generate new name for keyframes if name does not exist and referenced keyframes are overrided', () => { + const result = resolveEffectForCSS( + { effectId: 'effectId', keyframeEffect: { name: '', keyframes: [{}] } }, + BASE_INTERACTION, + { + interactions: [], + effects: { + effectId: { keyframeEffect: { name: 'orig', keyframes: [{}] } }, + }, + }, + ); + expect(result?.keyframeEffect?.name).toBeTruthy(); + expect(result?.keyframeEffect?.name).not.toEqual('orig'); + }); + }); + }); + + describe('sequence', () => { + describe('sequenceId', () => { + it('should use data from referenced sequence if sequenceId exists', () => { + const result = resolveSequenceForCSS({ sequenceId: 'sequenceId' }, BASE_INTERACTION, { + interactions: [], + effects: {}, + sequences: { + sequenceId: { sequenceId: 'sequenceId', effects: [{}], delay: 100 }, + }, + }); + expect(result).toMatchObject({ sequenceId: 'sequenceId', effects: [{}], delay: 100 }); + }); + it('should generate id if sequenceId does not exist', () => { + expect( + resolveSequenceForCSS(BASE_SEQUENCE, BASE_INTERACTION, EMPTY_CONFIG)?.sequenceId, + ).toBeTruthy(); + }); + }); + + describe('delay, offset, offsetEasing', () => { + it('should default to 0, 0, linear(function)', () => { + const result = resolveSequenceForCSS(BASE_SEQUENCE, BASE_INTERACTION, EMPTY_CONFIG); + expect(result).toMatchObject({ delay: 0, offset: 0 }); + const randomVal = Math.random(); + expect(result?.offsetEasing(randomVal)).toBe(randomVal); + }); + }); + + describe('triggerType', () => { + it('should default to once for viewEnter trigger', () => { + expect( + resolveSequenceForCSS(BASE_SEQUENCE, BASE_INTERACTION, EMPTY_CONFIG)?.triggerType, + ).toBe('once'); + }); + it('should default to alternate for hover trigger', () => { + expect( + resolveSequenceForCSS( + BASE_SEQUENCE, + { ...BASE_INTERACTION, trigger: 'hover' }, + EMPTY_CONFIG, + )?.triggerType, + ).toBe('alternate'); + }); + it('should default to alternate for click trigger', () => { + expect( + resolveSequenceForCSS( + BASE_SEQUENCE, + { ...BASE_INTERACTION, trigger: 'click' }, + EMPTY_CONFIG, + )?.triggerType, + ).toBe('alternate'); + }); + it('should respect explicit sequence-level triggerType', () => { + expect( + resolveSequenceForCSS( + { ...BASE_SEQUENCE, triggerType: 'repeat' }, + { ...BASE_INTERACTION, trigger: 'hover' }, + EMPTY_CONFIG, + )?.triggerType, + ).toBe('repeat'); + }); + it('should propagate triggerType to all effects in the sequence', () => { + const result = resolveSequenceForCSS( + { effects: [{ effectId: 'e1' }, { effectId: 'e2' }] }, + { ...BASE_INTERACTION, trigger: 'click' }, + EMPTY_CONFIG, + ); + expect(result?.effects[0].triggerType).toBe('alternate'); + expect(result?.effects[1].triggerType).toBe('alternate'); + }); + it('should override effect-level triggerType with sequence-level triggerType', () => { + const result = resolveSequenceForCSS( + { + triggerType: 'once', + effects: [ + { effectId: 'e1', triggerType: 'repeat' }, + { effectId: 'e2', triggerType: 'alternate' }, + ], + }, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect(result?.effects[0].triggerType).toBe('once'); + expect(result?.effects[1].triggerType).toBe('once'); + }); + }); + + describe('conditions', () => { + it('should create empty array if conditions is undefined', () => { + expect( + resolveSequenceForCSS(BASE_SEQUENCE, BASE_INTERACTION, EMPTY_CONFIG)?.conditions, + ).toEqual([]); + }); + it('should filter duplications from conditions', () => { + const result = resolveSequenceForCSS( + { ...BASE_SEQUENCE, conditions: ['condition', 'condition'] }, + BASE_INTERACTION, + { interactions: [], effects: {}, conditions: { condition: BASE_CONDITION } }, + ); + expect(result?.conditions).toEqual(['condition']); + }); + it('should filter non-existing condition names from conditions', () => { + const result = resolveSequenceForCSS( + { ...BASE_SEQUENCE, conditions: ['condition', 'garbage'] }, + BASE_INTERACTION, + { interactions: [], effects: {}, conditions: { condition: BASE_CONDITION } }, + ); + expect(result?.conditions).toEqual(['condition']); + }); + }); + + describe('effects', () => { + it('should pass on conditions to all effects in sequence', () => { + const effects = [{ effectId: 'e1' }, { effectId: 'e2' }]; + const result = resolveSequenceForCSS( + { ...BASE_SEQUENCE, effects, conditions: ['condition'] }, + BASE_INTERACTION, + { interactions: [], effects: {}, conditions: { condition: BASE_CONDITION } }, + ); + expect(result?.effects[0].conditions).toContain('condition'); + expect(result?.effects[1].conditions).toContain('condition'); + }); + it('should add offsets (delay) to all individual effects', () => { + const result = resolveSequenceForCSS( + { + delay: 100, + offset: 50, + effects: [{ effectId: 'e1' }, { effectId: 'e2' }], + }, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect((result?.effects[0] as any).delay).toBe(100); + expect((result?.effects[1] as any).delay).toBe(150); + }); + it('should add correct offsets to effects by original order (even if null after resolving)', () => { + const result = resolveSequenceForCSS( + { + offset: 100, + effects: [ + { effectId: 'e1' } as EffectRef, + { effectId: 'e2', key: 'x[]' } as Effect, + { effectId: 'e3' } as EffectRef, + ], + }, + BASE_INTERACTION, + EMPTY_CONFIG, + ); + expect(result?.effects).toHaveLength(2); + expect((result?.effects[0] as any).delay).toBe(0); + expect((result?.effects[1] as any).delay).toBe(200); + }); + }); + }); +}); diff --git a/packages/interact/test/sequences.spec.ts b/packages/interact/test/sequences.spec.ts index 7369e77f..fc412bb0 100644 --- a/packages/interact/test/sequences.spec.ts +++ b/packages/interact/test/sequences.spec.ts @@ -688,7 +688,7 @@ describe('interact sequences', () => { const interactionCall = clickAddSpy.mock.calls.find((call) => call[4]?.animation); expect(interactionCall?.[4]).toEqual( expect.objectContaining({ - selectorCondition: '.is-active &', + selectorCondition: ':is(.is-active &)', }), ); }); diff --git a/packages/interact/test/web.spec.ts b/packages/interact/test/web.spec.ts index b15c46f7..022d323d 100644 --- a/packages/interact/test/web.spec.ts +++ b/packages/interact/test/web.spec.ts @@ -22,6 +22,8 @@ vi.mock('@wix/motion', () => { playState: 'idle', ready: Promise.resolve(), }), + getElementCSSAnimation: vi.fn().mockReturnValue(null), + prepareAnimation: vi.fn(), getScrubScene: vi.fn().mockReturnValue({}), getEasing: vi.fn().mockImplementation((v) => v), getAnimation: vi.fn().mockImplementation((target, options, trigger, reducedMotion) => { diff --git a/packages/interact/vitest.config.ts b/packages/interact/vitest.config.ts index 595d18c5..dc041f02 100644 --- a/packages/interact/vitest.config.ts +++ b/packages/interact/vitest.config.ts @@ -5,6 +5,5 @@ export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', - setupFiles: [], }, }); diff --git a/packages/motion/src/api/common.ts b/packages/motion/src/api/common.ts index a26209ff..e5e676fc 100644 --- a/packages/motion/src/api/common.ts +++ b/packages/motion/src/api/common.ts @@ -89,6 +89,7 @@ function getEffectsData( animations: AnimationData[], trigger?: Partial, effectId?: string, + forCSS?: boolean, ) { // process each AnimationData object into a KeyframeEffect object return animations.map((effect, index) => { @@ -106,9 +107,9 @@ function getEffectsData( effectOptions.duration = effect.duration as number; effectOptions.delay = (effect as TimeAnimationOptions).delay || 0; } else { - // if ViewTimeline is supported AND this is a view-progress trigger - if (window.ViewTimeline && trigger?.trigger === 'view-progress') { - // set duration to 'auto' + // forCSS bypasses the runtime ViewTimeline check so that SSR / CSS generation + // always emits `duration: auto` for scroll-driven animations. + if (trigger?.trigger === 'view-progress' && (forCSS || window.ViewTimeline)) { effectOptions.duration = 'auto'; } else { // if ViewTimeline not supported then put a 100ms value in duration get a progress we can easily relate to diff --git a/packages/motion/src/api/cssAnimations.ts b/packages/motion/src/api/cssAnimations.ts index 6a834cb2..edc94cb1 100644 --- a/packages/motion/src/api/cssAnimations.ts +++ b/packages/motion/src/api/cssAnimations.ts @@ -57,7 +57,7 @@ function getCSSAnimation( const namedEffect = getNamedEffect(animationOptions) as AnimationEffectAPI | null; const animationsData = getCSSAnimationEffect(namedEffect, animationOptions); - const data = getEffectsData(animationsData, trigger, animationOptions.effectId); + const data = getEffectsData(animationsData, trigger, animationOptions.effectId, true); const isViewProgress = trigger?.trigger === 'view-progress'; return data.map((item, index) => {