From 6ed31bcfbdb3f1f35767eacb83f7b8707f03633a Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 7 Dec 2025 17:12:26 +0200 Subject: [PATCH 01/18] getCSS - first implementation --- packages/interact/src/core/getCSS.ts | 475 ++++++ packages/interact/src/handlers/pointerMove.ts | 2 +- .../interact/src/handlers/viewProgress.ts | 2 +- packages/interact/src/index.ts | 1 + packages/interact/src/types.ts | 7 + packages/interact/test/getCSS.spec.ts | 1451 +++++++++++++++++ 6 files changed, 1936 insertions(+), 2 deletions(-) create mode 100644 packages/interact/src/core/getCSS.ts create mode 100644 packages/interact/test/getCSS.spec.ts diff --git a/packages/interact/src/core/getCSS.ts b/packages/interact/src/core/getCSS.ts new file mode 100644 index 00000000..328de73a --- /dev/null +++ b/packages/interact/src/core/getCSS.ts @@ -0,0 +1,475 @@ +import type { + InteractConfig, + GetCSSResult, + TriggerType, + Effect, + EffectRef, + TimeEffect, + Condition, +} from '../types'; +import { getSelector } from './Interact'; +import { getEasing } from '@wix/motion'; +import { entranceAnimations } from '../../../motion/src/library/entrance'; +import { ongoingAnimations } from '../../../motion/src/library/ongoing'; +import { scrollAnimations } from '../../../motion/src/library/scroll'; +import { mouseAnimations } from '../../../motion/src/library/mouse'; +import { backgroundScrollAnimations } from '../../../motion/src/library/backgroundScroll'; + +// ============================================================================ +// Types +// ============================================================================ + +type KeyframeProperty = Record; + +interface AnimationData { + name: string; + keyframes: KeyframeProperty[]; + duration?: number; + delay?: number; + easing?: string; + iterations?: number; + alternate?: boolean; + reversed?: boolean; + fill?: string; +} + +interface AnimationProps { + names: string[]; + durations: string[]; + delays: string[]; + timingFunctions: string[]; + iterationCounts: string[]; + directions: string[]; + fillModes: string[]; +} + +interface AnimationEffectAPI { + style?: (options: any) => any[]; + web?: (options: any) => any[]; + getNames?: (options: any) => string[]; +} + +// ============================================================================ +// Helper Utilities +// ============================================================================ + +/** + * Checks if the trigger is a time-based trigger (not scrub-based). + */ +function isTimeTrigger(trigger: TriggerType): boolean { + return !['viewProgress', 'pointerMove'].includes(trigger); +} + +/** + * Determines the animation-direction CSS value based on alternate and reversed flags. + */ +function getDirection(alternate?: boolean, reversed?: boolean): string { + if (alternate && reversed) return 'alternate-reverse'; + if (alternate) return 'alternate'; + if (reversed) return 'reverse'; + return 'normal'; +} + +/** + * Interpolates missing offset values in keyframes array. + * When offsets are not provided, they are evenly distributed. + */ +function interpolateOffsets( + keyframes: KeyframeProperty[], +): KeyframeProperty[] { + if (keyframes.length === 0) return keyframes; + + const result = keyframes.map((kf) => ({ ...kf })); + const n = result.length; + + // Set first and last if not present + if (result[0].offset === undefined) { + result[0].offset = 0; + } + if (result[n - 1].offset === undefined) { + result[n - 1].offset = 1; + } + + // Find segments between defined offsets and interpolate + let lastDefinedIndex = 0; + for (let i = 1; i < n; i++) { + if (result[i].offset !== undefined) { + // Interpolate between lastDefinedIndex and i + const startOffset = result[lastDefinedIndex].offset as number; + const endOffset = result[i].offset as number; + const gap = i - lastDefinedIndex; + + for (let j = lastDefinedIndex + 1; j < i; j++) { + const progress = (j - lastDefinedIndex) / gap; + result[j].offset = startOffset + (endOffset - startOffset) * progress; + } + + lastDefinedIndex = i; + } + } + + return result; +} + +/** + * Rounds a number to avoid floating point precision issues. + */ +function roundOffset(offset: number): number { + // Round to 2 decimal places + return Math.round(offset * 100) / 100; +} + +/** + * Converts keyframes array to CSS @keyframes rule string. + */ +function keyframesToCSS(name: string, keyframes: KeyframeProperty[]): string { + if (!keyframes || keyframes.length === 0) return ''; + + const interpolated = interpolateOffsets(keyframes); + + const keyframeBlocks = interpolated + .map((kf) => { + const offset = kf.offset as number; + const percentage = roundOffset(offset * 100); + + // Filter out offset and build property string + const properties = Object.entries(kf) + .filter(([key]) => key !== 'offset') + .map(([key, value]) => { + // Convert camelCase to kebab-case for CSS + const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); + return `${cssKey}: ${value};`; + }) + .join(' '); + + return `${percentage}% { ${properties} }`; + }) + .join(' '); + + return `@keyframes ${name} { ${keyframeBlocks} }`; +} + +/** + * Checks if effect has customEffect (JS-only). + */ +function isCustomEffect(effect: Effect): boolean { + return 'customEffect' in effect && (effect as any).customEffect !== undefined; +} + +/** + * Checks if effect has namedEffect. + */ +function hasNamedEffect(effect: any): boolean { + return 'namedEffect' in effect && effect.namedEffect !== undefined; +} + +/** + * Checks if effect has keyframeEffect. + */ +function hasKeyframeEffect(effect: any): boolean { + return 'keyframeEffect' in effect && effect.keyframeEffect !== undefined; +} + +/** + * Gets the named effect API from animation libraries. + */ +function getNamedEffectAPI(namedEffect: { type: string }): AnimationEffectAPI | null { + const name = namedEffect.type; + + if (name in entranceAnimations) { + return entranceAnimations[name as keyof typeof entranceAnimations] as unknown as AnimationEffectAPI; + } + if (name in ongoingAnimations) { + return ongoingAnimations[name as keyof typeof ongoingAnimations] as unknown as AnimationEffectAPI; + } + if (name in scrollAnimations) { + return scrollAnimations[name as keyof typeof scrollAnimations] as unknown as AnimationEffectAPI; + } + if (name in mouseAnimations) { + return mouseAnimations[name as keyof typeof mouseAnimations] as unknown as AnimationEffectAPI; + } + if (name in backgroundScrollAnimations) { + return backgroundScrollAnimations[name as keyof typeof backgroundScrollAnimations] as unknown as AnimationEffectAPI; + } + + return null; +} + +/** + * Gets animation data from an effect using the motion library. + * Config values take precedence over effect defaults. + */ +function getAnimationData(effect: Effect): AnimationData[] { + const timeEffect = effect as TimeEffect; + + if (hasNamedEffect(effect)) { + const namedEffectApi = getNamedEffectAPI((effect as any).namedEffect); + + if (namedEffectApi && namedEffectApi.style) { + const styleResult = namedEffectApi.style(effect); + return styleResult.map((data: any) => ({ + name: data.name, + keyframes: data.keyframes, + // Config values take precedence over effect defaults + duration: timeEffect.duration ?? data.duration, + delay: timeEffect.delay ?? data.delay, + easing: timeEffect.easing ?? data.easing, + iterations: timeEffect.iterations ?? data.iterations, + alternate: timeEffect.alternate ?? data.alternate, + reversed: timeEffect.reversed ?? data.reversed, + fill: timeEffect.fill ?? data.fill, + })); + } + } else if (hasKeyframeEffect(effect)) { + const { name, keyframes } = (effect as any).keyframeEffect; + return [ + { + name, + keyframes: keyframes as KeyframeProperty[], + duration: timeEffect.duration, + delay: timeEffect.delay, + easing: timeEffect.easing, + iterations: timeEffect.iterations, + alternate: timeEffect.alternate, + reversed: timeEffect.reversed, + fill: timeEffect.fill, + }, + ]; + } + + return []; +} + +/** + * Resolves an effect from effectId reference or inline effect. + */ +function resolveEffect( + effectRef: Effect | EffectRef, + effectsMap: Record, +): Effect | null { + if ('effectId' in effectRef && effectRef.effectId) { + const baseEffect = effectsMap[effectRef.effectId]; + if (baseEffect) { + // Merge the base effect with any overrides from the reference + return { ...baseEffect, ...effectRef }; + } + return null; + } + + // Inline effect - check if it has namedEffect or keyframeEffect + if (hasNamedEffect(effectRef) || hasKeyframeEffect(effectRef)) { + return effectRef as Effect; + } + + return null; +} + +/** + * Builds the full CSS selector for an element. + */ +function buildSelector( + key: string, + effect: Effect, + addItemFilter = false, +): string { + const keySelector = `[data-interact-key="${key}"]`; + const childSelector = getSelector(effect, { addItemFilter }); + return `${keySelector} ${childSelector}`; +} + +/** + * Creates empty animation props object. + */ +function createEmptyAnimationProps(): AnimationProps { + return { + names: [], + durations: [], + delays: [], + timingFunctions: [], + iterationCounts: [], + directions: [], + fillModes: [], + }; +} + +/** + * Adds animation data properties to the animation props object. + */ +function addAnimationProps( + props: AnimationProps, + data: AnimationData, + effect: Effect, +): void { + const timeEffect = effect as TimeEffect; + + props.names.push(data.name); + props.durations.push(`${data.duration ?? timeEffect.duration ?? 0}ms`); + props.delays.push(`${data.delay ?? timeEffect.delay ?? 0}ms`); + + const easing = data.easing ?? timeEffect.easing; + props.timingFunctions.push(getEasing(easing)); + + const iterations = data.iterations ?? timeEffect.iterations; + props.iterationCounts.push( + iterations === 0 ? 'infinite' : String(iterations ?? 1), + ); + + const alternate = data.alternate ?? timeEffect.alternate; + const reversed = data.reversed ?? timeEffect.reversed; + props.directions.push(getDirection(alternate, reversed)); + + const fill = data.fill ?? timeEffect.fill ?? 'none'; + props.fillModes.push(fill); +} + +/** + * Builds a CSS animation rule from animation props. + */ +function buildAnimationRule(selector: string, props: AnimationProps): string { + const declarations: string[] = []; + + declarations.push(`animation-name: ${props.names.join(', ')};`); + declarations.push(`animation-duration: ${props.durations.join(', ')};`); + declarations.push(`animation-delay: ${props.delays.join(', ')};`); + declarations.push( + `animation-timing-function: ${props.timingFunctions.join(', ')};`, + ); + declarations.push( + `animation-iteration-count: ${props.iterationCounts.join(', ')};`, + ); + declarations.push(`animation-direction: ${props.directions.join(', ')};`); + declarations.push(`animation-fill-mode: ${props.fillModes.join(', ')};`); + + return `${selector} { ${declarations.join(' ')} }`; +} + +/** + * Wraps a CSS rule in a media query. + */ +function wrapInMediaQuery(rule: string, predicate: string): string { + return `@media (${predicate}) { ${rule} }`; +} + +/** + * Gets the media predicate for conditions. + */ +function getMediaPredicate( + conditionIds: string[] | undefined, + conditions: Record | undefined, +): string | null { + if (!conditionIds || conditionIds.length === 0 || !conditions) { + return null; + } + + // Find the first media condition + for (const id of conditionIds) { + const condition = conditions[id]; + if (condition && condition.type === 'media' && condition.predicate) { + return condition.predicate; + } + } + + return null; +} + +// ============================================================================ +// Main Function +// ============================================================================ + +/** + * Generates CSS for time-based animations from an InteractConfig. + * + * @param config - The interact configuration containing effects and interactions + * @returns GetCSSResult with keyframes and animationRules + */ +export function getCSS(config: InteractConfig): GetCSSResult { + const keyframeMap = new Map(); // name -> CSS @keyframes rule + const selectorPropsMap = new Map< + string, + { props: AnimationProps; conditions: string | null } + >(); + + for (const interaction of config.interactions) { + // Skip non-time-based triggers + if (!isTimeTrigger(interaction.trigger)) { + continue; + } + + for (const effectRef of interaction.effects) { + // Resolve the effect + const effect = resolveEffect(effectRef, config.effects); + if (!effect) continue; + + // Skip customEffect (JS-only) + if (isCustomEffect(effect)) continue; + + // Get animation data + const animationDataList = getAnimationData(effect); + if (animationDataList.length === 0) continue; + + // Build selector - use effect's key or fall back to interaction key + const targetKey = effect.key || interaction.key; + const hasListItemSelector = Boolean(effect.listItemSelector); + const selector = buildSelector(targetKey, effect, hasListItemSelector); + + // Get media predicate for conditions + const mediaPredicate = getMediaPredicate( + effectRef.conditions, + config.conditions, + ); + + // Create a unique key for the selector + conditions combination + const selectorKey = mediaPredicate + ? `${selector}::${mediaPredicate}` + : selector; + + // Get or create props for this selector + if (!selectorPropsMap.has(selectorKey)) { + selectorPropsMap.set(selectorKey, { + props: createEmptyAnimationProps(), + conditions: mediaPredicate, + }); + } + const { props } = selectorPropsMap.get(selectorKey)!; + + // Process each animation data + for (const data of animationDataList) { + // Add keyframe (deduplicated) + if (data.name && !keyframeMap.has(data.name)) { + const keyframeCSS = keyframesToCSS(data.name, data.keyframes); + if (keyframeCSS) { + keyframeMap.set(data.name, keyframeCSS); + } + } + + // Add animation properties + addAnimationProps(props, data, effect); + } + } + } + + // Build animation rules + const animationRules: string[] = []; + for (const [selectorKey, { props, conditions }] of selectorPropsMap) { + if (props.names.length === 0) continue; + + // Extract the actual selector (remove conditions suffix if present) + const selector = conditions + ? selectorKey.substring(0, selectorKey.lastIndexOf('::')) + : selectorKey; + + let rule = buildAnimationRule(selector, props); + + // Wrap in media query if conditions exist + if (conditions) { + rule = wrapInMediaQuery(rule, conditions); + } + + animationRules.push(rule); + } + + return { + keyframes: Array.from(keyframeMap.values()), + animationRules, + }; +} diff --git a/packages/interact/src/handlers/pointerMove.ts b/packages/interact/src/handlers/pointerMove.ts index 326e74fb..c897f3da 100644 --- a/packages/interact/src/handlers/pointerMove.ts +++ b/packages/interact/src/handlers/pointerMove.ts @@ -1,5 +1,5 @@ import { getScrubScene } from '@wix/motion'; -import { Pointer, PointerConfig } from 'kuliso'; +import { Pointer } from 'kuliso'; import type { PointerMoveParams, ScrubEffect, diff --git a/packages/interact/src/handlers/viewProgress.ts b/packages/interact/src/handlers/viewProgress.ts index ce0663fc..02de7ae5 100644 --- a/packages/interact/src/handlers/viewProgress.ts +++ b/packages/interact/src/handlers/viewProgress.ts @@ -1,6 +1,6 @@ import type { ScrubScrollScene } from '@wix/motion'; import { getWebAnimation, getScrubScene } from '@wix/motion'; -import { Scroll, scrollConfig } from 'fizban'; +import { Scroll } from 'fizban'; import type { ViewEnterParams, ScrubEffect, HandlerObjectMap } from '../types'; import { effectToAnimationOptions, diff --git a/packages/interact/src/index.ts b/packages/interact/src/index.ts index 3b8e6bad..4b2c9625 100644 --- a/packages/interact/src/index.ts +++ b/packages/interact/src/index.ts @@ -1,5 +1,6 @@ export { Interact } from './core/Interact'; export { add } from './core/add'; export { remove } from './core/remove'; +export { getCSS } from './core/getCSS'; export * from './types'; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 99c266b6..88fbaa35 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -251,3 +251,10 @@ export type CreateTransitionCSSParams = { properties?: TransitionProperty[]; childSelector?: string; }; + +export type GetCSSResult = { + /** @keyframes rules for the animations */ + keyframes: string[]; + /** Full animation property rules per element (animation-name, duration, timing-function, fill-mode, etc.) */ + animationRules: string[]; +}; diff --git a/packages/interact/test/getCSS.spec.ts b/packages/interact/test/getCSS.spec.ts new file mode 100644 index 00000000..d1ce84d2 --- /dev/null +++ b/packages/interact/test/getCSS.spec.ts @@ -0,0 +1,1451 @@ +import { describe, it, expect } from 'vitest'; +import type { InteractConfig, TimeEffect, Effect } from '../src/types'; +import type { NamedEffect } from '@wix/motion'; +import { getCSS } from '../src/core/getCSS'; +import { getSelector } from '../src/core/Interact'; + +/** + * getCSS Test Suite + * + * Tests CSS generation for time-based animations only. + * CSS should be generated for triggers: viewEnter, animationEnd, hover, click, pageVisible + * CSS should NOT be generated for scrub triggers: viewProgress, pointerMove + */ +describe('getCSS', () => { + // ============================================================================ + // Helpers + // ============================================================================ + + /** Get keyframe names - mocked to return predictable values */ + const getFadeInNames = (_: number): string[] => ['motion-fadeIn']; + const getArcInNames = (_: number): string[] => [ + 'motion-fadeIn', + 'motion-arcIn', + ]; + + /** Extract duration from an effect in a config */ + const getEffectDuration = (config: InteractConfig, effectId: string) => + (config.effects[effectId] as { duration: number }).duration; + + /** + * Creates a regex pattern for a keyframe block that allows properties in any order. + * @param percentage - The percentage value (e.g., '0', '25', '100') + * @param properties - Array of [property, value] tuples to match + * @returns RegExp that matches the keyframe block with properties in any order + */ + const createKeyframeBlockPattern = ( + percentage: string, + properties: [string, string][], + ): RegExp => { + // Each property must appear exactly once, but order doesn't matter + // Use lookahead assertions for unordered matching + const propertyLookaheads = properties + .map(([prop, val]) => { + const escapedVal = val.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return `(?=.*${prop}:\\s*${escapedVal})`; + }) + .join(''); + + return new RegExp(`${percentage}%\\s*\\{${propertyLookaheads}[^}]+\\}`); + }; + + /** + * Builds the full CSS selector for an element with a given key and optional child selector. + * @param key - The data-interact-key value + * @param effect - Optional effect object to derive child selector from + * @returns Full CSS selector string + */ + const buildFullSelector = (key: string, effect?: Effect): string => { + const keySelector = `[data-interact-key="${key}"]`; + const childSelector = effect ? getSelector(effect) : getSelector({}); + return `${keySelector} ${childSelector}`; + }; + + /** + * Escapes special regex characters in a string. + */ + const escapeRegex = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + /** + * Creates a regex pattern that matches a CSS rule with selector and specific property. + * @param selector - The CSS selector + * @param property - The animation property name + * @param value - The expected value + * @returns RegExp that matches the full rule + */ + const createAnimationRulePattern = ( + selector: string, + property: string, + value: string, + ): RegExp => { + const escapedSelector = escapeRegex(selector); + const escapedValue = escapeRegex(value); + return new RegExp( + `${escapedSelector}\\s*\\{[^}]*${property}:\\s*${escapedValue}[^}]*\\}`, + ); + }; + + /** + * Gets all effect names/values for multipleEffectsConfig. + * Must be called after multipleEffectsConfig is defined. + */ + const getMultiEffectValues = () => { + const dur1 = getEffectDuration(multipleEffectsConfig, 'first-effect'); + const dur2 = getEffectDuration(multipleEffectsConfig, 'second-effect'); + const dur3 = getEffectDuration(multipleEffectsConfig, 'third-effect'); + + const [name1] = getFadeInNames(dur1); + const name2 = ( + multipleEffectsConfig.effects['second-effect'] as { + keyframeEffect: { name: string }; + } + ).keyframeEffect.name; + const [name3] = getFadeInNames(dur3); + + const delay1 = ( + multipleEffectsConfig.effects['first-effect'] as { delay: number } + ).delay; + const delay2 = ( + multipleEffectsConfig.effects['second-effect'] as { delay: number } + ).delay; + const delay3 = ( + multipleEffectsConfig.effects['third-effect'] as { delay: number } + ).delay; + + const fill1 = ( + multipleEffectsConfig.effects['first-effect'] as { fill: string } + ).fill; + const fill2 = ( + multipleEffectsConfig.effects['second-effect'] as { fill: string } + ).fill; + const fill3 = ( + multipleEffectsConfig.effects['third-effect'] as { fill: string } + ).fill; + + return { + names: [name1, name2, name3], + durations: [dur1, dur2, dur3], + delays: [delay1, delay2, delay3], + fills: [fill1, fill2, fill3], + }; + }; + + // ============================================================================ + // Test Configurations + // ============================================================================ + + /** Config with FadeIn namedEffect */ + const fadeInConfig: InteractConfig = { + effects: { + 'fade-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'fade-element', + effects: [{ key: 'fade-element', effectId: 'fade-effect' }], + }, + ], + }; + + /** Config with custom keyframeEffect */ + const keyframeEffectConfig: InteractConfig = { + effects: { + 'custom-slide': { + keyframeEffect: { + name: 'custom-slide-animation', + keyframes: [ + { offset: 0, transform: 'translateX(-100px)', opacity: '0' }, + { offset: 1, transform: 'translateX(0)', opacity: '1' }, + ], + }, + duration: 800, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'slide-element', + effects: [{ key: 'slide-element', effectId: 'custom-slide' }], + }, + ], + }; + + /** Config with customEffect - should NOT generate CSS */ + const customEffectConfig: InteractConfig = { + effects: { + 'js-effect': { + customEffect: (_element: Element, _progress: number) => { + // JavaScript-only animation + }, + duration: 1000, + } as Effect, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'js-element', + effects: [{ key: 'js-element', effectId: 'js-effect' }], + }, + ], + }; + + /** Config with all TimeEffect options */ + const fullOptionsConfig: InteractConfig = { + effects: { + 'full-options': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 1000, + delay: 200, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + iterations: 3, + alternate: true, + fill: 'both', + reversed: false, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'full-options-element', + effects: [{ key: 'full-options-element', effectId: 'full-options' }], + }, + ], + }; + + /** Config with reversed direction */ + const reversedConfig: InteractConfig = { + effects: { + 'reversed-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + reversed: true, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'reversed-element', + effects: [{ key: 'reversed-element', effectId: 'reversed-effect' }], + }, + ], + }; + + /** Config with alternate-reverse direction */ + const alternateReversedConfig: InteractConfig = { + effects: { + 'alt-rev-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + alternate: true, + reversed: true, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'alt-rev-element', + effects: [{ key: 'alt-rev-element', effectId: 'alt-rev-effect' }], + }, + ], + }; + + /** Config with infinite iterations */ + const infiniteIterationsConfig: InteractConfig = { + effects: { + 'infinite-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + iterations: 0, // 0 means infinite + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'infinite-element', + effects: [{ key: 'infinite-element', effectId: 'infinite-effect' }], + }, + ], + }; + + /** Config with scrub trigger (viewProgress) - should NOT generate CSS */ + const scrubTriggerConfig: InteractConfig = { + effects: { + 'scroll-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + rangeStart: { + name: 'contain', + offset: { value: 0, type: 'percentage' }, + }, + rangeEnd: { + name: 'contain', + offset: { value: 100, type: 'percentage' }, + }, + } as Effect, + }, + interactions: [ + { + trigger: 'viewProgress', + key: 'scroll-element', + effects: [{ key: 'scroll-element', effectId: 'scroll-effect' }], + }, + ], + }; + + /** Config with pointerMove trigger - should NOT generate CSS */ + const pointerMoveTriggerConfig: InteractConfig = { + effects: { + 'pointer-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + rangeStart: { + name: 'contain', + offset: { value: 0, type: 'percentage' }, + }, + rangeEnd: { + name: 'contain', + offset: { value: 100, type: 'percentage' }, + }, + } as Effect, + }, + interactions: [ + { + trigger: 'pointerMove', + key: 'pointer-element', + effects: [{ key: 'pointer-element', effectId: 'pointer-effect' }], + }, + ], + }; + + /** Config with hover trigger - time-based, should generate CSS */ + const hoverTriggerConfig: InteractConfig = { + effects: { + 'hover-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 300, + }, + }, + interactions: [ + { + trigger: 'hover', + key: 'hover-element', + effects: [{ key: 'hover-element', effectId: 'hover-effect' }], + }, + ], + }; + + /** Config with click trigger - time-based, should generate CSS */ + const clickTriggerConfig: InteractConfig = { + effects: { + 'click-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 400, + }, + }, + interactions: [ + { + trigger: 'click', + key: 'click-element', + effects: [{ key: 'click-element', effectId: 'click-effect' }], + }, + ], + }; + + /** Config with animationEnd trigger - time-based, should generate CSS */ + const animationEndTriggerConfig: InteractConfig = { + effects: { + 'chain-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 600, + }, + }, + interactions: [ + { + trigger: 'animationEnd', + key: 'chain-element', + params: { effectId: 'some-previous-effect' }, + effects: [{ key: 'chain-element', effectId: 'chain-effect' }], + }, + ], + }; + + /** Config with custom selector */ + const customSelectorConfig: InteractConfig = { + effects: { + 'selector-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'parent-element', + effects: [ + { + key: 'parent-element', + selector: '.child-target', + effectId: 'selector-effect', + }, + ], + }, + ], + }; + + /** Config with listContainer */ + const listContainerConfig: InteractConfig = { + effects: { + 'list-item-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 300, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'list-wrapper', + listContainer: '.items-container', + listItemSelector: '.item', + effects: [ + { + listContainer: '.items-container', + listItemSelector: '.item', + effectId: 'list-item-effect', + }, + ], + }, + ], + }; + + /** Config with media condition */ + const conditionalConfig: InteractConfig = { + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + }, + effects: { + 'conditional-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'conditional-element', + effects: [ + { + key: 'conditional-element', + effectId: 'conditional-effect', + conditions: ['desktop'], + }, + ], + }, + ], + }; + + /** Empty config */ + const emptyConfig: InteractConfig = { + effects: {}, + interactions: [], + }; + + /** Config with same effect used twice (deduplication test) */ + const duplicateEffectConfig: InteractConfig = { + effects: { + 'shared-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'element-a', + effects: [{ key: 'element-a', effectId: 'shared-effect' }], + }, + { + trigger: 'viewEnter', + key: 'element-b', + effects: [{ key: 'element-b', effectId: 'shared-effect' }], + }, + ], + }; + + /** Config with inline effect definition (no effectId reference) */ + const inlineEffectConfig: InteractConfig = { + effects: {}, + interactions: [ + { + trigger: 'viewEnter', + key: 'inline-element', + effects: [ + { + key: 'inline-element', + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 400, + } as Effect, + ], + }, + ], + }; + + /** Config with fill modes */ + const fillNoneConfig: InteractConfig = { + effects: { + 'fill-none': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + fill: 'none', + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'fill-none-element', + effects: [{ key: 'fill-none-element', effectId: 'fill-none' }], + }, + ], + }; + + const fillForwardsConfig: InteractConfig = { + effects: { + 'fill-forwards': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + fill: 'forwards', + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'fill-forwards-element', + effects: [{ key: 'fill-forwards-element', effectId: 'fill-forwards' }], + }, + ], + }; + + /** Config with ArcIn (multi-keyframe effect) */ + const arcInConfig: InteractConfig = { + effects: { + 'arc-effect': { + namedEffect: { + type: 'ArcIn', + direction: 'right', + power: 'medium', + } as NamedEffect, + duration: 1000, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'arc-element', + effects: [{ key: 'arc-element', effectId: 'arc-effect' }], + }, + ], + }; + + /** Config with fractional offset keyframes */ + const fractionalKeyframesConfig: InteractConfig = { + effects: { + 'multi-step': { + keyframeEffect: { + name: 'multi-step-animation', + keyframes: [ + { offset: 0, opacity: '0' }, + { offset: 0.25, opacity: '0.5' }, + { offset: 0.75, opacity: '0.8' }, + { offset: 1, opacity: '1' }, + ], + }, + duration: 1000, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'multi-step-element', + effects: [{ key: 'multi-step-element', effectId: 'multi-step' }], + }, + ], + }; + + /** Config with keyframes without explicit offsets (interpolated) */ + const interpolatedKeyframesConfig: InteractConfig = { + effects: { + 'interpolated-effect': { + keyframeEffect: { + name: 'interpolated-animation', + keyframes: [ + { opacity: '0', transform: 'scale(0.5)' }, + { opacity: '0.3', transform: 'scale(0.7)' }, + { opacity: '0.7', transform: 'scale(0.9)' }, + { opacity: '1', transform: 'scale(1)' }, + ], + }, + duration: 1000, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'interpolated-element', + effects: [ + { + key: 'interpolated-element', + effectId: 'interpolated-effect', + }, + ], + }, + ], + }; + + /** Config with mixed explicit and implicit offsets */ + const mixedOffsetsConfig: InteractConfig = { + effects: { + 'mixed-effect': { + keyframeEffect: { + name: 'mixed-offset-animation', + keyframes: [ + { offset: 0, opacity: '0' }, + { opacity: '0.5' }, // Should be interpolated to 50% + { offset: 1, opacity: '1' }, + ], + }, + duration: 500, + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'mixed-element', + effects: [{ key: 'mixed-element', effectId: 'mixed-effect' }], + }, + ], + }; + + /** Config with multiple effects on same target */ + const multipleEffectsConfig: InteractConfig = { + effects: { + 'first-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + delay: 0, + fill: 'forwards', + }, + 'second-effect': { + keyframeEffect: { + name: 'scale-up', + keyframes: [ + { offset: 0, transform: 'scale(0.8)' }, + { offset: 1, transform: 'scale(1)' }, + ], + }, + duration: 800, + delay: 100, + fill: 'both', + }, + 'third-effect': { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 300, + delay: 200, + iterations: 2, + fill: 'none', + }, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'multi-effect-element', + effects: [ + { key: 'multi-effect-element', effectId: 'first-effect' }, + { key: 'multi-effect-element', effectId: 'second-effect' }, + { key: 'multi-effect-element', effectId: 'third-effect' }, + ], + }, + ], + }; + + // ============================================================================ + // SUITE 1: Keyframes Rules + // ============================================================================ + describe('keyframes rules', () => { + describe('namedEffect keyframes', () => { + it('should generate valid @keyframes rule for FadeIn effect', () => { + const result = getCSS(fadeInConfig); + const duration = getEffectDuration(fadeInConfig, 'fade-effect'); + const [fadeInName] = getFadeInNames(duration); + + expect(result.keyframes).toHaveLength(1); + + // FadeIn generates keyframe with opacity animation + // Pattern: @keyframes {name} { 0% { opacity: 0 } 100% { opacity: ... } } + const keyframeRule = result.keyframes[0]; + const keyframePattern = new RegExp( + `^@keyframes\\s+${fadeInName}\\s*\\{\\s*0%\\s*\\{\\s*opacity:\\s*0;?\\s*\\}\\s*100%\\s*\\{\\s*opacity:\\s*[^}]+\\}\\s*\\}$`, + ); + + expect(keyframeRule).toMatch(keyframePattern); + }); + + it('should generate multiple @keyframes rules for multi-keyframe effects', () => { + const result = getCSS(arcInConfig); + const duration = getEffectDuration(arcInConfig, 'arc-effect'); + const [fadeInName, arcInName] = getArcInNames(duration); + + // ArcIn generates 2 keyframes + expect(result.keyframes).toHaveLength(2); + + // Should have fadeIn keyframe + const hasFadeIn = result.keyframes.some((kf) => + new RegExp(`^@keyframes\\s+${fadeInName}\\s*\\{`).test(kf), + ); + expect(hasFadeIn).toBe(true); + + // Should have arcIn keyframe with transform/perspective + const hasArcIn = result.keyframes.some( + (kf) => + new RegExp(`^@keyframes\\s+${arcInName}\\s*\\{`).test(kf) && + kf.includes('transform') && + kf.includes('perspective'), + ); + expect(hasArcIn).toBe(true); + }); + }); + + describe('keyframeEffect keyframes', () => { + it('should generate @keyframes with custom name and keyframe values (properties may be scrambled)', () => { + const result = getCSS(keyframeEffectConfig); + + const { name, keyframes } = ( + keyframeEffectConfig.effects['custom-slide'] as { + keyframeEffect: { name: string; keyframes: Keyframe[] }; + } + ).keyframeEffect; + + expect(result.keyframes).toHaveLength(1); + const keyframeRule = result.keyframes[0]; + + // Verify complete @keyframes structure: @keyframes name { ... } + const fullStructurePattern = new RegExp( + `^@keyframes\\s+${name}\\s*\\{[\\s\\S]*\\}$`, + ); + expect(keyframeRule).toMatch(fullStructurePattern); + + // Verify first keyframe (0%) with properties in any order + const firstKeyframe = keyframes[0]; + const firstPercentage = String((firstKeyframe.offset ?? 0) * 100); + const firstProps: [string, string][] = [ + ['transform', 'translateX(-100px)'], + ['opacity', '0'], + ]; + expect(keyframeRule).toMatch( + createKeyframeBlockPattern(firstPercentage, firstProps), + ); + + // Verify last keyframe (100%) with properties in any order + const lastKeyframe = keyframes[keyframes.length - 1]; + const lastPercentage = String((lastKeyframe.offset ?? 1) * 100); + const lastProps: [string, string][] = [ + ['transform', 'translateX(0)'], + ['opacity', '1'], + ]; + expect(keyframeRule).toMatch( + createKeyframeBlockPattern(lastPercentage, lastProps), + ); + }); + + it('should generate @keyframes with fractional offset values', () => { + const result = getCSS(fractionalKeyframesConfig); + + const { name } = ( + fractionalKeyframesConfig.effects['multi-step'] as { + keyframeEffect: { name: string }; + } + ).keyframeEffect; + + expect(result.keyframes).toHaveLength(1); + const keyframeRule = result.keyframes[0]; + + // Verify complete structure with all percentage stops + const fullStructurePattern = new RegExp( + `^@keyframes\\s+${name}\\s*\\{` + + `\\s*0%\\s*\\{\\s*opacity:\\s*0;?\\s*\\}` + + `\\s*25%\\s*\\{\\s*opacity:\\s*0\\.5;?\\s*\\}` + + `\\s*75%\\s*\\{\\s*opacity:\\s*0\\.8;?\\s*\\}` + + `\\s*100%\\s*\\{\\s*opacity:\\s*1;?\\s*\\}` + + `\\s*\\}$`, + ); + expect(keyframeRule).toMatch(fullStructurePattern); + }); + + it('should interpolate percentages when offset is not provided', () => { + // When offsets are not provided, they should be evenly distributed + // 4 keyframes without offset → 0%, 33.33%, 66.67%, 100% + const result = getCSS(interpolatedKeyframesConfig); + + const { name } = ( + interpolatedKeyframesConfig.effects['interpolated-effect'] as { + keyframeEffect: { name: string }; + } + ).keyframeEffect; + + expect(result.keyframes).toHaveLength(1); + const keyframeRule = result.keyframes[0]; + + // Verify @keyframes name + expect(keyframeRule).toMatch( + new RegExp(`^@keyframes\\s+${name}\\s*\\{`), + ); + + // First keyframe should be 0% + expect(keyframeRule).toMatch( + createKeyframeBlockPattern('0', [ + ['opacity', '0'], + ['transform', 'scale(0.5)'], + ]), + ); + + // Middle keyframes should be interpolated (33% or 33.33%, 66% or 66.67%) + // Allow for rounding variations + expect(keyframeRule).toMatch( + createKeyframeBlockPattern('33(?:\\.33)?', [ + ['opacity', '0.3'], + ['transform', 'scale(0.7)'], + ]), + ); + + expect(keyframeRule).toMatch( + createKeyframeBlockPattern('66(?:\\.67)?', [ + ['opacity', '0.7'], + ['transform', 'scale(0.9)'], + ]), + ); + + // Last keyframe should be 100% + expect(keyframeRule).toMatch( + createKeyframeBlockPattern('100', [ + ['opacity', '1'], + ['transform', 'scale(1)'], + ]), + ); + }); + + it('should handle mixed explicit and implicit offsets', () => { + // First and last have explicit offsets, middle ones are interpolated + const result = getCSS(mixedOffsetsConfig); + + const { name } = ( + mixedOffsetsConfig.effects['mixed-effect'] as { + keyframeEffect: { name: string }; + } + ).keyframeEffect; + + expect(result.keyframes).toHaveLength(1); + const keyframeRule = result.keyframes[0]; + + // Verify complete structure with interpolated middle keyframe + const fullStructurePattern = new RegExp( + `^@keyframes\\s+${name}\\s*\\{` + + `\\s*0%\\s*\\{\\s*opacity:\\s*0;?\\s*\\}` + + `\\s*50%\\s*\\{\\s*opacity:\\s*0\\.5;?\\s*\\}` + + `\\s*100%\\s*\\{\\s*opacity:\\s*1;?\\s*\\}` + + `\\s*\\}$`, + ); + expect(keyframeRule).toMatch(fullStructurePattern); + }); + }); + + describe('customEffect keyframes', () => { + it('should NOT generate keyframes for customEffect', () => { + const result = getCSS(customEffectConfig); + + expect(result.keyframes).toHaveLength(0); + }); + }); + + describe('keyframes deduplication', () => { + it('should not duplicate keyframes when same effect is used multiple times', () => { + const result = getCSS(duplicateEffectConfig); + const duration = getEffectDuration( + duplicateEffectConfig, + 'shared-effect', + ); + const [fadeInName] = getFadeInNames(duration); + + // fadeIn keyframe should appear exactly once + const fadeInKeyframes = result.keyframes.filter((kf) => + kf.includes(fadeInName), + ); + expect(fadeInKeyframes).toHaveLength(1); + }); + }); + + describe('trigger filtering', () => { + it('should NOT generate keyframes for viewProgress trigger', () => { + const result = getCSS(scrubTriggerConfig); + expect(result.keyframes).toHaveLength(0); + }); + + it('should NOT generate keyframes for pointerMove trigger', () => { + const result = getCSS(pointerMoveTriggerConfig); + expect(result.keyframes).toHaveLength(0); + }); + + it('should generate keyframes for hover trigger', () => { + const result = getCSS(hoverTriggerConfig); + const duration = getEffectDuration(hoverTriggerConfig, 'hover-effect'); + const [fadeInName] = getFadeInNames(duration); + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.keyframes[0]).toMatch( + new RegExp(`^@keyframes\\s+${fadeInName}`), + ); + }); + + it('should generate keyframes for click trigger', () => { + const result = getCSS(clickTriggerConfig); + const duration = getEffectDuration(clickTriggerConfig, 'click-effect'); + const [fadeInName] = getFadeInNames(duration); + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.keyframes[0]).toMatch( + new RegExp(`^@keyframes\\s+${fadeInName}`), + ); + }); + + it('should generate keyframes for animationEnd trigger', () => { + const result = getCSS(animationEndTriggerConfig); + const duration = getEffectDuration( + animationEndTriggerConfig, + 'chain-effect', + ); + const [fadeInName] = getFadeInNames(duration); + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.keyframes[0]).toMatch( + new RegExp(`^@keyframes\\s+${fadeInName}`), + ); + }); + }); + + describe('edge cases', () => { + it('should return empty keyframes array for empty config', () => { + const result = getCSS(emptyConfig); + expect(result.keyframes).toEqual([]); + }); + + it('should generate keyframes for inline effect definitions', () => { + const result = getCSS(inlineEffectConfig); + // Inline effect has duration in the interaction effect, not in config.effects + const inlineEffect = inlineEffectConfig.interactions[0].effects[0] as { + duration: number; + }; + const [fadeInName] = getFadeInNames(inlineEffect.duration); + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.keyframes[0]).toMatch( + new RegExp(`^@keyframes\\s+${fadeInName}`), + ); + }); + + it('should skip effects without namedEffect or keyframeEffect', () => { + const invalidConfig: InteractConfig = { + effects: { + 'invalid-effect': { duration: 500 } as TimeEffect, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'invalid-element', + effects: [{ key: 'invalid-element', effectId: 'invalid-effect' }], + }, + ], + }; + + const result = getCSS(invalidConfig); + expect(result.keyframes).toEqual([]); + }); + }); + }); + + // ============================================================================ + // SUITE 2: Animation Rules + // ============================================================================ + describe('animation rules', () => { + describe('animation-name', () => { + it('should include animation-name with selector for namedEffect', () => { + const result = getCSS(fadeInConfig); + const duration = getEffectDuration(fadeInConfig, 'fade-effect'); + const [fadeInName] = getFadeInNames(duration); + const selector = buildFullSelector('fade-element'); + + expect(result.animationRules.length).toBeGreaterThan(0); + + const pattern = createAnimationRulePattern( + selector, + 'animation-name', + fadeInName, + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should include animation-name with selector for keyframeEffect', () => { + const result = getCSS(keyframeEffectConfig); + const { name } = ( + keyframeEffectConfig.effects['custom-slide'] as { + keyframeEffect: { name: string }; + } + ).keyframeEffect; + const selector = buildFullSelector('slide-element'); + + expect(result.animationRules.length).toBeGreaterThan(0); + + const pattern = createAnimationRulePattern( + selector, + 'animation-name', + name, + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + }); + + describe('animation-duration', () => { + it('should include animation-duration in milliseconds with selector', () => { + const result = getCSS(fadeInConfig); + const duration = getEffectDuration(fadeInConfig, 'fade-effect'); + const selector = buildFullSelector('fade-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-duration', + `${duration}ms`, + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should use correct duration value from effect config', () => { + const result = getCSS(keyframeEffectConfig); + const duration = getEffectDuration( + keyframeEffectConfig, + 'custom-slide', + ); + const selector = buildFullSelector('slide-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-duration', + `${duration}ms`, + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + }); + + describe('animation-delay', () => { + it('should include animation-delay when delay is specified', () => { + const result = getCSS(fullOptionsConfig); + const delay = ( + fullOptionsConfig.effects['full-options'] as { delay: number } + ).delay; + const selector = buildFullSelector('full-options-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-delay', + `${delay}ms`, + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should omit animation-delay when not specified', () => { + const result = getCSS(fadeInConfig); + + // fadeInConfig has no delay - either no animation-delay or 0ms + const hasNonZeroDelay = result.animationRules.some((rule) => + /animation-delay:\s*[1-9]\d*ms/.test(rule), + ); + expect(hasNonZeroDelay).toBe(false); + }); + }); + + describe('animation-timing-function', () => { + it('should include custom easing as animation-timing-function', () => { + const result = getCSS(fullOptionsConfig); + const easing = ( + fullOptionsConfig.effects['full-options'] as { easing: string } + ).easing; + const selector = buildFullSelector('full-options-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-timing-function', + easing, + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should include animation-timing-function when not specified', () => { + const result = getCSS(fadeInConfig); + const selector = buildFullSelector('fade-element'); + + // Should have animation-timing-function property + const hasTimingFunction = result.animationRules.some( + (rule) => + rule.includes(selector) && + rule.includes('animation-timing-function:'), + ); + expect(hasTimingFunction).toBe(true); + }); + }); + + describe('animation-iteration-count', () => { + it('should include animation-iteration-count when iterations specified', () => { + const result = getCSS(fullOptionsConfig); + const iterations = ( + fullOptionsConfig.effects['full-options'] as { iterations: number } + ).iterations; + const selector = buildFullSelector('full-options-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-iteration-count', + String(iterations), + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should use "infinite" for iterations: 0', () => { + const result = getCSS(infiniteIterationsConfig); + const selector = buildFullSelector('infinite-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-iteration-count', + 'infinite', + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + }); + + describe('animation-direction', () => { + it('should include animation-direction: alternate when alternate: true', () => { + const result = getCSS(fullOptionsConfig); + const selector = buildFullSelector('full-options-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-direction', + 'alternate', + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should include animation-direction: reverse when reversed: true', () => { + const result = getCSS(reversedConfig); + const selector = buildFullSelector('reversed-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-direction', + 'reverse', + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should include animation-direction: alternate-reverse when both', () => { + const result = getCSS(alternateReversedConfig); + const selector = buildFullSelector('alt-rev-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-direction', + 'alternate-reverse', + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should use normal direction when neither alternate nor reversed', () => { + const result = getCSS(fadeInConfig); + + // Should not have reverse or alternate in direction + const hasReverseOrAlternate = result.animationRules.some( + (rule) => + /animation-direction:\s*reverse/.test(rule) || + /animation-direction:\s*alternate/.test(rule), + ); + expect(hasReverseOrAlternate).toBe(false); + }); + }); + + describe('animation-fill-mode', () => { + it('should include animation-fill-mode: both when fill: "both"', () => { + const result = getCSS(fullOptionsConfig); + const selector = buildFullSelector('full-options-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-fill-mode', + 'both', + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should include animation-fill-mode: forwards when fill: "forwards"', () => { + const result = getCSS(fillForwardsConfig); + const selector = buildFullSelector('fill-forwards-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-fill-mode', + 'forwards', + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should not include forwards/backwards/both when fill: "none"', () => { + const result = getCSS(fillNoneConfig); + + // Should NOT have forwards, backwards, or both + const hasOtherFill = result.animationRules.some( + (rule) => + /animation-fill-mode:\s*forwards/.test(rule) || + /animation-fill-mode:\s*backwards/.test(rule) || + /animation-fill-mode:\s*both/.test(rule), + ); + expect(hasOtherFill).toBe(false); + }); + }); + + describe('selectors', () => { + it('should target element using data-interact-key and child selector', () => { + const result = getCSS(fadeInConfig); + const selector = buildFullSelector('fade-element'); + + const hasSelector = result.animationRules.some((rule) => + rule.includes(selector), + ); + expect(hasSelector).toBe(true); + }); + + it('should include custom selector when specified', () => { + const result = getCSS(customSelectorConfig); + const effect = customSelectorConfig.interactions[0] + .effects[0] as Effect; + const selector = buildFullSelector('parent-element', effect); + + const hasSelector = result.animationRules.some((rule) => + rule.includes(selector), + ); + expect(hasSelector).toBe(true); + }); + + it('should include listContainer and listItemSelector in selector', () => { + const result = getCSS(listContainerConfig); + const effect = listContainerConfig.interactions[0].effects[0] as Effect; + const childSelector = getSelector(effect, { addItemFilter: true }); + + // Should include the list container path + const hasListSelector = result.animationRules.some( + (rule) => + rule.includes('[data-interact-key="list-wrapper"]') && + rule.includes(childSelector), + ); + expect(hasListSelector).toBe(true); + }); + }); + + describe('media conditions', () => { + it('should wrap animation rule in @media query when condition specified', () => { + const result = getCSS(conditionalConfig); + const predicate = conditionalConfig.conditions!.desktop.predicate || ''; + const duration = getEffectDuration( + conditionalConfig, + 'conditional-effect', + ); + const [fadeInName] = getFadeInNames(duration); + const selector = buildFullSelector('conditional-element'); + + // Build the animation rule pattern + const animationRulePattern = createAnimationRulePattern( + selector, + 'animation-name', + fadeInName, + ); + + // Should have @media (predicate) wrapper containing the animation rule + const mediaPattern = new RegExp( + `@media\\s*\\(\\s*${escapeRegex( + predicate, + )}\\s*\\)\\s*\\{[\\s\\S]*\\}`, + ); + const hasMediaQueryWithRule = result.animationRules.some( + (rule) => mediaPattern.test(rule) && animationRulePattern.test(rule), + ); + expect(hasMediaQueryWithRule).toBe(true); + }); + + it('should NOT wrap in @media when no conditions', () => { + const result = getCSS(fadeInConfig); + + const hasMediaQuery = result.animationRules.some((rule) => + rule.includes('@media'), + ); + expect(hasMediaQuery).toBe(false); + }); + }); + + describe('multiple effects on same target', () => { + it('should generate comma-separated animation-name values in order', () => { + const result = getCSS(multipleEffectsConfig); + const { names } = getMultiEffectValues(); + const selector = buildFullSelector('multi-effect-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-name', + names.join(', '), + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should generate comma-separated animation-duration values in order', () => { + const result = getCSS(multipleEffectsConfig); + const { durations } = getMultiEffectValues(); + const selector = buildFullSelector('multi-effect-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-duration', + durations.map((d) => `${d}ms`).join(', '), + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should generate comma-separated animation-delay values in order', () => { + const result = getCSS(multipleEffectsConfig); + const { delays } = getMultiEffectValues(); + const selector = buildFullSelector('multi-effect-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-delay', + delays.map((d) => `${d}ms`).join(', '), + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should generate comma-separated animation-fill-mode values in order', () => { + const result = getCSS(multipleEffectsConfig); + const { fills } = getMultiEffectValues(); + const selector = buildFullSelector('multi-effect-element'); + + const pattern = createAnimationRulePattern( + selector, + 'animation-fill-mode', + fills.join(', '), + ); + expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( + true, + ); + }); + + it('should maintain consistent order across all animation properties', () => { + const result = getCSS(multipleEffectsConfig); + const { names, durations } = getMultiEffectValues(); + const selector = buildFullSelector('multi-effect-element'); + + // Verify both animation-name and animation-duration have correct order + const namePattern = createAnimationRulePattern( + selector, + 'animation-name', + names.join(', '), + ); + const durationPattern = createAnimationRulePattern( + selector, + 'animation-duration', + durations.map((d) => `${d}ms`).join(', '), + ); + + // Find the rule for this element + const rule = result.animationRules.find((r) => + r.includes('[data-interact-key="multi-effect-element"]'), + ); + expect(rule).toBeDefined(); + + // Both patterns should match the same rule + expect(namePattern.test(rule!)).toBe(true); + expect(durationPattern.test(rule!)).toBe(true); + }); + }); + + describe('trigger filtering', () => { + it('should NOT generate animation rules for viewProgress trigger', () => { + const result = getCSS(scrubTriggerConfig); + expect(result.animationRules).toHaveLength(0); + }); + + it('should NOT generate animation rules for pointerMove trigger', () => { + const result = getCSS(pointerMoveTriggerConfig); + expect(result.animationRules).toHaveLength(0); + }); + + it('should generate animation rules for all time-based triggers', () => { + const hoverResult = getCSS(hoverTriggerConfig); + const clickResult = getCSS(clickTriggerConfig); + const animEndResult = getCSS(animationEndTriggerConfig); + + expect(hoverResult.animationRules.length).toBeGreaterThan(0); + expect(clickResult.animationRules.length).toBeGreaterThan(0); + expect(animEndResult.animationRules.length).toBeGreaterThan(0); + }); + }); + + describe('edge cases', () => { + it('should return empty animation rules for empty config', () => { + const result = getCSS(emptyConfig); + expect(result.animationRules).toEqual([]); + }); + + it('should NOT generate animation rules for customEffect', () => { + const result = getCSS(customEffectConfig); + expect(result.animationRules).toHaveLength(0); + }); + + it('should generate animation rules for inline effect definitions', () => { + const result = getCSS(inlineEffectConfig); + expect(result.animationRules.length).toBeGreaterThan(0); + }); + }); + }); +}); From c8102db72ca650c5b5daad924e7a9a40ca6d22ca Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 7 Dec 2025 17:21:27 +0200 Subject: [PATCH 02/18] fix build and lint --- packages/interact/package.json | 2 +- packages/interact/tsconfig.build.json | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/interact/package.json b/packages/interact/package.json index 32558653..dcd3cac7 100644 --- a/packages/interact/package.json +++ b/packages/interact/package.json @@ -20,7 +20,7 @@ "scripts": { "dev": "vite dev --open", "build": "rimraf dist && npm run build:types && vite build", - "build:types": "tsc -p tsconfig.build.json", + "build:types": "tsc --build tsconfig.build.json", "lint": "tsc --noEmit", "test": "vitest run" }, diff --git a/packages/interact/tsconfig.build.json b/packages/interact/tsconfig.build.json index a9294615..543d84d1 100644 --- a/packages/interact/tsconfig.build.json +++ b/packages/interact/tsconfig.build.json @@ -9,6 +9,11 @@ "emitDeclarationOnly": true, "noEmit": false }, - "include": ["src"] + "include": ["src"], + "references": [ + { + "path": "../motion/tsconfig.build.json" + } + ] } From f111288f3d5cdb6a266866065cf0fd2cc4bd9f3d Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 8 Dec 2025 16:53:51 +0200 Subject: [PATCH 03/18] gitignore --- packages/motion/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/motion/.gitignore b/packages/motion/.gitignore index 1d99561b..24142a1b 100644 --- a/packages/motion/.gitignore +++ b/packages/motion/.gitignore @@ -12,3 +12,4 @@ test/e2e/screenshots .env*.local storybook-static .vscode +*.d.ts* \ No newline at end of file From 9f34f3512dd04912f48f3b90c3b1e8bd817eb1e2 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 15 Dec 2025 17:26:48 +0200 Subject: [PATCH 04/18] wip --- packages/interact/src/core/getCSS.ts | 191 ++++++++------------------- 1 file changed, 54 insertions(+), 137 deletions(-) diff --git a/packages/interact/src/core/getCSS.ts b/packages/interact/src/core/getCSS.ts index 328de73a..9134d7b2 100644 --- a/packages/interact/src/core/getCSS.ts +++ b/packages/interact/src/core/getCSS.ts @@ -5,15 +5,14 @@ import type { Effect, EffectRef, TimeEffect, + TransitionEffect, + CreateTransitionCSSParams, Condition, } from '../types'; +import { createTransitionCSS } from '../utils'; import { getSelector } from './Interact'; -import { getEasing } from '@wix/motion'; -import { entranceAnimations } from '../../../motion/src/library/entrance'; -import { ongoingAnimations } from '../../../motion/src/library/ongoing'; -import { scrollAnimations } from '../../../motion/src/library/scroll'; -import { mouseAnimations } from '../../../motion/src/library/mouse'; -import { backgroundScrollAnimations } from '../../../motion/src/library/backgroundScroll'; +import { effectToAnimationOptions } from '../handlers/utilities'; +import { getCSSAnimation, getEasing } from '@wix/motion'; // ============================================================================ // Types @@ -21,18 +20,6 @@ import { backgroundScrollAnimations } from '../../../motion/src/library/backgrou type KeyframeProperty = Record; -interface AnimationData { - name: string; - keyframes: KeyframeProperty[]; - duration?: number; - delay?: number; - easing?: string; - iterations?: number; - alternate?: boolean; - reversed?: boolean; - fill?: string; -} - interface AnimationProps { names: string[]; durations: string[]; @@ -43,12 +30,6 @@ interface AnimationProps { fillModes: string[]; } -interface AnimationEffectAPI { - style?: (options: any) => any[]; - web?: (options: any) => any[]; - getNames?: (options: any) => string[]; -} - // ============================================================================ // Helper Utilities // ============================================================================ @@ -150,115 +131,59 @@ function keyframesToCSS(name: string, keyframes: KeyframeProperty[]): string { } /** - * Checks if effect has customEffect (JS-only). - */ -function isCustomEffect(effect: Effect): boolean { - return 'customEffect' in effect && (effect as any).customEffect !== undefined; -} - -/** - * Checks if effect has namedEffect. + * Gets animation data from an effect using the motion library's getCSSAnimation. */ -function hasNamedEffect(effect: any): boolean { - return 'namedEffect' in effect && effect.namedEffect !== undefined; +function getTransitionData(effect: Effect & { key: string }): string[] { + const args: CreateTransitionCSSParams = { + key: effect.key, + effectId: (effect as Effect).effectId!, + transition: (effect as TransitionEffect).transition, + properties: (effect as TransitionEffect).transitionProperties, + childSelector: getSelector(effect, { + // TODO: (ameerf) - paste the right conditions here + asCombinator: true, + addItemFilter: true, + }), + }; + return createTransitionCSS(args); } -/** - * Checks if effect has keyframeEffect. - */ -function hasKeyframeEffect(effect: any): boolean { - return 'keyframeEffect' in effect && effect.keyframeEffect !== undefined; -} - -/** - * Gets the named effect API from animation libraries. - */ -function getNamedEffectAPI(namedEffect: { type: string }): AnimationEffectAPI | null { - const name = namedEffect.type; - - if (name in entranceAnimations) { - return entranceAnimations[name as keyof typeof entranceAnimations] as unknown as AnimationEffectAPI; - } - if (name in ongoingAnimations) { - return ongoingAnimations[name as keyof typeof ongoingAnimations] as unknown as AnimationEffectAPI; - } - if (name in scrollAnimations) { - return scrollAnimations[name as keyof typeof scrollAnimations] as unknown as AnimationEffectAPI; - } - if (name in mouseAnimations) { - return mouseAnimations[name as keyof typeof mouseAnimations] as unknown as AnimationEffectAPI; - } - if (name in backgroundScrollAnimations) { - return backgroundScrollAnimations[name as keyof typeof backgroundScrollAnimations] as unknown as AnimationEffectAPI; - } - - return null; +interface CSSAnimationResult { + name: string; + keyframes: KeyframeProperty[]; } /** - * Gets animation data from an effect using the motion library. - * Config values take precedence over effect defaults. + * Gets animation data from an effect using the motion library's getCSSAnimation. */ -function getAnimationData(effect: Effect): AnimationData[] { - const timeEffect = effect as TimeEffect; - - if (hasNamedEffect(effect)) { - const namedEffectApi = getNamedEffectAPI((effect as any).namedEffect); - - if (namedEffectApi && namedEffectApi.style) { - const styleResult = namedEffectApi.style(effect); - return styleResult.map((data: any) => ({ - name: data.name, - keyframes: data.keyframes, - // Config values take precedence over effect defaults - duration: timeEffect.duration ?? data.duration, - delay: timeEffect.delay ?? data.delay, - easing: timeEffect.easing ?? data.easing, - iterations: timeEffect.iterations ?? data.iterations, - alternate: timeEffect.alternate ?? data.alternate, - reversed: timeEffect.reversed ?? data.reversed, - fill: timeEffect.fill ?? data.fill, - })); - } - } else if (hasKeyframeEffect(effect)) { - const { name, keyframes } = (effect as any).keyframeEffect; - return [ - { - name, - keyframes: keyframes as KeyframeProperty[], - duration: timeEffect.duration, - delay: timeEffect.delay, - easing: timeEffect.easing, - iterations: timeEffect.iterations, - alternate: timeEffect.alternate, - reversed: timeEffect.reversed, - fill: timeEffect.fill, - }, - ]; - } - - return []; +function getAnimationData(effect: Effect): CSSAnimationResult[] { + const animationOptions = effectToAnimationOptions(effect as TimeEffect); + + // Use getCSSAnimation from motion to get the animation data + const cssAnimations = getCSSAnimation(null, animationOptions); + + return cssAnimations + .filter((anim) => anim.name !== undefined) + .map((anim) => ({ + name: anim.name!, + keyframes: anim.keyframes as KeyframeProperty[], + })); } -/** - * Resolves an effect from effectId reference or inline effect. - */ function resolveEffect( effectRef: Effect | EffectRef, effectsMap: Record, + interactionKey: string, ): Effect | null { - if ('effectId' in effectRef && effectRef.effectId) { - const baseEffect = effectsMap[effectRef.effectId]; - if (baseEffect) { - // Merge the base effect with any overrides from the reference - return { ...baseEffect, ...effectRef }; - } - return null; - } + const fullEffect: any = effectRef.effectId + ? { ...effectsMap[effectRef.effectId], ...effectRef} + : { ...effectRef}; - // Inline effect - check if it has namedEffect or keyframeEffect - if (hasNamedEffect(effectRef) || hasKeyframeEffect(effectRef)) { - return effectRef as Effect; + if (fullEffect.namedEffect || fullEffect.keyframeEffect || fullEffect.transition) { + if (!fullEffect.key) { + fullEffect.key = interactionKey; + } + return fullEffect as Effect; } return null; @@ -297,29 +222,24 @@ function createEmptyAnimationProps(): AnimationProps { */ function addAnimationProps( props: AnimationProps, - data: AnimationData, + animationResult: CSSAnimationResult, effect: Effect, ): void { const timeEffect = effect as TimeEffect; - props.names.push(data.name); - props.durations.push(`${data.duration ?? timeEffect.duration ?? 0}ms`); - props.delays.push(`${data.delay ?? timeEffect.delay ?? 0}ms`); + props.names.push(animationResult.name); + props.durations.push(`${timeEffect.duration ?? 0}ms`); + props.delays.push(`${timeEffect.delay ?? 0}ms`); - const easing = data.easing ?? timeEffect.easing; - props.timingFunctions.push(getEasing(easing)); + props.timingFunctions.push(getEasing(timeEffect.easing)); - const iterations = data.iterations ?? timeEffect.iterations; props.iterationCounts.push( - iterations === 0 ? 'infinite' : String(iterations ?? 1), + timeEffect.iterations === 0 ? 'infinite' : String(timeEffect.iterations ?? 1), ); - const alternate = data.alternate ?? timeEffect.alternate; - const reversed = data.reversed ?? timeEffect.reversed; - props.directions.push(getDirection(alternate, reversed)); + props.directions.push(getDirection(timeEffect.alternate, timeEffect.reversed)); - const fill = data.fill ?? timeEffect.fill ?? 'none'; - props.fillModes.push(fill); + props.fillModes.push(timeEffect.fill ?? 'none'); } /** @@ -397,13 +317,10 @@ export function getCSS(config: InteractConfig): GetCSSResult { for (const effectRef of interaction.effects) { // Resolve the effect - const effect = resolveEffect(effectRef, config.effects); + const effect = resolveEffect(effectRef, config.effects, interaction.key); if (!effect) continue; - // Skip customEffect (JS-only) - if (isCustomEffect(effect)) continue; - - // Get animation data + // Get animation data using motion's getCSSAnimation const animationDataList = getAnimationData(effect); if (animationDataList.length === 0) continue; From b117e78ff82c4bfe891ff4384da50d99c6306d50 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Wed, 17 Dec 2025 14:14:48 +0200 Subject: [PATCH 05/18] wip --- packages/interact/src/core/getCSS.ts | 305 +++++++++++---------------- packages/interact/src/types.ts | 4 +- packages/interact/src/utils.ts | 2 +- 3 files changed, 123 insertions(+), 188 deletions(-) diff --git a/packages/interact/src/core/getCSS.ts b/packages/interact/src/core/getCSS.ts index 9134d7b2..313a2322 100644 --- a/packages/interact/src/core/getCSS.ts +++ b/packages/interact/src/core/getCSS.ts @@ -7,50 +7,32 @@ import type { TimeEffect, TransitionEffect, CreateTransitionCSSParams, - Condition, + Interaction, } from '../types'; -import { createTransitionCSS } from '../utils'; +import { createTransitionCSS, applySelectorCondition } from '../utils'; import { getSelector } from './Interact'; import { effectToAnimationOptions } from '../handlers/utilities'; -import { getCSSAnimation, getEasing } from '@wix/motion'; - -// ============================================================================ -// Types -// ============================================================================ +import { getCSSAnimation } from '@wix/motion'; type KeyframeProperty = Record; - interface AnimationProps { - names: string[]; - durations: string[]; - delays: string[]; - timingFunctions: string[]; - iterationCounts: string[]; - directions: string[]; - fillModes: string[]; + animations: string[], + compositions: CompositeOperation[], + custom: KeyframeProperty, } -// ============================================================================ -// Helper Utilities -// ============================================================================ +interface CSSAnimationResult { + animation: string, + composition: CompositeOperation, + custom: KeyframeProperty, + name: string, + keyframes: KeyframeProperty[], +} -/** - * Checks if the trigger is a time-based trigger (not scrub-based). - */ function isTimeTrigger(trigger: TriggerType): boolean { return !['viewProgress', 'pointerMove'].includes(trigger); } -/** - * Determines the animation-direction CSS value based on alternate and reversed flags. - */ -function getDirection(alternate?: boolean, reversed?: boolean): string { - if (alternate && reversed) return 'alternate-reverse'; - if (alternate) return 'alternate'; - if (reversed) return 'reverse'; - return 'normal'; -} - /** * Interpolates missing offset values in keyframes array. * When offsets are not provided, they are evenly distributed. @@ -130,60 +112,69 @@ function keyframesToCSS(name: string, keyframes: KeyframeProperty[]): string { return `@keyframes ${name} { ${keyframeBlocks} }`; } -/** - * Gets animation data from an effect using the motion library's getCSSAnimation. - */ -function getTransitionData(effect: Effect & { key: string }): string[] { +function shortestCompositionPatternLength(compositions: CompositeOperation[]): number { + let patternLength = 1; + let index = 1; + while (index < compositions.length) { + if (compositions[index] === compositions[index % patternLength]) { + index++; + } else { + patternLength = Math.max(index - patternLength, patternLength) + 1; + index = patternLength; + } + } + return patternLength; +} + +function getTransitionRules(effect: Effect & { key: string }, childSelector: string): string[] { const args: CreateTransitionCSSParams = { key: effect.key, effectId: (effect as Effect).effectId!, transition: (effect as TransitionEffect).transition, properties: (effect as TransitionEffect).transitionProperties, - childSelector: getSelector(effect, { - // TODO: (ameerf) - paste the right conditions here - asCombinator: true, - addItemFilter: true, - }), + childSelector, }; return createTransitionCSS(args); } -interface CSSAnimationResult { - name: string; - keyframes: KeyframeProperty[]; -} - -/** - * Gets animation data from an effect using the motion library's getCSSAnimation. - */ function getAnimationData(effect: Effect): CSSAnimationResult[] { const animationOptions = effectToAnimationOptions(effect as TimeEffect); - - // Use getCSSAnimation from motion to get the animation data + const cssAnimations = getCSSAnimation(null, animationOptions); return cssAnimations - .filter((anim) => anim.name !== undefined) + .filter((anim) => anim.name) .map((anim) => ({ - name: anim.name!, - keyframes: anim.keyframes as KeyframeProperty[], + animation: anim.animation, + composition: anim.composition || 'replace', + custom: anim.custom || {}, + name: anim.name, + keyframes: anim.keyframes, })); } function resolveEffect( effectRef: Effect | EffectRef, effectsMap: Record, - interactionKey: string, -): Effect | null { + interaction: Interaction, +): (Effect & { key: string }) | null { const fullEffect: any = effectRef.effectId ? { ...effectsMap[effectRef.effectId], ...effectRef} : { ...effectRef}; if (fullEffect.namedEffect || fullEffect.keyframeEffect || fullEffect.transition) { if (!fullEffect.key) { - fullEffect.key = interactionKey; + fullEffect.key = interaction.key; + } + if (interaction.conditions && interaction.conditions.length) { + fullEffect.conditions = [...new Set(...interaction.conditions, ...(fullEffect.conditions || []))] } - return fullEffect as Effect; + + const {keyframeEffect} = fullEffect; + if (keyframeEffect && !keyframeEffect.name) { + keyframeEffect.name = effectRef.keyframeEffect ? generateUniqueKeyframesName(keyframeEffect) : effectRef.effectId; + } + return fullEffect; } return null; @@ -193,103 +184,54 @@ function resolveEffect( * Builds the full CSS selector for an element. */ function buildSelector( - key: string, - effect: Effect, - addItemFilter = false, + effect: Effect & {key: string}, + childSelector: string, + selectorCondition?: string ): string { - const keySelector = `[data-interact-key="${key}"]`; - const childSelector = getSelector(effect, { addItemFilter }); - return `${keySelector} ${childSelector}`; + const escapedKey = effect.key.replace(/"/g, "'"); + const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; + return selectorCondition + ? applySelectorCondition(selector, selectorCondition) + : selector; } -/** - * Creates empty animation props object. - */ function createEmptyAnimationProps(): AnimationProps { return { - names: [], - durations: [], - delays: [], - timingFunctions: [], - iterationCounts: [], - directions: [], - fillModes: [], + animations: [], + compositions: [], + custom: {}, }; } -/** - * Adds animation data properties to the animation props object. - */ function addAnimationProps( props: AnimationProps, animationResult: CSSAnimationResult, - effect: Effect, ): void { - const timeEffect = effect as TimeEffect; - - props.names.push(animationResult.name); - props.durations.push(`${timeEffect.duration ?? 0}ms`); - props.delays.push(`${timeEffect.delay ?? 0}ms`); - - props.timingFunctions.push(getEasing(timeEffect.easing)); - - props.iterationCounts.push( - timeEffect.iterations === 0 ? 'infinite' : String(timeEffect.iterations ?? 1), - ); - - props.directions.push(getDirection(timeEffect.alternate, timeEffect.reversed)); - - props.fillModes.push(timeEffect.fill ?? 'none'); + props.animations.push(animationResult.animation); + props.compositions.push(animationResult.composition); + props.custom = {...props.custom, ...animationResult.custom}; } -/** - * Builds a CSS animation rule from animation props. - */ function buildAnimationRule(selector: string, props: AnimationProps): string { const declarations: string[] = []; - declarations.push(`animation-name: ${props.names.join(', ')};`); - declarations.push(`animation-duration: ${props.durations.join(', ')};`); - declarations.push(`animation-delay: ${props.delays.join(', ')};`); - declarations.push( - `animation-timing-function: ${props.timingFunctions.join(', ')};`, - ); - declarations.push( - `animation-iteration-count: ${props.iterationCounts.join(', ')};`, - ); - declarations.push(`animation-direction: ${props.directions.join(', ')};`); - declarations.push(`animation-fill-mode: ${props.fillModes.join(', ')};`); + let {compositions} = props; + const compositionRepeatLength = shortestCompositionPatternLength(props.compositions); + compositions = compositions.slice(0, compositionRepeatLength); + if (compositions.length === 1 && compositions[0] === 'replace') { + compositions = []; + } - return `${selector} { ${declarations.join(' ')} }`; -} + const {animations, custom} = props; -/** - * Wraps a CSS rule in a media query. - */ -function wrapInMediaQuery(rule: string, predicate: string): string { - return `@media (${predicate}) { ${rule} }`; -} + declarations.push(`animation: ${animations.join(', ')};`); + declarations.push(`animation-composition: ${compositions.join(', ')};`); -/** - * Gets the media predicate for conditions. - */ -function getMediaPredicate( - conditionIds: string[] | undefined, - conditions: Record | undefined, -): string | null { - if (!conditionIds || conditionIds.length === 0 || !conditions) { - return null; + for (const [key, val] of Object.entries(custom)) { + declarations.push(`${key}: ${val};`); } - // Find the first media condition - for (const id of conditionIds) { - const condition = conditions[id]; - if (condition && condition.type === 'media' && condition.predicate) { - return condition.predicate; - } - } - - return null; + return `${selector} { ${declarations.join(' ')} }`; } // ============================================================================ @@ -303,90 +245,81 @@ function getMediaPredicate( * @returns GetCSSResult with keyframes and animationRules */ export function getCSS(config: InteractConfig): GetCSSResult { + const transitionRules: string[] = []; const keyframeMap = new Map(); // name -> CSS @keyframes rule - const selectorPropsMap = new Map< + const selectorPropsMap = new Map< // selector -> condition -> CSS props to apply string, - { props: AnimationProps; conditions: string | null } + Record >(); + for (const interaction of config.interactions) { - // Skip non-time-based triggers if (!isTimeTrigger(interaction.trigger)) { continue; } for (const effectRef of interaction.effects) { - // Resolve the effect - const effect = resolveEffect(effectRef, config.effects, interaction.key); + const effect = resolveEffect(effectRef, config.effects, interaction); if (!effect) continue; - // Get animation data using motion's getCSSAnimation - const animationDataList = getAnimationData(effect); - if (animationDataList.length === 0) continue; - - // Build selector - use effect's key or fall back to interaction key - const targetKey = effect.key || interaction.key; const hasListItemSelector = Boolean(effect.listItemSelector); - const selector = buildSelector(targetKey, effect, hasListItemSelector); - - // Get media predicate for conditions - const mediaPredicate = getMediaPredicate( - effectRef.conditions, - config.conditions, - ); - - // Create a unique key for the selector + conditions combination - const selectorKey = mediaPredicate - ? `${selector}::${mediaPredicate}` - : selector; - - // Get or create props for this selector - if (!selectorPropsMap.has(selectorKey)) { - selectorPropsMap.set(selectorKey, { - props: createEmptyAnimationProps(), - conditions: mediaPredicate, - }); + const childSelector = getSelector(effect, { + asCombinator: true, + addItemFilter: hasListItemSelector, // TODO: (ameerf) - correct? + }); + + if ( + (effect as TransitionEffect).transition || + (effect as TransitionEffect).transitionProperties + ) { + // TODO: (ameerf) - Do we want to override transition property like this? + transitionRules.push(...getTransitionRules(effect, childSelector)); } - const { props } = selectorPropsMap.get(selectorKey)!; - // Process each animation data - for (const data of animationDataList) { - // Add keyframe (deduplicated) - if (data.name && !keyframeMap.has(data.name)) { + if ( + (effect as any).namedEffect || + (effect as any).keyframeEffect + ) { + const animationDataList = getAnimationData(effect); + if (animationDataList.length === 0) continue; + + const escapedKey = effect.key.replace(/"/g, "'"); + const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; + if (!selectorPropsMap.has(selector)) { + selectorPropsMap.set(selector, {}); + } + + for (const data of animationDataList) { const keyframeCSS = keyframesToCSS(data.name, data.keyframes); if (keyframeCSS) { keyframeMap.set(data.name, keyframeCSS); } - } - // Add animation properties - addAnimationProps(props, data, effect); + const conditionToProps = selectorPropsMap.get(selector)!; + const conditions = effect.conditions?.length ? effect.conditions : ['']; + for (const condition of conditions) { + if (!conditionToProps[condition]) { + conditionToProps[condition] = createEmptyAnimationProps(); + } + addAnimationProps(conditionToProps[condition], data); + } + } } } } - // Build animation rules const animationRules: string[] = []; - for (const [selectorKey, { props, conditions }] of selectorPropsMap) { - if (props.names.length === 0) continue; - - // Extract the actual selector (remove conditions suffix if present) - const selector = conditions - ? selectorKey.substring(0, selectorKey.lastIndexOf('::')) - : selectorKey; - - let rule = buildAnimationRule(selector, props); - - // Wrap in media query if conditions exist - if (conditions) { - rule = wrapInMediaQuery(rule, conditions); + for (const [baseSelector, conditionsMap] of selectorPropsMap) { + for (const [condition, props] of Object.entries(conditionsMap)) { + if (props.animations.length === 0) continue; + const rule = buildAnimationRule(baseSelector, props, condition); + animationRules.push(rule); } - - animationRules.push(rule); } return { keyframes: Array.from(keyframeMap.values()), animationRules, + transitionRules }; } diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 7a084206..cdfc86b4 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -285,6 +285,8 @@ export type CreateTransitionCSSParams = { export type GetCSSResult = { /** @keyframes rules for the animations */ keyframes: string[]; - /** Full animation property rules per element (animation-name, duration, timing-function, fill-mode, etc.) */ + /** Full animation property rules per element (animation, composition, custom, etc.) */ animationRules: string[]; + /** Full transition properties rules per element */ + transitionRules: string[]; }; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index c2271102..5afe1225 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -6,7 +6,7 @@ import type { Condition, CreateTransitionCSSParams } from './types'; * - If `&` is in the predicate, replace `&` with the base selector * - If no `&`, assume `&` (append predicate to base selector) */ -function applySelectorCondition(baseSelector: string, predicate: string): string { +export function applySelectorCondition(baseSelector: string, predicate: string): string { if (predicate.includes('&')) { return predicate.replace(/&/g, baseSelector); } From 8a0c42860f9c8e0f948c126cd4643b7e57ba992f Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 22 Dec 2025 01:55:18 +0200 Subject: [PATCH 06/18] wip --- packages/interact/src/core/css.ts | 289 +++++++++++++++- packages/interact/src/core/getCSS.ts | 325 ------------------ packages/interact/src/core/utilities.ts | 92 +++++ packages/interact/src/index.ts | 1 - packages/interact/src/types.ts | 1 + packages/interact/src/utils.ts | 39 ++- .../test/{getCSS.spec.ts => css.spec.ts} | 4 +- 7 files changed, 406 insertions(+), 345 deletions(-) delete mode 100644 packages/interact/src/core/getCSS.ts rename packages/interact/test/{getCSS.spec.ts => css.spec.ts} (99%) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 1b8411c6..24a0fa3f 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -1,17 +1,280 @@ -import { InteractConfig } from '../types'; - -export function generate(_config: InteractConfig): string { - const css: string[] = [ - `@media (prefers-reduced-motion: no-preference) { - [data-interact-initial="true"] > :first-child:not([data-motion-enter="done"]) { - visibility: hidden; - transform: none; - translate: none; - scale: none; - rotate: none; +import type { + InteractConfig, + GetCSSResult, + Effect, + EffectRef, + TimeEffect, + TransitionEffect, + CreateTransitionCSSParams, + Interaction, + Condition, +} from '../types'; +import { + createTransitionCSS, + applySelectorCondition, + generateId, + isTimeTrigger, + shortestRepeatingPatternLength, + getFullPredicateByType, + getSelectorCondition +} from '../utils'; +import { getSelector } from './Interact'; +import { keyframesToCSS } from './utilities'; +import { effectToAnimationOptions } from '../handlers/utilities'; +import { getCSSAnimation, MotionKeyframeEffect } from '@wix/motion'; + +const DEFAULT_INITIAL = { + visibility: 'hidden', + transform: 'none', + translate: 'none', + scale: 'none', + rotate: 'none', +}; + +interface AnimationProps { + animations: string[], + compositions: CompositeOperation[], + custom: Keyframe, +} +interface CSSAnimationResult { + animation: string, + composition: CompositeOperation, + custom: Keyframe, + name: string, + keyframes: Keyframe[], +} + +function createEmptyAnimationProps(): AnimationProps { + return { + animations: [], + compositions: [], + custom: {}, + }; +} + +function addAnimationProps( + props: AnimationProps, + animationResult: CSSAnimationResult, +): void { + props.animations.push(animationResult.animation); + props.compositions.push(animationResult.composition); + props.custom = {...props.custom, ...animationResult.custom}; +} + +function getTransitionRules( + effect: Effect & { key: string }, + childSelector: string +): string[] { + const args: CreateTransitionCSSParams = { + key: effect.key, + effectId: (effect as Effect).effectId!, + transition: (effect as TransitionEffect).transition, + properties: (effect as TransitionEffect).transitionProperties, + childSelector, + }; + return createTransitionCSS(args); +} + +function getAnimationData(effect: Effect): CSSAnimationResult[] { + const animationOptions = effectToAnimationOptions(effect as TimeEffect); + + const cssAnimations = getCSSAnimation(null, animationOptions); + + return cssAnimations + .filter((anim) => anim.name) + .map((anim) => ({ + animation: anim.animation, + composition: anim.composition || 'replace', + custom: anim.custom || {}, + name: anim.name as string, + keyframes: anim.keyframes, + })); +} + +function resolveEffect( + effectRef: Effect | EffectRef, + effectsMap: Record, + interaction: Interaction, + conditionDefinitions: Record, +): (Effect & { key: string }) | null { + const fullEffect: any = effectRef.effectId + ? { ...effectsMap[effectRef.effectId], ...effectRef } + : { ...effectRef }; + + if (fullEffect.namedEffect || fullEffect.keyframeEffect || fullEffect.transition) { + if (!fullEffect.key) { + fullEffect.key = interaction.key; + } + if (interaction.conditions && interaction.conditions.length) { + fullEffect.conditions = [...new Set(...interaction.conditions, ...(fullEffect.conditions || []))] + .filter((condition) => conditionDefinitions[condition]); + } + + const {keyframeEffect} = fullEffect; + if (keyframeEffect && !keyframeEffect.name) { + keyframeEffect.name = (effectRef as TimeEffect & { keyframeEffect: MotionKeyframeEffect }).keyframeEffect ? + generateId() : effectRef.effectId; + } + + fullEffect.initial = fullEffect.initial === 'disable' ? + undefined : (fullEffect.initial || DEFAULT_INITIAL); + + return fullEffect; + } + + return null; +} + +function buildAnimationRule( + selector: string, + props: AnimationProps, + conditionNames: string[], + configConditions: Record +): string { + const declarations: string[] = []; + + let { compositions } = props; + const compositionRepeatLength = shortestRepeatingPatternLength(props.compositions); + compositions = compositions.slice(0, compositionRepeatLength); + if (compositions.length === 1 && compositions[0] === 'replace') { + compositions = []; + } + + const { animations, custom } = props; + + declarations.push(`animation: ${animations.join(', ')};`); + declarations.push(`animation-composition: ${compositions.join(', ')};`); + + for (const [key, val] of Object.entries(custom)) { + if (val !== undefined && val !== null) { + declarations.push(`${key}: ${val};`); + } + } + + // TODO: (ameerf) - getSelectorCondition takes the first selector - fix it to get the AND of all selectors + const selectorCondition = getSelectorCondition(conditionNames, configConditions); + const targetSelector = selectorCondition ? + applySelectorCondition(selector, selectorCondition) : selector; + + let rule = `${targetSelector} { ${declarations.join(' ')} }`; + + ['container' as const, 'media' as const].forEach((type) => { + const predicate = getFullPredicateByType(conditionNames, configConditions, type); + if (predicate) { + rule = `@${type} ${predicate} { ${rule} }`; + } + }); + + return rule; +} + +/** + * Generates CSS for time-based animations from an InteractConfig. + * + * @param config - The interact configuration containing effects and interactions + * @returns GetCSSResult with keyframes and animationRules + */ +export function getCSS(config: InteractConfig): GetCSSResult { + const transitionRules: string[] = []; + const keyframeMap = new Map(); // name -> CSS @keyframes rule + const selectorPropsMap = new Map< // selector -> conditionSetHash -> CSS props to apply + string, + Map + >(); + + const configConditions = config.conditions || {}; + const conditionHashNumbers = Object.keys(configConditions).reduce((acc, condition, index) => { + acc[condition] = 1 << index; + return acc; + }, {} as Record ); + + for (const interaction of config.interactions) { + if (!isTimeTrigger(interaction.trigger)) { + continue; + } + + for (const effectRef of interaction.effects) { + const effect = resolveEffect(effectRef, config.effects, interaction, configConditions); + if (!effect) continue; + + const childSelector = getSelector(effect, { + asCombinator: true, // TODO: (ameerf) - correct? + addItemFilter: Boolean(effect.listItemSelector), // TODO: (ameerf) - correct? + }); + + if ( + (effect as TransitionEffect).transition || + (effect as TransitionEffect).transitionProperties + ) { + // TODO: (ameerf) - Do we want to override transition property like this? + transitionRules.push(...getTransitionRules(effect, childSelector)); + } + + if ( + (effect as any).namedEffect || + (effect as any).keyframeEffect + ) { + const animationDataList = getAnimationData(effect); + if (animationDataList.length === 0) continue; + + const escapedKey = effect.key.replace(/"/g, "'"); + const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; + if (!selectorPropsMap.has(selector)) { + selectorPropsMap.set(selector, new Map()); + } + + for (const data of animationDataList) { + const keyframeCSS = keyframesToCSS(data.name, data.keyframes, effect.initial); + if (keyframeCSS) { + keyframeMap.set(data.name, keyframeCSS); + } + + const conditions = effect.conditions?.length ? effect.conditions : ['']; + const conditionSetHash = conditions.reduce((acc, condition) => {return acc + conditionHashNumbers[condition]}, 0); + const conditionToProps = selectorPropsMap.get(selector)!; + if (!conditionToProps.has) { + conditionToProps.set(conditionSetHash, createEmptyAnimationProps()); + } + + const props = conditionToProps.get(conditionSetHash); + addAnimationProps(props!, data); + } + } + } + } + + const animationRules: string[] = []; + for (const [baseSelector, conditionsMap] of selectorPropsMap) { + for (const [conditionSetHash, props] of conditionsMap) { + if (props.animations.length === 0) { + continue; + } + const conditions = Object.entries(conditionHashNumbers).reduce((acc, [condition, hash]) => { + if (hash & conditionSetHash) { + acc.push(condition); + } + return acc; + }, [] as string[]); + const rule = buildAnimationRule( + baseSelector, + props, + conditions, + configConditions + ); + animationRules.push(rule); + } } -}` - ]; + + return { + keyframes: Array.from(keyframeMap.values()), + animationRules, + transitionRules + }; +} + +export function generate(config: InteractConfig): string { + const {keyframes, animationRules, transitionRules} = getCSS(config); + const css: string[] = [...keyframes, ...animationRules, ...transitionRules]; return css.join('\n'); } diff --git a/packages/interact/src/core/getCSS.ts b/packages/interact/src/core/getCSS.ts deleted file mode 100644 index 313a2322..00000000 --- a/packages/interact/src/core/getCSS.ts +++ /dev/null @@ -1,325 +0,0 @@ -import type { - InteractConfig, - GetCSSResult, - TriggerType, - Effect, - EffectRef, - TimeEffect, - TransitionEffect, - CreateTransitionCSSParams, - Interaction, -} from '../types'; -import { createTransitionCSS, applySelectorCondition } from '../utils'; -import { getSelector } from './Interact'; -import { effectToAnimationOptions } from '../handlers/utilities'; -import { getCSSAnimation } from '@wix/motion'; - -type KeyframeProperty = Record; -interface AnimationProps { - animations: string[], - compositions: CompositeOperation[], - custom: KeyframeProperty, -} - -interface CSSAnimationResult { - animation: string, - composition: CompositeOperation, - custom: KeyframeProperty, - name: string, - keyframes: KeyframeProperty[], -} - -function isTimeTrigger(trigger: TriggerType): boolean { - return !['viewProgress', 'pointerMove'].includes(trigger); -} - -/** - * Interpolates missing offset values in keyframes array. - * When offsets are not provided, they are evenly distributed. - */ -function interpolateOffsets( - keyframes: KeyframeProperty[], -): KeyframeProperty[] { - if (keyframes.length === 0) return keyframes; - - const result = keyframes.map((kf) => ({ ...kf })); - const n = result.length; - - // Set first and last if not present - if (result[0].offset === undefined) { - result[0].offset = 0; - } - if (result[n - 1].offset === undefined) { - result[n - 1].offset = 1; - } - - // Find segments between defined offsets and interpolate - let lastDefinedIndex = 0; - for (let i = 1; i < n; i++) { - if (result[i].offset !== undefined) { - // Interpolate between lastDefinedIndex and i - const startOffset = result[lastDefinedIndex].offset as number; - const endOffset = result[i].offset as number; - const gap = i - lastDefinedIndex; - - for (let j = lastDefinedIndex + 1; j < i; j++) { - const progress = (j - lastDefinedIndex) / gap; - result[j].offset = startOffset + (endOffset - startOffset) * progress; - } - - lastDefinedIndex = i; - } - } - - return result; -} - -/** - * Rounds a number to avoid floating point precision issues. - */ -function roundOffset(offset: number): number { - // Round to 2 decimal places - return Math.round(offset * 100) / 100; -} - -/** - * Converts keyframes array to CSS @keyframes rule string. - */ -function keyframesToCSS(name: string, keyframes: KeyframeProperty[]): string { - if (!keyframes || keyframes.length === 0) return ''; - - const interpolated = interpolateOffsets(keyframes); - - const keyframeBlocks = interpolated - .map((kf) => { - const offset = kf.offset as number; - const percentage = roundOffset(offset * 100); - - // Filter out offset and build property string - const properties = Object.entries(kf) - .filter(([key]) => key !== 'offset') - .map(([key, value]) => { - // Convert camelCase to kebab-case for CSS - const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase(); - return `${cssKey}: ${value};`; - }) - .join(' '); - - return `${percentage}% { ${properties} }`; - }) - .join(' '); - - return `@keyframes ${name} { ${keyframeBlocks} }`; -} - -function shortestCompositionPatternLength(compositions: CompositeOperation[]): number { - let patternLength = 1; - let index = 1; - while (index < compositions.length) { - if (compositions[index] === compositions[index % patternLength]) { - index++; - } else { - patternLength = Math.max(index - patternLength, patternLength) + 1; - index = patternLength; - } - } - return patternLength; -} - -function getTransitionRules(effect: Effect & { key: string }, childSelector: string): string[] { - const args: CreateTransitionCSSParams = { - key: effect.key, - effectId: (effect as Effect).effectId!, - transition: (effect as TransitionEffect).transition, - properties: (effect as TransitionEffect).transitionProperties, - childSelector, - }; - return createTransitionCSS(args); -} - -function getAnimationData(effect: Effect): CSSAnimationResult[] { - const animationOptions = effectToAnimationOptions(effect as TimeEffect); - - const cssAnimations = getCSSAnimation(null, animationOptions); - - return cssAnimations - .filter((anim) => anim.name) - .map((anim) => ({ - animation: anim.animation, - composition: anim.composition || 'replace', - custom: anim.custom || {}, - name: anim.name, - keyframes: anim.keyframes, - })); -} - -function resolveEffect( - effectRef: Effect | EffectRef, - effectsMap: Record, - interaction: Interaction, -): (Effect & { key: string }) | null { - const fullEffect: any = effectRef.effectId - ? { ...effectsMap[effectRef.effectId], ...effectRef} - : { ...effectRef}; - - if (fullEffect.namedEffect || fullEffect.keyframeEffect || fullEffect.transition) { - if (!fullEffect.key) { - fullEffect.key = interaction.key; - } - if (interaction.conditions && interaction.conditions.length) { - fullEffect.conditions = [...new Set(...interaction.conditions, ...(fullEffect.conditions || []))] - } - - const {keyframeEffect} = fullEffect; - if (keyframeEffect && !keyframeEffect.name) { - keyframeEffect.name = effectRef.keyframeEffect ? generateUniqueKeyframesName(keyframeEffect) : effectRef.effectId; - } - return fullEffect; - } - - return null; -} - -/** - * Builds the full CSS selector for an element. - */ -function buildSelector( - effect: Effect & {key: string}, - childSelector: string, - selectorCondition?: string -): string { - const escapedKey = effect.key.replace(/"/g, "'"); - const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; - return selectorCondition - ? applySelectorCondition(selector, selectorCondition) - : selector; -} - -function createEmptyAnimationProps(): AnimationProps { - return { - animations: [], - compositions: [], - custom: {}, - }; -} - -function addAnimationProps( - props: AnimationProps, - animationResult: CSSAnimationResult, -): void { - props.animations.push(animationResult.animation); - props.compositions.push(animationResult.composition); - props.custom = {...props.custom, ...animationResult.custom}; -} - -function buildAnimationRule(selector: string, props: AnimationProps): string { - const declarations: string[] = []; - - let {compositions} = props; - const compositionRepeatLength = shortestCompositionPatternLength(props.compositions); - compositions = compositions.slice(0, compositionRepeatLength); - if (compositions.length === 1 && compositions[0] === 'replace') { - compositions = []; - } - - const {animations, custom} = props; - - declarations.push(`animation: ${animations.join(', ')};`); - declarations.push(`animation-composition: ${compositions.join(', ')};`); - - for (const [key, val] of Object.entries(custom)) { - declarations.push(`${key}: ${val};`); - } - - return `${selector} { ${declarations.join(' ')} }`; -} - -// ============================================================================ -// Main Function -// ============================================================================ - -/** - * Generates CSS for time-based animations from an InteractConfig. - * - * @param config - The interact configuration containing effects and interactions - * @returns GetCSSResult with keyframes and animationRules - */ -export function getCSS(config: InteractConfig): GetCSSResult { - const transitionRules: string[] = []; - const keyframeMap = new Map(); // name -> CSS @keyframes rule - const selectorPropsMap = new Map< // selector -> condition -> CSS props to apply - string, - Record - >(); - - - for (const interaction of config.interactions) { - if (!isTimeTrigger(interaction.trigger)) { - continue; - } - - for (const effectRef of interaction.effects) { - const effect = resolveEffect(effectRef, config.effects, interaction); - if (!effect) continue; - - const hasListItemSelector = Boolean(effect.listItemSelector); - const childSelector = getSelector(effect, { - asCombinator: true, - addItemFilter: hasListItemSelector, // TODO: (ameerf) - correct? - }); - - if ( - (effect as TransitionEffect).transition || - (effect as TransitionEffect).transitionProperties - ) { - // TODO: (ameerf) - Do we want to override transition property like this? - transitionRules.push(...getTransitionRules(effect, childSelector)); - } - - if ( - (effect as any).namedEffect || - (effect as any).keyframeEffect - ) { - const animationDataList = getAnimationData(effect); - if (animationDataList.length === 0) continue; - - const escapedKey = effect.key.replace(/"/g, "'"); - const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; - if (!selectorPropsMap.has(selector)) { - selectorPropsMap.set(selector, {}); - } - - for (const data of animationDataList) { - const keyframeCSS = keyframesToCSS(data.name, data.keyframes); - if (keyframeCSS) { - keyframeMap.set(data.name, keyframeCSS); - } - - const conditionToProps = selectorPropsMap.get(selector)!; - const conditions = effect.conditions?.length ? effect.conditions : ['']; - for (const condition of conditions) { - if (!conditionToProps[condition]) { - conditionToProps[condition] = createEmptyAnimationProps(); - } - addAnimationProps(conditionToProps[condition], data); - } - } - } - } - } - - const animationRules: string[] = []; - for (const [baseSelector, conditionsMap] of selectorPropsMap) { - for (const [condition, props] of Object.entries(conditionsMap)) { - if (props.animations.length === 0) continue; - const rule = buildAnimationRule(baseSelector, props, condition); - animationRules.push(rule); - } - } - - return { - keyframes: Array.from(keyframeMap.values()), - animationRules, - transitionRules - }; -} diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index 9099e71a..70ba514c 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -1,3 +1,5 @@ +import { roundNumber } from '../utils'; + export function _processKeysForInterpolation(key: string) { return [...key.matchAll(/\[([-\w]+)]/g)].map( ([_, _instanceKey]) => _instanceKey, @@ -12,3 +14,93 @@ export function getInterpolatedKey(template: string, key: string) { ? template.replace(/\[]/g, () => `[${keys[index++]}]` || '[]') : template; } + + +function interpolateKeyframesOffsets( + keyframes: Keyframe[], +): Keyframe[] { + if (!keyframes || keyframes.length === 0) 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[lastDefinedIndex].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; +} + +function keyframePropertyToCSS(key: string): string { + if (key === 'cssFloat') { + return 'float' + } + if (key === 'easing') { + return 'animation-timing-function' + } + if (key === 'cssOffset') { + return 'offset' + } + return key.replace(/([A-Z])/g, '-$1').toLowerCase(); +} + +export function keyframesToCSS(name: string, keyframes: Keyframe[], initial?: any): string { + const interpolated = interpolateKeyframesOffsets(keyframes); + if (keyframes.length === 0) return ''; + + let keyframeBlocks = interpolated + .map((kf) => { + const offset = kf.offset as number; + const percentage = roundNumber(offset * 100); + + const properties = Object.entries(kf) + .filter(([key, value]) => key !== 'offset' && value !== undefined && value !== null) + .map(([key, value]) => { + const cssKey = keyframePropertyToCSS(key); + return `${cssKey}: ${value};`; + }) + .join(' '); + + return `${percentage}% { ${properties} }`; + }) + .join(' '); + + if (initial) { + const fromFrame = Object.entries(initial) + .map(([key, value]) => { + return `${key}: ${value};`; + }) + .join(' '); + keyframeBlocks = `from { ${fromFrame} } ${keyframeBlocks}`; + } + + return `@keyframes ${name} { ${keyframeBlocks} }`; +} diff --git a/packages/interact/src/index.ts b/packages/interact/src/index.ts index 9783664f..bffc5e20 100644 --- a/packages/interact/src/index.ts +++ b/packages/interact/src/index.ts @@ -1,6 +1,5 @@ export { Interact } from './core/Interact'; export { add, remove } from './dom/api'; -export { getCSS } from './core/getCSS'; export { generate } from './core/css'; export * from './types'; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index cdfc86b4..968a3b62 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -136,6 +136,7 @@ export type EffectBase = { conditions?: string[]; selector?: string; effectId?: string; + initial?: 'disable' | Record; }; export type EffectRef = EffectBase & { effectId: string }; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 5afe1225..00d323f5 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -1,5 +1,28 @@ import { getEasing } from '@wix/motion'; -import type { Condition, CreateTransitionCSSParams } from './types'; +import type { TriggerType, Condition, CreateTransitionCSSParams } from './types'; + + +export function isTimeTrigger(trigger: TriggerType): boolean { + return !['viewProgress', 'pointerMove'].includes(trigger); +} + +export function roundNumber(num: number, precision = 2): number { + return parseFloat(num.toFixed(precision)); +} + +export function shortestRepeatingPatternLength(values: string[] | number[]): number { + let patternLength = 1; + let index = 1; + while (index < values.length) { + if (values[index] === values[index % patternLength]) { + index++; + } else { + patternLength = Math.max(index - patternLength, patternLength) + 1; + index = patternLength; + } + } + return patternLength; +} /** * Applies a selector condition predicate to a base selector. @@ -106,20 +129,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; diff --git a/packages/interact/test/getCSS.spec.ts b/packages/interact/test/css.spec.ts similarity index 99% rename from packages/interact/test/getCSS.spec.ts rename to packages/interact/test/css.spec.ts index d1ce84d2..b8a7e5f2 100644 --- a/packages/interact/test/getCSS.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import type { InteractConfig, TimeEffect, Effect } from '../src/types'; import type { NamedEffect } from '@wix/motion'; -import { getCSS } from '../src/core/getCSS'; +import { getCSS } from '../src/core/css'; import { getSelector } from '../src/core/Interact'; /** @@ -971,7 +971,7 @@ describe('getCSS', () => { // ============================================================================ // SUITE 2: Animation Rules // ============================================================================ - describe('animation rules', () => { + describe.skip('animation rules', () => { describe('animation-name', () => { it('should include animation-name with selector for namedEffect', () => { const result = getCSS(fadeInConfig); From c030958ada56daee54cf62038efd386a48213093 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 22 Dec 2025 01:58:56 +0200 Subject: [PATCH 07/18] wip --- packages/interact/test/css.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index b8a7e5f2..a277a531 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -971,6 +971,7 @@ describe('getCSS', () => { // ============================================================================ // SUITE 2: Animation Rules // ============================================================================ + // TODO: (ameerf) - fix this to use animation short-hand and unskip describe.skip('animation rules', () => { describe('animation-name', () => { it('should include animation-name with selector for namedEffect', () => { From f6917c179321b2a68686eec7a9714f689d00e67e Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 22 Dec 2025 02:12:27 +0200 Subject: [PATCH 08/18] wip --- packages/interact/src/core/css.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 24a0fa3f..3f262fd1 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -177,6 +177,7 @@ function buildAnimationRule( export function getCSS(config: InteractConfig): GetCSSResult { const transitionRules: string[] = []; const keyframeMap = new Map(); // name -> CSS @keyframes rule + // TODO: (ameerf) - this couldn't possibly be the correct mapping along with condition cascading - get a well-defined structure from ydaniv const selectorPropsMap = new Map< // selector -> conditionSetHash -> CSS props to apply string, Map @@ -229,7 +230,7 @@ export function getCSS(config: InteractConfig): GetCSSResult { keyframeMap.set(data.name, keyframeCSS); } - const conditions = effect.conditions?.length ? effect.conditions : ['']; + const conditions = effect.conditions || []; const conditionSetHash = conditions.reduce((acc, condition) => {return acc + conditionHashNumbers[condition]}, 0); const conditionToProps = selectorPropsMap.get(selector)!; if (!conditionToProps.has) { From e0849810ef6bf92a7b6aba43923a393444185e1e Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Wed, 24 Dec 2025 14:31:02 +0200 Subject: [PATCH 09/18] fixing conditions cascading logic and transitions --- packages/interact/src/core/css.ts | 237 +++++++++++++++++++----------- packages/interact/src/utils.ts | 51 +++++-- 2 files changed, 190 insertions(+), 98 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 3f262fd1..938aecd4 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -10,7 +10,7 @@ import type { Condition, } from '../types'; import { - createTransitionCSS, + createStateRuleAndCSSTransitions, applySelectorCondition, generateId, isTimeTrigger, @@ -32,9 +32,17 @@ const DEFAULT_INITIAL = { }; interface AnimationProps { - animations: string[], - compositions: CompositeOperation[], + animation: string, + composition: CompositeOperation, custom: Keyframe, + conditions: string[], + animationCustomPropName: string, +} + +interface TransitionProps { + transition: string, + conditions: string[], + transitionCustomPropName: string, } interface CSSAnimationResult { animation: string, @@ -44,27 +52,15 @@ interface CSSAnimationResult { keyframes: Keyframe[], } -function createEmptyAnimationProps(): AnimationProps { - return { - animations: [], - compositions: [], - custom: {}, - }; -} - -function addAnimationProps( - props: AnimationProps, - animationResult: CSSAnimationResult, -): void { - props.animations.push(animationResult.animation); - props.compositions.push(animationResult.composition); - props.custom = {...props.custom, ...animationResult.custom}; +interface CSSTransitionResult { + stateRule: string, + transitions: string[], } -function getTransitionRules( +function getTransitionData( effect: Effect & { key: string }, childSelector: string -): string[] { +): CSSTransitionResult { const args: CreateTransitionCSSParams = { key: effect.key, effectId: (effect as Effect).effectId!, @@ -72,7 +68,7 @@ function getTransitionRules( properties: (effect as TransitionEffect).transitionProperties, childSelector, }; - return createTransitionCSS(args); + return createStateRuleAndCSSTransitions(args); } function getAnimationData(effect: Effect): CSSAnimationResult[] { @@ -105,12 +101,13 @@ function resolveEffect( if (!fullEffect.key) { fullEffect.key = interaction.key; } + if (interaction.conditions && interaction.conditions.length) { fullEffect.conditions = [...new Set(...interaction.conditions, ...(fullEffect.conditions || []))] .filter((condition) => conditionDefinitions[condition]); } - const {keyframeEffect} = fullEffect; + const { keyframeEffect } = fullEffect; if (keyframeEffect && !keyframeEffect.name) { keyframeEffect.name = (effectRef as TimeEffect & { keyframeEffect: MotionKeyframeEffect }).keyframeEffect ? generateId() : effectRef.effectId; @@ -125,41 +122,54 @@ function resolveEffect( return null; } -function buildAnimationRule( +function buildCascadingTransitionCustomPropRule( selector: string, - props: AnimationProps, - conditionNames: string[], + props: TransitionProps, configConditions: Record -): string { - const declarations: string[] = []; +) { + const { transitionCustomPropName, transition, conditions } = props; - let { compositions } = props; - const compositionRepeatLength = shortestRepeatingPatternLength(props.compositions); - compositions = compositions.slice(0, compositionRepeatLength); - if (compositions.length === 1 && compositions[0] === 'replace') { - compositions = []; - } + const declaration: string = `${transitionCustomPropName}: ${transition};`; + + const selectorCondition = getSelectorCondition(conditions, configConditions); + const targetSelector = selectorCondition ? + applySelectorCondition(selector, selectorCondition) : selector; - const { animations, custom } = props; + let rule = `${targetSelector} { ${declaration} }`; - declarations.push(`animation: ${animations.join(', ')};`); - declarations.push(`animation-composition: ${compositions.join(', ')};`); + ['container' as const, 'media' as const].forEach((type) => { + const predicate = getFullPredicateByType(conditions, configConditions, type); + if (predicate) { + rule = `@${type} ${predicate} { ${rule} }`; + } + }); + + return rule; +} + +function buildCascadingAnimationCustomPropRule( + selector: string, + props: AnimationProps, + configConditions: Record +) { + const declarations: string[] = []; + const { animationCustomPropName, animation, custom, conditions } = props; - for (const [key, val] of Object.entries(custom)) { + const propsToApply = { [animationCustomPropName]: animation, ...custom }; + for (const [key, val] of Object.entries(propsToApply)) { if (val !== undefined && val !== null) { declarations.push(`${key}: ${val};`); } } - // TODO: (ameerf) - getSelectorCondition takes the first selector - fix it to get the AND of all selectors - const selectorCondition = getSelectorCondition(conditionNames, configConditions); + const selectorCondition = getSelectorCondition(conditions, configConditions); const targetSelector = selectorCondition ? applySelectorCondition(selector, selectorCondition) : selector; let rule = `${targetSelector} { ${declarations.join(' ')} }`; ['container' as const, 'media' as const].forEach((type) => { - const predicate = getFullPredicateByType(conditionNames, configConditions, type); + const predicate = getFullPredicateByType(conditions, configConditions, type); if (predicate) { rule = `@${type} ${predicate} { ${rule} }`; } @@ -168,6 +178,40 @@ function buildAnimationRule( return rule; } +function buildTransitionRule( + selector: string, + propsArray: TransitionProps[], +): string { + const declarations: string[] = []; + + const transitions = propsArray.map((props) => `var(${props.transitionCustomPropName}, _)`); + declarations.push(`transition: ${transitions.join(', ')};`); + + return `${selector} { ${declarations.join(' ')} }`; +} + +function buildAnimationRule( + selector: string, + propsArray: AnimationProps[], +): string { + const declarations: string[] = []; + + const animations = propsArray.map((props) => `var(${props.animationCustomPropName}, none)`); + declarations.push(`animation: ${animations.join(', ')};`); + + let compositions = propsArray.map((props) => props.composition); + const compositionRepeatLength = shortestRepeatingPatternLength(compositions); + compositions = compositions.slice(0, compositionRepeatLength); + + if (compositions.length === 1 && compositions[0] === 'replace') { + compositions = []; + } + + declarations.push(`animation-composition: ${compositions.join(', ')};`); + + return `${selector} { ${declarations.join(' ')} }`; +} + /** * Generates CSS for time-based animations from an InteractConfig. * @@ -175,19 +219,18 @@ function buildAnimationRule( * @returns GetCSSResult with keyframes and animationRules */ export function getCSS(config: InteractConfig): GetCSSResult { - const transitionRules: string[] = []; - const keyframeMap = new Map(); // name -> CSS @keyframes rule - // TODO: (ameerf) - this couldn't possibly be the correct mapping along with condition cascading - get a well-defined structure from ydaniv - const selectorPropsMap = new Map< // selector -> conditionSetHash -> CSS props to apply + const keyframeMap = new Map(); + const selectorTransitionPropsMap = new Map< + string, + TransitionProps[] + >(); + const selectorAnimationPropsMap = new Map< string, - Map + AnimationProps[] >(); + const transitionRules: string[] = []; const configConditions = config.conditions || {}; - const conditionHashNumbers = Object.keys(configConditions).reduce((acc, condition, index) => { - acc[condition] = 1 << index; - return acc; - }, {} as Record ); for (const interaction of config.interactions) { if (!isTimeTrigger(interaction.trigger)) { @@ -202,13 +245,35 @@ export function getCSS(config: InteractConfig): GetCSSResult { asCombinator: true, // TODO: (ameerf) - correct? addItemFilter: Boolean(effect.listItemSelector), // TODO: (ameerf) - correct? }); + + const escapedKey = CSS.escape(effect.key); + const keyWithNoSpecialChars = effect.key.replace(/[^a-zA-Z0-9_-]/g, ''); + const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; + const conditions = (effect.conditions || []); if ( (effect as TransitionEffect).transition || (effect as TransitionEffect).transitionProperties ) { - // TODO: (ameerf) - Do we want to override transition property like this? - transitionRules.push(...getTransitionRules(effect, childSelector)); + const {stateRule, transitions} = getTransitionData(effect, childSelector); + transitionRules.push(stateRule); + if (transitions.length === 0) { + continue; + } + + if (!selectorTransitionPropsMap.has(selector)) { + selectorTransitionPropsMap.set(selector, []); + } + const transitionPropsArray = selectorTransitionPropsMap.get(selector)!; + + for (const transition of transitions) { + const transitionCustomPropName = `--trans-def-${keyWithNoSpecialChars}-${transitionPropsArray.length}`; + transitionPropsArray.push({ + transition, + conditions, + transitionCustomPropName + }); + } } if ( @@ -216,13 +281,14 @@ export function getCSS(config: InteractConfig): GetCSSResult { (effect as any).keyframeEffect ) { const animationDataList = getAnimationData(effect); - if (animationDataList.length === 0) continue; - - const escapedKey = effect.key.replace(/"/g, "'"); - const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; - if (!selectorPropsMap.has(selector)) { - selectorPropsMap.set(selector, new Map()); + if (animationDataList.length === 0) { + continue; + } + + if (!selectorAnimationPropsMap.has(selector)) { + selectorAnimationPropsMap.set(selector, []); } + const animationPropsArray = selectorAnimationPropsMap.get(selector)!; for (const data of animationDataList) { const keyframeCSS = keyframesToCSS(data.name, data.keyframes, effect.initial); @@ -230,40 +296,44 @@ export function getCSS(config: InteractConfig): GetCSSResult { keyframeMap.set(data.name, keyframeCSS); } - const conditions = effect.conditions || []; - const conditionSetHash = conditions.reduce((acc, condition) => {return acc + conditionHashNumbers[condition]}, 0); - const conditionToProps = selectorPropsMap.get(selector)!; - if (!conditionToProps.has) { - conditionToProps.set(conditionSetHash, createEmptyAnimationProps()); - } + const { animation, composition, custom } = data; + const animationCustomPropName = `--anim-def-${keyWithNoSpecialChars}-${animationPropsArray.length}`; - const props = conditionToProps.get(conditionSetHash); - addAnimationProps(props!, data); + animationPropsArray.push({ + animation, + composition, + custom, + conditions, + animationCustomPropName + }); } } } } + for (const [baseSelector, transitionPropsArray] of selectorTransitionPropsMap) { + transitionPropsArray.forEach((transitionProps) => { + transitionRules.push(buildCascadingTransitionCustomPropRule( + baseSelector, + transitionProps, + configConditions + )); + }); + + transitionRules.push(buildTransitionRule(baseSelector, transitionPropsArray)); + } + const animationRules: string[] = []; - for (const [baseSelector, conditionsMap] of selectorPropsMap) { - for (const [conditionSetHash, props] of conditionsMap) { - if (props.animations.length === 0) { - continue; - } - const conditions = Object.entries(conditionHashNumbers).reduce((acc, [condition, hash]) => { - if (hash & conditionSetHash) { - acc.push(condition); - } - return acc; - }, [] as string[]); - const rule = buildAnimationRule( + for (const [baseSelector, animationPropsArray] of selectorAnimationPropsMap) { + animationPropsArray.forEach((animationProps) => { + animationRules.push(buildCascadingAnimationCustomPropRule( baseSelector, - props, - conditions, + animationProps, configConditions - ); - animationRules.push(rule); - } + )); + }); + + animationRules.push(buildAnimationRule(baseSelector, animationPropsArray)); } return { @@ -274,8 +344,7 @@ export function getCSS(config: InteractConfig): GetCSSResult { } export function generate(config: InteractConfig): string { - const {keyframes, animationRules, transitionRules} = getCSS(config); + const { keyframes, animationRules, transitionRules } = getCSS(config); const css: string[] = [...keyframes, ...animationRules, ...transitionRules]; - return css.join('\n'); } diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index 00d323f5..b9a850f4 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -46,14 +46,16 @@ export function generateId() { ); } -export function createTransitionCSS({ - key, +export function createStateRuleAndCSSTransitions({ effectId, transition, properties, childSelector = '> :first-child', selectorCondition, -}: CreateTransitionCSSParams): string[] { +}: CreateTransitionCSSParams): { + stateRule: string, + transitions: string[] +} { let transitions: string[] = []; if (transition?.styleProperties) { @@ -95,7 +97,6 @@ export function createTransitionCSS({ const styleProperties = properties?.map((property) => `${property.name}: ${property.value};`) || []; - const escapedKey = key.replace(/"/g, "'"); // Build selectors, applying condition if present const stateSelector = `:is(:state(${effectId}), :--${effectId}) ${childSelector}`; @@ -108,14 +109,35 @@ export function createTransitionCSS({ ? applySelectorCondition(dataAttrSelector, selectorCondition) : dataAttrSelector; - const result = [ + const stateRule = `${finalStateSelector}, ${finalDataAttrSelector} { ${styleProperties.join(` `)} - }`, - ]; + }`; + + return { stateRule, transitions }; +} + +export function createTransitionCSS({ + key, + effectId, + transition, + properties, + childSelector = '> :first-child', + selectorCondition, +}: CreateTransitionCSSParams): string[] { + const { stateRule, transitions } = createStateRuleAndCSSTransitions({ + key, + effectId, + transition, + properties, + childSelector, + selectorCondition, + }); + const result = [stateRule]; + const escapedKey = key.replace(/"/g, "'"); if (transitions.length) { const transitionSelector = `[data-interact-key="${escapedKey}"] ${childSelector}`; const finalTransitionSelector = selectorCondition @@ -160,11 +182,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(''); } From 38f5df390ab4b103a13fc65ba2b5afad78902fcc Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Wed, 24 Dec 2025 16:16:52 +0200 Subject: [PATCH 10/18] updated tests --- packages/interact/test/css.spec.ts | 1947 +++++++++------------------- 1 file changed, 633 insertions(+), 1314 deletions(-) diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index a277a531..491094a3 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1,1452 +1,771 @@ -import { describe, it, expect } from 'vitest'; -import type { InteractConfig, TimeEffect, Effect } from '../src/types'; +import { describe, it, expect, beforeAll } from 'vitest'; +import type { InteractConfig, Effect, TimeEffect, TransitionEffect } from '../src/types'; import type { NamedEffect } from '@wix/motion'; import { getCSS } from '../src/core/css'; -import { getSelector } from '../src/core/Interact'; +import { getCSSAnimation } from '@wix/motion'; +import { effectToAnimationOptions } from '../src/handlers/utilities'; + +// Mock CSS.escape for jsdom environment +beforeAll(() => { + if (typeof CSS === 'undefined') { + (globalThis as any).CSS = {}; + } + if (typeof CSS.escape !== 'function') { + CSS.escape = (value: string) => value.replace(/([^\w-])/g, '\\$1'); + } +}); /** * getCSS Test Suite * - * Tests CSS generation for time-based animations only. - * CSS should be generated for triggers: viewEnter, animationEnd, hover, click, pageVisible - * CSS should NOT be generated for scrub triggers: viewProgress, pointerMove + * Tests CSS generation for time-based animations and transitions. + * - Generates CSS for triggers: viewEnter, animationEnd, hover, click, pageVisible + * - Does NOT generate CSS for scrub triggers: viewProgress, pointerMove */ describe('getCSS', () => { // ============================================================================ - // Helpers + // Test Helpers // ============================================================================ - /** Get keyframe names - mocked to return predictable values */ - const getFadeInNames = (_: number): string[] => ['motion-fadeIn']; - const getArcInNames = (_: number): string[] => [ - 'motion-fadeIn', - 'motion-arcIn', - ]; - - /** Extract duration from an effect in a config */ - const getEffectDuration = (config: InteractConfig, effectId: string) => - (config.effects[effectId] as { duration: number }).duration; - - /** - * Creates a regex pattern for a keyframe block that allows properties in any order. - * @param percentage - The percentage value (e.g., '0', '25', '100') - * @param properties - Array of [property, value] tuples to match - * @returns RegExp that matches the keyframe block with properties in any order - */ - const createKeyframeBlockPattern = ( - percentage: string, - properties: [string, string][], - ): RegExp => { - // Each property must appear exactly once, but order doesn't matter - // Use lookahead assertions for unordered matching - const propertyLookaheads = properties - .map(([prop, val]) => { - const escapedVal = val.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return `(?=.*${prop}:\\s*${escapedVal})`; - }) - .join(''); - - return new RegExp(`${percentage}%\\s*\\{${propertyLookaheads}[^}]+\\}`); - }; + /** Escapes special regex characters */ + const escapeRegex = (str: string): string => + str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + /** Creates regex to match a @keyframes rule with the given name */ + const keyframesPattern = (name: string) => + new RegExp(`@keyframes\\s+${escapeRegex(name)}\\s*\\{[^}]*\\}`); - /** - * Builds the full CSS selector for an element with a given key and optional child selector. - * @param key - The data-interact-key value - * @param effect - Optional effect object to derive child selector from - * @returns Full CSS selector string - */ - const buildFullSelector = (key: string, effect?: Effect): string => { - const keySelector = `[data-interact-key="${key}"]`; - const childSelector = effect ? getSelector(effect) : getSelector({}); - return `${keySelector} ${childSelector}`; + /** Creates regex to match a CSS property in a rule */ + const propertyPattern = (prop: string, value: string) => + new RegExp(`${escapeRegex(prop)}:\\s*${escapeRegex(value)}`); + + /** Gets animation data from motion library for a given effect */ + const getMotionAnimationData = (effect: TimeEffect) => { + const options = effectToAnimationOptions(effect); + return getCSSAnimation(null, options); }; - /** - * Escapes special regex characters in a string. - */ - const escapeRegex = (str: string): string => - str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + /** Extracts keyframe names from motion library for a given effect */ + const getKeyframeNames = (effect: TimeEffect): string[] => { + const data = getMotionAnimationData(effect); + return data.map((d) => d.name).filter(Boolean) as string[]; + }; - /** - * Creates a regex pattern that matches a CSS rule with selector and specific property. - * @param selector - The CSS selector - * @param property - The animation property name - * @param value - The expected value - * @returns RegExp that matches the full rule - */ - const createAnimationRulePattern = ( - selector: string, - property: string, - value: string, - ): RegExp => { - const escapedSelector = escapeRegex(selector); - const escapedValue = escapeRegex(value); - return new RegExp( - `${escapedSelector}\\s*\\{[^}]*${property}:\\s*${escapedValue}[^}]*\\}`, - ); + /** Extracts animation strings from motion library */ + const getAnimationStrings = (effect: TimeEffect): string[] => { + const data = getMotionAnimationData(effect); + return data.map((d) => d.animation); }; - /** - * Gets all effect names/values for multipleEffectsConfig. - * Must be called after multipleEffectsConfig is defined. - */ - const getMultiEffectValues = () => { - const dur1 = getEffectDuration(multipleEffectsConfig, 'first-effect'); - const dur2 = getEffectDuration(multipleEffectsConfig, 'second-effect'); - const dur3 = getEffectDuration(multipleEffectsConfig, 'third-effect'); - - const [name1] = getFadeInNames(dur1); - const name2 = ( - multipleEffectsConfig.effects['second-effect'] as { - keyframeEffect: { name: string }; - } - ).keyframeEffect.name; - const [name3] = getFadeInNames(dur3); - - const delay1 = ( - multipleEffectsConfig.effects['first-effect'] as { delay: number } - ).delay; - const delay2 = ( - multipleEffectsConfig.effects['second-effect'] as { delay: number } - ).delay; - const delay3 = ( - multipleEffectsConfig.effects['third-effect'] as { delay: number } - ).delay; - - const fill1 = ( - multipleEffectsConfig.effects['first-effect'] as { fill: string } - ).fill; - const fill2 = ( - multipleEffectsConfig.effects['second-effect'] as { fill: string } - ).fill; - const fill3 = ( - multipleEffectsConfig.effects['third-effect'] as { fill: string } - ).fill; + /** Creates a basic config with a single effect and interaction */ + const createConfig = ( + effect: Effect, + options: { + key?: string; + trigger?: InteractConfig['interactions'][0]['trigger']; + effectId?: string; + conditions?: InteractConfig['conditions']; + effectConditions?: string[]; + selector?: string; + listContainer?: string; + listItemSelector?: string; + } = {}, + ): InteractConfig => { + const { + key = 'test-element', + trigger = 'viewEnter', + effectId = 'test-effect', + conditions, + effectConditions, + selector, + listContainer, + listItemSelector, + } = options; + + const effectRef: any = { key, effectId }; + if (effectConditions) effectRef.conditions = effectConditions; + if (selector) effectRef.selector = selector; + if (listContainer) effectRef.listContainer = listContainer; + if (listItemSelector) effectRef.listItemSelector = listItemSelector; return { - names: [name1, name2, name3], - durations: [dur1, dur2, dur3], - delays: [delay1, delay2, delay3], - fills: [fill1, fill2, fill3], + conditions, + effects: { [effectId]: effect }, + interactions: [ + { + trigger, + key, + listContainer, + listItemSelector, + effects: [effectRef], + }, + ], }; }; // ============================================================================ - // Test Configurations + // Common Test Effects // ============================================================================ - /** Config with FadeIn namedEffect */ - const fadeInConfig: InteractConfig = { - effects: { - 'fade-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'fade-element', - effects: [{ key: 'fade-element', effectId: 'fade-effect' }], - }, - ], - }; - - /** Config with custom keyframeEffect */ - const keyframeEffectConfig: InteractConfig = { - effects: { - 'custom-slide': { - keyframeEffect: { - name: 'custom-slide-animation', - keyframes: [ - { offset: 0, transform: 'translateX(-100px)', opacity: '0' }, - { offset: 1, transform: 'translateX(0)', opacity: '1' }, - ], - }, - duration: 800, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'slide-element', - effects: [{ key: 'slide-element', effectId: 'custom-slide' }], - }, - ], + const fadeInEffect: TimeEffect = { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, }; - /** Config with customEffect - should NOT generate CSS */ - const customEffectConfig: InteractConfig = { - effects: { - 'js-effect': { - customEffect: (_element: Element, _progress: number) => { - // JavaScript-only animation - }, - duration: 1000, - } as Effect, + const keyframeEffect: TimeEffect = { + keyframeEffect: { + name: 'custom-slide', + keyframes: [ + { offset: 0, transform: 'translateX(-100px)', opacity: '0' }, + { offset: 1, transform: 'translateX(0)', opacity: '1' }, + ], }, - interactions: [ - { - trigger: 'viewEnter', - key: 'js-element', - effects: [{ key: 'js-element', effectId: 'js-effect' }], - }, - ], + duration: 800, }; - /** Config with all TimeEffect options */ - const fullOptionsConfig: InteractConfig = { - effects: { - 'full-options': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 1000, - delay: 200, - easing: 'cubic-bezier(0.4, 0, 0.2, 1)', - iterations: 3, - alternate: true, - fill: 'both', - reversed: false, - }, + const transitionEffect: TransitionEffect = { + transition: { + duration: 300, + easing: 'ease-out', + styleProperties: [ + { name: 'opacity', value: '1' }, + { name: 'transform', value: 'scale(1)' }, + ], }, - interactions: [ - { - trigger: 'viewEnter', - key: 'full-options-element', - effects: [{ key: 'full-options-element', effectId: 'full-options' }], - }, - ], }; - /** Config with reversed direction */ - const reversedConfig: InteractConfig = { - effects: { - 'reversed-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - reversed: true, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'reversed-element', - effects: [{ key: 'reversed-element', effectId: 'reversed-effect' }], - }, - ], - }; + // ============================================================================ + // SUITE 1: Return Structure + // ============================================================================ + describe('return structure', () => { + it('should return an object with keyframes, animationRules, and transitionRules arrays', () => { + const result = getCSS({ effects: {}, interactions: [] }); + + expect(result).toHaveProperty('keyframes'); + expect(result).toHaveProperty('animationRules'); + expect(result).toHaveProperty('transitionRules'); + expect(Array.isArray(result.keyframes)).toBe(true); + expect(Array.isArray(result.animationRules)).toBe(true); + expect(Array.isArray(result.transitionRules)).toBe(true); + }); - /** Config with alternate-reverse direction */ - const alternateReversedConfig: InteractConfig = { - effects: { - 'alt-rev-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - alternate: true, - reversed: true, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'alt-rev-element', - effects: [{ key: 'alt-rev-element', effectId: 'alt-rev-effect' }], - }, - ], - }; + it('should return empty arrays for empty config', () => { + const result = getCSS({ effects: {}, interactions: [] }); - /** Config with infinite iterations */ - const infiniteIterationsConfig: InteractConfig = { - effects: { - 'infinite-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - iterations: 0, // 0 means infinite - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'infinite-element', - effects: [{ key: 'infinite-element', effectId: 'infinite-effect' }], - }, - ], - }; + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + expect(result.transitionRules).toEqual([]); + }); + }); - /** Config with scrub trigger (viewProgress) - should NOT generate CSS */ - const scrubTriggerConfig: InteractConfig = { - effects: { - 'scroll-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - rangeStart: { - name: 'contain', - offset: { value: 0, type: 'percentage' }, - }, - rangeEnd: { - name: 'contain', - offset: { value: 100, type: 'percentage' }, - }, - } as Effect, - }, - interactions: [ - { - trigger: 'viewProgress', - key: 'scroll-element', - effects: [{ key: 'scroll-element', effectId: 'scroll-effect' }], - }, - ], - }; + // ============================================================================ + // SUITE 2: Trigger Filtering + // ============================================================================ + describe('trigger filtering', () => { + const timeTriggers = ['viewEnter', 'hover', 'click', 'animationEnd', 'pageVisible'] as const; + const scrubTriggers = ['viewProgress', 'pointerMove'] as const; - /** Config with pointerMove trigger - should NOT generate CSS */ - const pointerMoveTriggerConfig: InteractConfig = { - effects: { - 'pointer-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - rangeStart: { - name: 'contain', - offset: { value: 0, type: 'percentage' }, - }, - rangeEnd: { - name: 'contain', - offset: { value: 100, type: 'percentage' }, - }, - } as Effect, - }, - interactions: [ - { - trigger: 'pointerMove', - key: 'pointer-element', - effects: [{ key: 'pointer-element', effectId: 'pointer-effect' }], - }, - ], - }; + it.each(timeTriggers)('should generate CSS for %s trigger', (trigger) => { + const config = createConfig(fadeInEffect, { trigger }); + const result = getCSS(config); - /** Config with hover trigger - time-based, should generate CSS */ - const hoverTriggerConfig: InteractConfig = { - effects: { - 'hover-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 300, - }, - }, - interactions: [ - { - trigger: 'hover', - key: 'hover-element', - effects: [{ key: 'hover-element', effectId: 'hover-effect' }], - }, - ], - }; + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + }); - /** Config with click trigger - time-based, should generate CSS */ - const clickTriggerConfig: InteractConfig = { - effects: { - 'click-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 400, - }, - }, - interactions: [ - { - trigger: 'click', - key: 'click-element', - effects: [{ key: 'click-element', effectId: 'click-effect' }], - }, - ], - }; + it.each(scrubTriggers)('should NOT generate CSS for %s trigger', (trigger) => { + const config = createConfig(fadeInEffect, { trigger }); + const result = getCSS(config); - /** Config with animationEnd trigger - time-based, should generate CSS */ - const animationEndTriggerConfig: InteractConfig = { - effects: { - 'chain-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 600, - }, - }, - interactions: [ - { - trigger: 'animationEnd', - key: 'chain-element', - params: { effectId: 'some-previous-effect' }, - effects: [{ key: 'chain-element', effectId: 'chain-effect' }], - }, - ], - }; + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + }); + }); - /** Config with custom selector */ - const customSelectorConfig: InteractConfig = { - effects: { - 'selector-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'parent-element', - effects: [ - { - key: 'parent-element', - selector: '.child-target', - effectId: 'selector-effect', - }, - ], - }, - ], - }; + // ============================================================================ + // SUITE 3: Keyframes Generation + // ============================================================================ + describe('keyframes generation', () => { + it('should generate valid @keyframes rule for namedEffect', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); + const [expectedName] = getKeyframeNames(fadeInEffect); + + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]).toMatch(keyframesPattern(expectedName)); + expect(result.keyframes[0]).toMatch(/0%\s*\{[^}]*opacity/); + expect(result.keyframes[0]).toMatch(/100%\s*\{[^}]*opacity/); + }); - /** Config with listContainer */ - const listContainerConfig: InteractConfig = { - effects: { - 'list-item-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 300, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'list-wrapper', - listContainer: '.items-container', - listItemSelector: '.item', - effects: [ - { - listContainer: '.items-container', - listItemSelector: '.item', - effectId: 'list-item-effect', - }, - ], - }, - ], - }; + it('should generate @keyframes with custom name for keyframeEffect', () => { + const config = createConfig(keyframeEffect); + const result = getCSS(config); - /** Config with media condition */ - const conditionalConfig: InteractConfig = { - conditions: { - desktop: { type: 'media', predicate: 'min-width: 1024px' }, - }, - effects: { - 'conditional-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'conditional-element', - effects: [ - { - key: 'conditional-element', - effectId: 'conditional-effect', - conditions: ['desktop'], - }, - ], - }, - ], - }; + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]).toMatch(keyframesPattern('custom-slide')); + expect(result.keyframes[0]).toMatch(/0%\s*\{[^}]*transform/); + expect(result.keyframes[0]).toMatch(/100%\s*\{[^}]*transform/); + }); - /** Empty config */ - const emptyConfig: InteractConfig = { - effects: {}, - interactions: [], - }; + it('should generate multiple @keyframes for multi-part effects (like ArcIn)', () => { + const arcInEffect: TimeEffect = { + namedEffect: { type: 'ArcIn', direction: 'right' } as NamedEffect, + duration: 1000, + }; + const config = createConfig(arcInEffect); + const result = getCSS(config); + const expectedNames = getKeyframeNames(arcInEffect); - /** Config with same effect used twice (deduplication test) */ - const duplicateEffectConfig: InteractConfig = { - effects: { - 'shared-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'element-a', - effects: [{ key: 'element-a', effectId: 'shared-effect' }], - }, - { - trigger: 'viewEnter', - key: 'element-b', - effects: [{ key: 'element-b', effectId: 'shared-effect' }], - }, - ], - }; + expect(result.keyframes.length).toBe(expectedNames.length); + expectedNames.forEach((name) => { + expect(result.keyframes.some((kf) => kf.includes(`@keyframes ${name}`))).toBe(true); + }); + }); - /** Config with inline effect definition (no effectId reference) */ - const inlineEffectConfig: InteractConfig = { - effects: {}, - interactions: [ - { - trigger: 'viewEnter', - key: 'inline-element', - effects: [ - { - key: 'inline-element', - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 400, - } as Effect, + it('should deduplicate keyframes when same effect is used multiple times', () => { + const config: InteractConfig = { + effects: { shared: fadeInEffect }, + interactions: [ + { trigger: 'viewEnter', key: 'el-a', effects: [{ key: 'el-a', effectId: 'shared' }] }, + { trigger: 'viewEnter', key: 'el-b', effects: [{ key: 'el-b', effectId: 'shared' }] }, ], - }, - ], - }; - - /** Config with fill modes */ - const fillNoneConfig: InteractConfig = { - effects: { - 'fill-none': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - fill: 'none', - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'fill-none-element', - effects: [{ key: 'fill-none-element', effectId: 'fill-none' }], - }, - ], - }; - - const fillForwardsConfig: InteractConfig = { - effects: { - 'fill-forwards': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - fill: 'forwards', - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'fill-forwards-element', - effects: [{ key: 'fill-forwards-element', effectId: 'fill-forwards' }], - }, - ], - }; + }; + const result = getCSS(config); + const [expectedName] = getKeyframeNames(fadeInEffect); - /** Config with ArcIn (multi-keyframe effect) */ - const arcInConfig: InteractConfig = { - effects: { - 'arc-effect': { - namedEffect: { - type: 'ArcIn', - direction: 'right', - power: 'medium', - } as NamedEffect, - duration: 1000, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'arc-element', - effects: [{ key: 'arc-element', effectId: 'arc-effect' }], - }, - ], - }; + const keyframesWithName = result.keyframes.filter((kf) => kf.includes(expectedName)); + expect(keyframesWithName).toHaveLength(1); + }); - /** Config with fractional offset keyframes */ - const fractionalKeyframesConfig: InteractConfig = { - effects: { - 'multi-step': { + it('should interpolate keyframe offsets when not provided', () => { + const interpolatedEffect: TimeEffect = { keyframeEffect: { - name: 'multi-step-animation', + name: 'interpolated', keyframes: [ - { offset: 0, opacity: '0' }, - { offset: 0.25, opacity: '0.5' }, - { offset: 0.75, opacity: '0.8' }, - { offset: 1, opacity: '1' }, + { opacity: '0' }, + { opacity: '0.5' }, + { opacity: '1' }, ], }, duration: 1000, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'multi-step-element', - effects: [{ key: 'multi-step-element', effectId: 'multi-step' }], - }, - ], - }; + }; + const config = createConfig(interpolatedEffect); + const result = getCSS(config); + + // Should have 0%, 50%, 100% (evenly distributed) + expect(result.keyframes[0]).toMatch(/0%\s*\{/); + expect(result.keyframes[0]).toMatch(/50%\s*\{/); + expect(result.keyframes[0]).toMatch(/100%\s*\{/); + }); - /** Config with keyframes without explicit offsets (interpolated) */ - const interpolatedKeyframesConfig: InteractConfig = { - effects: { - 'interpolated-effect': { - keyframeEffect: { - name: 'interpolated-animation', - keyframes: [ - { opacity: '0', transform: 'scale(0.5)' }, - { opacity: '0.3', transform: 'scale(0.7)' }, - { opacity: '0.7', transform: 'scale(0.9)' }, - { opacity: '1', transform: 'scale(1)' }, - ], - }, + it('should NOT generate keyframes for customEffect', () => { + const customEffect = { + customEffect: () => {}, duration: 1000, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'interpolated-element', - effects: [ - { - key: 'interpolated-element', - effectId: 'interpolated-effect', - }, - ], - }, - ], - }; + } as Effect; + const config = createConfig(customEffect); + const result = getCSS(config); - /** Config with mixed explicit and implicit offsets */ - const mixedOffsetsConfig: InteractConfig = { - effects: { - 'mixed-effect': { - keyframeEffect: { - name: 'mixed-offset-animation', - keyframes: [ - { offset: 0, opacity: '0' }, - { opacity: '0.5' }, // Should be interpolated to 50% - { offset: 1, opacity: '1' }, - ], - }, - duration: 500, - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'mixed-element', - effects: [{ key: 'mixed-element', effectId: 'mixed-effect' }], - }, - ], - }; - - /** Config with multiple effects on same target */ - const multipleEffectsConfig: InteractConfig = { - effects: { - 'first-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 500, - delay: 0, - fill: 'forwards', - }, - 'second-effect': { - keyframeEffect: { - name: 'scale-up', - keyframes: [ - { offset: 0, transform: 'scale(0.8)' }, - { offset: 1, transform: 'scale(1)' }, - ], - }, - duration: 800, - delay: 100, - fill: 'both', - }, - 'third-effect': { - namedEffect: { type: 'FadeIn' } as NamedEffect, - duration: 300, - delay: 200, - iterations: 2, - fill: 'none', - }, - }, - interactions: [ - { - trigger: 'viewEnter', - key: 'multi-effect-element', - effects: [ - { key: 'multi-effect-element', effectId: 'first-effect' }, - { key: 'multi-effect-element', effectId: 'second-effect' }, - { key: 'multi-effect-element', effectId: 'third-effect' }, - ], - }, - ], - }; + expect(result.keyframes).toEqual([]); + }); + }); // ============================================================================ - // SUITE 1: Keyframes Rules + // SUITE 4: Initial State in Keyframes // ============================================================================ - describe('keyframes rules', () => { - describe('namedEffect keyframes', () => { - it('should generate valid @keyframes rule for FadeIn effect', () => { - const result = getCSS(fadeInConfig); - const duration = getEffectDuration(fadeInConfig, 'fade-effect'); - const [fadeInName] = getFadeInNames(duration); - - expect(result.keyframes).toHaveLength(1); - - // FadeIn generates keyframe with opacity animation - // Pattern: @keyframes {name} { 0% { opacity: 0 } 100% { opacity: ... } } - const keyframeRule = result.keyframes[0]; - const keyframePattern = new RegExp( - `^@keyframes\\s+${fadeInName}\\s*\\{\\s*0%\\s*\\{\\s*opacity:\\s*0;?\\s*\\}\\s*100%\\s*\\{\\s*opacity:\\s*[^}]+\\}\\s*\\}$`, - ); - - expect(keyframeRule).toMatch(keyframePattern); - }); + describe('initial state in keyframes', () => { + it('should include default initial state properties in keyframes', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); - it('should generate multiple @keyframes rules for multi-keyframe effects', () => { - const result = getCSS(arcInConfig); - const duration = getEffectDuration(arcInConfig, 'arc-effect'); - const [fadeInName, arcInName] = getArcInNames(duration); - - // ArcIn generates 2 keyframes - expect(result.keyframes).toHaveLength(2); - - // Should have fadeIn keyframe - const hasFadeIn = result.keyframes.some((kf) => - new RegExp(`^@keyframes\\s+${fadeInName}\\s*\\{`).test(kf), - ); - expect(hasFadeIn).toBe(true); - - // Should have arcIn keyframe with transform/perspective - const hasArcIn = result.keyframes.some( - (kf) => - new RegExp(`^@keyframes\\s+${arcInName}\\s*\\{`).test(kf) && - kf.includes('transform') && - kf.includes('perspective'), - ); - expect(hasArcIn).toBe(true); - }); + // Default initial includes visibility: hidden + expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*visibility:\s*hidden/); }); - describe('keyframeEffect keyframes', () => { - it('should generate @keyframes with custom name and keyframe values (properties may be scrambled)', () => { - const result = getCSS(keyframeEffectConfig); - - const { name, keyframes } = ( - keyframeEffectConfig.effects['custom-slide'] as { - keyframeEffect: { name: string; keyframes: Keyframe[] }; - } - ).keyframeEffect; - - expect(result.keyframes).toHaveLength(1); - const keyframeRule = result.keyframes[0]; - - // Verify complete @keyframes structure: @keyframes name { ... } - const fullStructurePattern = new RegExp( - `^@keyframes\\s+${name}\\s*\\{[\\s\\S]*\\}$`, - ); - expect(keyframeRule).toMatch(fullStructurePattern); - - // Verify first keyframe (0%) with properties in any order - const firstKeyframe = keyframes[0]; - const firstPercentage = String((firstKeyframe.offset ?? 0) * 100); - const firstProps: [string, string][] = [ - ['transform', 'translateX(-100px)'], - ['opacity', '0'], - ]; - expect(keyframeRule).toMatch( - createKeyframeBlockPattern(firstPercentage, firstProps), - ); - - // Verify last keyframe (100%) with properties in any order - const lastKeyframe = keyframes[keyframes.length - 1]; - const lastPercentage = String((lastKeyframe.offset ?? 1) * 100); - const lastProps: [string, string][] = [ - ['transform', 'translateX(0)'], - ['opacity', '1'], - ]; - expect(keyframeRule).toMatch( - createKeyframeBlockPattern(lastPercentage, lastProps), - ); - }); - - it('should generate @keyframes with fractional offset values', () => { - const result = getCSS(fractionalKeyframesConfig); - - const { name } = ( - fractionalKeyframesConfig.effects['multi-step'] as { - keyframeEffect: { name: string }; - } - ).keyframeEffect; - - expect(result.keyframes).toHaveLength(1); - const keyframeRule = result.keyframes[0]; - - // Verify complete structure with all percentage stops - const fullStructurePattern = new RegExp( - `^@keyframes\\s+${name}\\s*\\{` + - `\\s*0%\\s*\\{\\s*opacity:\\s*0;?\\s*\\}` + - `\\s*25%\\s*\\{\\s*opacity:\\s*0\\.5;?\\s*\\}` + - `\\s*75%\\s*\\{\\s*opacity:\\s*0\\.8;?\\s*\\}` + - `\\s*100%\\s*\\{\\s*opacity:\\s*1;?\\s*\\}` + - `\\s*\\}$`, - ); - expect(keyframeRule).toMatch(fullStructurePattern); - }); + it('should use custom initial state when provided', () => { + const effectWithInitial: Effect = { + ...fadeInEffect, + initial: { opacity: '0', transform: 'scale(0.5)' }, + }; + const config = createConfig(effectWithInitial); + const result = getCSS(config); - it('should interpolate percentages when offset is not provided', () => { - // When offsets are not provided, they should be evenly distributed - // 4 keyframes without offset → 0%, 33.33%, 66.67%, 100% - const result = getCSS(interpolatedKeyframesConfig); - - const { name } = ( - interpolatedKeyframesConfig.effects['interpolated-effect'] as { - keyframeEffect: { name: string }; - } - ).keyframeEffect; - - expect(result.keyframes).toHaveLength(1); - const keyframeRule = result.keyframes[0]; - - // Verify @keyframes name - expect(keyframeRule).toMatch( - new RegExp(`^@keyframes\\s+${name}\\s*\\{`), - ); - - // First keyframe should be 0% - expect(keyframeRule).toMatch( - createKeyframeBlockPattern('0', [ - ['opacity', '0'], - ['transform', 'scale(0.5)'], - ]), - ); - - // Middle keyframes should be interpolated (33% or 33.33%, 66% or 66.67%) - // Allow for rounding variations - expect(keyframeRule).toMatch( - createKeyframeBlockPattern('33(?:\\.33)?', [ - ['opacity', '0.3'], - ['transform', 'scale(0.7)'], - ]), - ); - - expect(keyframeRule).toMatch( - createKeyframeBlockPattern('66(?:\\.67)?', [ - ['opacity', '0.7'], - ['transform', 'scale(0.9)'], - ]), - ); - - // Last keyframe should be 100% - expect(keyframeRule).toMatch( - createKeyframeBlockPattern('100', [ - ['opacity', '1'], - ['transform', 'scale(1)'], - ]), - ); - }); - - it('should handle mixed explicit and implicit offsets', () => { - // First and last have explicit offsets, middle ones are interpolated - const result = getCSS(mixedOffsetsConfig); - - const { name } = ( - mixedOffsetsConfig.effects['mixed-effect'] as { - keyframeEffect: { name: string }; - } - ).keyframeEffect; - - expect(result.keyframes).toHaveLength(1); - const keyframeRule = result.keyframes[0]; - - // Verify complete structure with interpolated middle keyframe - const fullStructurePattern = new RegExp( - `^@keyframes\\s+${name}\\s*\\{` + - `\\s*0%\\s*\\{\\s*opacity:\\s*0;?\\s*\\}` + - `\\s*50%\\s*\\{\\s*opacity:\\s*0\\.5;?\\s*\\}` + - `\\s*100%\\s*\\{\\s*opacity:\\s*1;?\\s*\\}` + - `\\s*\\}$`, - ); - expect(keyframeRule).toMatch(fullStructurePattern); - }); + expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*opacity:\s*0/); + expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*transform:\s*scale\(0\.5\)/); }); - describe('customEffect keyframes', () => { - it('should NOT generate keyframes for customEffect', () => { - const result = getCSS(customEffectConfig); + it('should NOT include initial when set to "disable"', () => { + const effectWithDisabledInitial: Effect = { + ...fadeInEffect, + initial: 'disable', + }; + const config = createConfig(effectWithDisabledInitial); + const result = getCSS(config); - expect(result.keyframes).toHaveLength(0); - }); + expect(result.keyframes[0]).not.toMatch(/from\s*\{/); }); + }); - describe('keyframes deduplication', () => { - it('should not duplicate keyframes when same effect is used multiple times', () => { - const result = getCSS(duplicateEffectConfig); - const duration = getEffectDuration( - duplicateEffectConfig, - 'shared-effect', - ); - const [fadeInName] = getFadeInNames(duration); - - // fadeIn keyframe should appear exactly once - const fadeInKeyframes = result.keyframes.filter((kf) => - kf.includes(fadeInName), - ); - expect(fadeInKeyframes).toHaveLength(1); - }); + // ============================================================================ + // SUITE 5: Animation Rules + // ============================================================================ + describe('animation rules', () => { + it('should generate animation rule with correct selector', () => { + const config = createConfig(fadeInEffect, { key: 'my-element' }); + const result = getCSS(config); + + const hasCorrectSelector = result.animationRules.some((rule) => + rule.includes('[data-interact-key="my-element"]'), + ); + expect(hasCorrectSelector).toBe(true); }); - describe('trigger filtering', () => { - it('should NOT generate keyframes for viewProgress trigger', () => { - const result = getCSS(scrubTriggerConfig); - expect(result.keyframes).toHaveLength(0); - }); + it('should use CSS custom properties for animation definition', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); - it('should NOT generate keyframes for pointerMove trigger', () => { - const result = getCSS(pointerMoveTriggerConfig); - expect(result.keyframes).toHaveLength(0); - }); + // Animation rules should use --anim-def-* custom properties + const hasCustomProp = result.animationRules.some((rule) => + /--anim-def-\w+/.test(rule), + ); + expect(hasCustomProp).toBe(true); + }); - it('should generate keyframes for hover trigger', () => { - const result = getCSS(hoverTriggerConfig); - const duration = getEffectDuration(hoverTriggerConfig, 'hover-effect'); - const [fadeInName] = getFadeInNames(duration); - expect(result.keyframes.length).toBeGreaterThan(0); - expect(result.keyframes[0]).toMatch( - new RegExp(`^@keyframes\\s+${fadeInName}`), - ); - }); + it('should include animation property with var() fallback', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); - it('should generate keyframes for click trigger', () => { - const result = getCSS(clickTriggerConfig); - const duration = getEffectDuration(clickTriggerConfig, 'click-effect'); - const [fadeInName] = getFadeInNames(duration); - expect(result.keyframes.length).toBeGreaterThan(0); - expect(result.keyframes[0]).toMatch( - new RegExp(`^@keyframes\\s+${fadeInName}`), - ); - }); + const hasAnimationWithVar = result.animationRules.some((rule) => + /animation:\s*var\(--anim-def-[^,]+,\s*none\)/.test(rule), + ); + expect(hasAnimationWithVar).toBe(true); + }); - it('should generate keyframes for animationEnd trigger', () => { - const result = getCSS(animationEndTriggerConfig); - const duration = getEffectDuration( - animationEndTriggerConfig, - 'chain-effect', - ); - const [fadeInName] = getFadeInNames(duration); - expect(result.keyframes.length).toBeGreaterThan(0); - expect(result.keyframes[0]).toMatch( - new RegExp(`^@keyframes\\s+${fadeInName}`), - ); - }); + it('should include animation string from motion library', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); + const [expectedAnimation] = getAnimationStrings(fadeInEffect); + + const hasMotionAnimation = result.animationRules.some((rule) => + rule.includes(expectedAnimation), + ); + expect(hasMotionAnimation).toBe(true); }); - describe('edge cases', () => { - it('should return empty keyframes array for empty config', () => { - const result = getCSS(emptyConfig); - expect(result.keyframes).toEqual([]); - }); + it('should include animation-composition property', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); - it('should generate keyframes for inline effect definitions', () => { - const result = getCSS(inlineEffectConfig); - // Inline effect has duration in the interaction effect, not in config.effects - const inlineEffect = inlineEffectConfig.interactions[0].effects[0] as { - duration: number; - }; - const [fadeInName] = getFadeInNames(inlineEffect.duration); - expect(result.keyframes.length).toBeGreaterThan(0); - expect(result.keyframes[0]).toMatch( - new RegExp(`^@keyframes\\s+${fadeInName}`), - ); - }); + const hasComposition = result.animationRules.some((rule) => + rule.includes('animation-composition:'), + ); + expect(hasComposition).toBe(true); + }); - it('should skip effects without namedEffect or keyframeEffect', () => { - const invalidConfig: InteractConfig = { - effects: { - 'invalid-effect': { duration: 500 } as TimeEffect, + it('should generate multiple animation custom props for multiple effects on same element', () => { + const config: InteractConfig = { + effects: { + effect1: fadeInEffect, + effect2: keyframeEffect, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'multi-effect', + effects: [ + { key: 'multi-effect', effectId: 'effect1' }, + { key: 'multi-effect', effectId: 'effect2' }, + ], }, - interactions: [ - { - trigger: 'viewEnter', - key: 'invalid-element', - effects: [{ key: 'invalid-element', effectId: 'invalid-effect' }], - }, - ], - }; - - const result = getCSS(invalidConfig); - expect(result.keyframes).toEqual([]); - }); + ], + }; + const result = getCSS(config); + + // Should have comma-separated var() calls in animation property + const hasMultipleVars = result.animationRules.some((rule) => + /animation:\s*var\([^)]+\),\s*var\([^)]+\)/.test(rule), + ); + expect(hasMultipleVars).toBe(true); }); }); // ============================================================================ - // SUITE 2: Animation Rules + // SUITE 6: Selectors // ============================================================================ - // TODO: (ameerf) - fix this to use animation short-hand and unskip - describe.skip('animation rules', () => { - describe('animation-name', () => { - it('should include animation-name with selector for namedEffect', () => { - const result = getCSS(fadeInConfig); - const duration = getEffectDuration(fadeInConfig, 'fade-effect'); - const [fadeInName] = getFadeInNames(duration); - const selector = buildFullSelector('fade-element'); - - expect(result.animationRules.length).toBeGreaterThan(0); - - const pattern = createAnimationRulePattern( - selector, - 'animation-name', - fadeInName, - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + describe('selectors', () => { + it('should escape special characters in key', () => { + const config = createConfig(fadeInEffect, { key: 'element.with:special#chars' }); + const result = getCSS(config); + + // CSS.escape handles special chars + const hasEscapedSelector = result.animationRules.some((rule) => + rule.includes('data-interact-key='), + ); + expect(hasEscapedSelector).toBe(true); + }); - it('should include animation-name with selector for keyframeEffect', () => { - const result = getCSS(keyframeEffectConfig); - const { name } = ( - keyframeEffectConfig.effects['custom-slide'] as { - keyframeEffect: { name: string }; - } - ).keyframeEffect; - const selector = buildFullSelector('slide-element'); - - expect(result.animationRules.length).toBeGreaterThan(0); - - const pattern = createAnimationRulePattern( - selector, - 'animation-name', - name, - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + it('should include custom selector when specified', () => { + const config = createConfig(fadeInEffect, { selector: '.child-target' }); + const result = getCSS(config); + + const hasCustomSelector = result.animationRules.some((rule) => + rule.includes('.child-target'), + ); + expect(hasCustomSelector).toBe(true); }); - describe('animation-duration', () => { - it('should include animation-duration in milliseconds with selector', () => { - const result = getCSS(fadeInConfig); - const duration = getEffectDuration(fadeInConfig, 'fade-effect'); - const selector = buildFullSelector('fade-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-duration', - `${duration}ms`, - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); + it('should include listContainer and listItemSelector in selector', () => { + const config = createConfig(fadeInEffect, { + listContainer: '.items-container', + listItemSelector: '.item', }); + const result = getCSS(config); - it('should use correct duration value from effect config', () => { - const result = getCSS(keyframeEffectConfig); - const duration = getEffectDuration( - keyframeEffectConfig, - 'custom-slide', - ); - const selector = buildFullSelector('slide-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-duration', - `${duration}ms`, - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + const hasListSelector = result.animationRules.some( + (rule) => rule.includes('.items-container') && rule.includes('.item'), + ); + expect(hasListSelector).toBe(true); }); - describe('animation-delay', () => { - it('should include animation-delay when delay is specified', () => { - const result = getCSS(fullOptionsConfig); - const delay = ( - fullOptionsConfig.effects['full-options'] as { delay: number } - ).delay; - const selector = buildFullSelector('full-options-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-delay', - `${delay}ms`, - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + it('should use default child selector (> :first-child) when no selector specified', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); - it('should omit animation-delay when not specified', () => { - const result = getCSS(fadeInConfig); + const hasDefaultSelector = result.animationRules.some((rule) => + rule.includes('> :first-child'), + ); + expect(hasDefaultSelector).toBe(true); + }); + }); - // fadeInConfig has no delay - either no animation-delay or 0ms - const hasNonZeroDelay = result.animationRules.some((rule) => - /animation-delay:\s*[1-9]\d*ms/.test(rule), - ); - expect(hasNonZeroDelay).toBe(false); - }); + // ============================================================================ + // SUITE 7: Conditions - Media Queries + // ============================================================================ + describe('conditions - media queries', () => { + it('should wrap rule in @media when media condition is specified', () => { + const config = createConfig(fadeInEffect, { + conditions: { desktop: { type: 'media', predicate: 'min-width: 1024px' } }, + effectConditions: ['desktop'], + }); + const result = getCSS(config); + + const hasMediaQuery = result.animationRules.some((rule) => + /@media\s*\(min-width:\s*1024px\)/.test(rule), + ); + expect(hasMediaQuery).toBe(true); }); - describe('animation-timing-function', () => { - it('should include custom easing as animation-timing-function', () => { - const result = getCSS(fullOptionsConfig); - const easing = ( - fullOptionsConfig.effects['full-options'] as { easing: string } - ).easing; - const selector = buildFullSelector('full-options-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-timing-function', - easing, - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); + it('should combine multiple media conditions with "and"', () => { + const config = createConfig(fadeInEffect, { + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + landscape: { type: 'media', predicate: 'orientation: landscape' }, + }, + effectConditions: ['desktop', 'landscape'], }); + const result = getCSS(config); - it('should include animation-timing-function when not specified', () => { - const result = getCSS(fadeInConfig); - const selector = buildFullSelector('fade-element'); - - // Should have animation-timing-function property - const hasTimingFunction = result.animationRules.some( - (rule) => - rule.includes(selector) && - rule.includes('animation-timing-function:'), - ); - expect(hasTimingFunction).toBe(true); - }); + const hasCombinedMedia = result.animationRules.some((rule) => + /@media\s*\([^)]+\)\s*and\s*\([^)]+\)/.test(rule), + ); + expect(hasCombinedMedia).toBe(true); }); - describe('animation-iteration-count', () => { - it('should include animation-iteration-count when iterations specified', () => { - const result = getCSS(fullOptionsConfig); - const iterations = ( - fullOptionsConfig.effects['full-options'] as { iterations: number } - ).iterations; - const selector = buildFullSelector('full-options-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-iteration-count', - String(iterations), - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + it('should NOT wrap in @media when no conditions', () => { + const config = createConfig(fadeInEffect); + const result = getCSS(config); - it('should use "infinite" for iterations: 0', () => { - const result = getCSS(infiniteIterationsConfig); - const selector = buildFullSelector('infinite-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-iteration-count', - 'infinite', - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + const hasMediaQuery = result.animationRules.some((rule) => rule.includes('@media')); + expect(hasMediaQuery).toBe(false); }); + }); - describe('animation-direction', () => { - it('should include animation-direction: alternate when alternate: true', () => { - const result = getCSS(fullOptionsConfig); - const selector = buildFullSelector('full-options-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-direction', - 'alternate', - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); - - it('should include animation-direction: reverse when reversed: true', () => { - const result = getCSS(reversedConfig); - const selector = buildFullSelector('reversed-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-direction', - 'reverse', - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + // ============================================================================ + // SUITE 8: Conditions - Container Queries + // ============================================================================ + describe('conditions - container queries', () => { + it('should wrap rule in @container when container condition is specified', () => { + const config = createConfig(fadeInEffect, { + conditions: { wide: { type: 'container', predicate: 'min-width: 500px' } }, + effectConditions: ['wide'], + }); + const result = getCSS(config); + + const hasContainerQuery = result.animationRules.some((rule) => + /@container\s*\(min-width:\s*500px\)/.test(rule), + ); + expect(hasContainerQuery).toBe(true); + }); + }); - it('should include animation-direction: alternate-reverse when both', () => { - const result = getCSS(alternateReversedConfig); - const selector = buildFullSelector('alt-rev-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-direction', - 'alternate-reverse', - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + // ============================================================================ + // SUITE 9: Conditions - Selector Conditions + // ============================================================================ + describe('conditions - selector conditions', () => { + it('should apply selector condition to the target selector', () => { + const config = createConfig(fadeInEffect, { + conditions: { active: { type: 'selector', predicate: '.is-active' } }, + effectConditions: ['active'], + }); + const result = getCSS(config); + + const hasSelectorCondition = result.animationRules.some((rule) => + rule.includes(':is(.is-active)'), + ); + expect(hasSelectorCondition).toBe(true); + }); + }); - it('should use normal direction when neither alternate nor reversed', () => { - const result = getCSS(fadeInConfig); + // ============================================================================ + // SUITE 10: Transitions + // ============================================================================ + describe('transitions', () => { + it('should generate transition rules for transition effects', () => { + const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); + const result = getCSS(config); - // Should not have reverse or alternate in direction - const hasReverseOrAlternate = result.animationRules.some( - (rule) => - /animation-direction:\s*reverse/.test(rule) || - /animation-direction:\s*alternate/.test(rule), - ); - expect(hasReverseOrAlternate).toBe(false); - }); + expect(result.transitionRules.length).toBeGreaterThan(0); }); - describe('animation-fill-mode', () => { - it('should include animation-fill-mode: both when fill: "both"', () => { - const result = getCSS(fullOptionsConfig); - const selector = buildFullSelector('full-options-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-fill-mode', - 'both', - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + it('should generate state rule with :state() and data-interact-effect selectors', () => { + const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); + const result = getCSS(config); - it('should include animation-fill-mode: forwards when fill: "forwards"', () => { - const result = getCSS(fillForwardsConfig); - const selector = buildFullSelector('fill-forwards-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-fill-mode', - 'forwards', - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + const hasStateSelector = result.transitionRules.some((rule) => + /:state\(trans-effect\)/.test(rule) || /--trans-effect/.test(rule), + ); + const hasDataAttrSelector = result.transitionRules.some((rule) => + rule.includes('[data-interact-effect~="trans-effect"]'), + ); - it('should not include forwards/backwards/both when fill: "none"', () => { - const result = getCSS(fillNoneConfig); - - // Should NOT have forwards, backwards, or both - const hasOtherFill = result.animationRules.some( - (rule) => - /animation-fill-mode:\s*forwards/.test(rule) || - /animation-fill-mode:\s*backwards/.test(rule) || - /animation-fill-mode:\s*both/.test(rule), - ); - expect(hasOtherFill).toBe(false); - }); + expect(hasStateSelector).toBe(true); + expect(hasDataAttrSelector).toBe(true); }); - describe('selectors', () => { - it('should target element using data-interact-key and child selector', () => { - const result = getCSS(fadeInConfig); - const selector = buildFullSelector('fade-element'); + it('should include style properties in state rule', () => { + const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); + const result = getCSS(config); - const hasSelector = result.animationRules.some((rule) => - rule.includes(selector), - ); - expect(hasSelector).toBe(true); - }); + const hasOpacity = result.transitionRules.some((rule) => + propertyPattern('opacity', '1').test(rule), + ); + const hasTransform = result.transitionRules.some((rule) => + propertyPattern('transform', 'scale(1)').test(rule), + ); - it('should include custom selector when specified', () => { - const result = getCSS(customSelectorConfig); - const effect = customSelectorConfig.interactions[0] - .effects[0] as Effect; - const selector = buildFullSelector('parent-element', effect); + expect(hasOpacity).toBe(true); + expect(hasTransform).toBe(true); + }); - const hasSelector = result.animationRules.some((rule) => - rule.includes(selector), - ); - expect(hasSelector).toBe(true); - }); + it('should use CSS custom properties for transition definition', () => { + const config = createConfig(transitionEffect as Effect); + const result = getCSS(config); - it('should include listContainer and listItemSelector in selector', () => { - const result = getCSS(listContainerConfig); - const effect = listContainerConfig.interactions[0].effects[0] as Effect; - const childSelector = getSelector(effect, { addItemFilter: true }); - - // Should include the list container path - const hasListSelector = result.animationRules.some( - (rule) => - rule.includes('[data-interact-key="list-wrapper"]') && - rule.includes(childSelector), - ); - expect(hasListSelector).toBe(true); - }); + const hasTransitionCustomProp = result.transitionRules.some((rule) => + /--trans-def-\w+/.test(rule), + ); + expect(hasTransitionCustomProp).toBe(true); }); - describe('media conditions', () => { - it('should wrap animation rule in @media query when condition specified', () => { - const result = getCSS(conditionalConfig); - const predicate = conditionalConfig.conditions!.desktop.predicate || ''; - const duration = getEffectDuration( - conditionalConfig, - 'conditional-effect', - ); - const [fadeInName] = getFadeInNames(duration); - const selector = buildFullSelector('conditional-element'); - - // Build the animation rule pattern - const animationRulePattern = createAnimationRulePattern( - selector, - 'animation-name', - fadeInName, - ); - - // Should have @media (predicate) wrapper containing the animation rule - const mediaPattern = new RegExp( - `@media\\s*\\(\\s*${escapeRegex( - predicate, - )}\\s*\\)\\s*\\{[\\s\\S]*\\}`, - ); - const hasMediaQueryWithRule = result.animationRules.some( - (rule) => mediaPattern.test(rule) && animationRulePattern.test(rule), - ); - expect(hasMediaQueryWithRule).toBe(true); - }); + it('should include transition property with var() fallback', () => { + const config = createConfig(transitionEffect as Effect); + const result = getCSS(config); + + const hasTransitionWithVar = result.transitionRules.some((rule) => + /transition:\s*var\(--trans-def-[^,]+,\s*_\)/.test(rule), + ); + expect(hasTransitionWithVar).toBe(true); + }); - it('should NOT wrap in @media when no conditions', () => { - const result = getCSS(fadeInConfig); + it('should NOT generate animation rules for transition-only effects', () => { + const config = createConfig(transitionEffect as Effect); + const result = getCSS(config); - const hasMediaQuery = result.animationRules.some((rule) => - rule.includes('@media'), - ); - expect(hasMediaQuery).toBe(false); - }); + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); }); + }); - describe('multiple effects on same target', () => { - it('should generate comma-separated animation-name values in order', () => { - const result = getCSS(multipleEffectsConfig); - const { names } = getMultiEffectValues(); - const selector = buildFullSelector('multi-effect-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-name', - names.join(', '), - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + // ============================================================================ + // SUITE 11: TransitionProperties (alternative syntax) + // ============================================================================ + describe('transitionProperties', () => { + it('should support transitionProperties when used with transition', () => { + // Note: transitionProperties alone doesn't work; it needs transition to be present + const effect: TransitionEffect = { + transition: { + duration: 300, + styleProperties: [{ name: 'opacity', value: '1' }], + }, + transitionProperties: [ + { name: 'transform', value: 'translateY(0)', duration: 500, delay: 100 }, + ], + }; + const config = createConfig(effect as Effect, { effectId: 'prop-trans' }); + const result = getCSS(config); + + expect(result.transitionRules.length).toBeGreaterThan(0); + + const hasOpacity = result.transitionRules.some((rule) => rule.includes('opacity')); + expect(hasOpacity).toBe(true); + }); - it('should generate comma-separated animation-duration values in order', () => { - const result = getCSS(multipleEffectsConfig); - const { durations } = getMultiEffectValues(); - const selector = buildFullSelector('multi-effect-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-duration', - durations.map((d) => `${d}ms`).join(', '), - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + it('should NOT generate CSS for transitionProperties without transition', () => { + // Current implementation requires transition property to be present + const effect: TransitionEffect = { + transitionProperties: [ + { name: 'opacity', value: '1', duration: 300 }, + ], + }; + const config = createConfig(effect as Effect, { effectId: 'prop-trans' }); + const result = getCSS(config); - it('should generate comma-separated animation-delay values in order', () => { - const result = getCSS(multipleEffectsConfig); - const { delays } = getMultiEffectValues(); - const selector = buildFullSelector('multi-effect-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-delay', - delays.map((d) => `${d}ms`).join(', '), - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + // No CSS generated because resolveEffect returns null without transition + expect(result.transitionRules).toEqual([]); + }); + }); - it('should generate comma-separated animation-fill-mode values in order', () => { - const result = getCSS(multipleEffectsConfig); - const { fills } = getMultiEffectValues(); - const selector = buildFullSelector('multi-effect-element'); - - const pattern = createAnimationRulePattern( - selector, - 'animation-fill-mode', - fills.join(', '), - ); - expect(result.animationRules.some((rule) => pattern.test(rule))).toBe( - true, - ); - }); + // ============================================================================ + // SUITE 12: Effect Options + // ============================================================================ + describe('effect options', () => { + it('should handle all TimeEffect options correctly', () => { + const fullOptionsEffect: TimeEffect = { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 1000, + delay: 200, + easing: 'ease-in-out', + iterations: 3, + alternate: true, + fill: 'both', + reversed: true, + }; + const config = createConfig(fullOptionsEffect); + const result = getCSS(config); + const [expectedAnimation] = getAnimationStrings(fullOptionsEffect); + + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + + // Animation string from motion should be in the rules + const hasExpectedAnimation = result.animationRules.some((rule) => + rule.includes(expectedAnimation), + ); + expect(hasExpectedAnimation).toBe(true); + }); - it('should maintain consistent order across all animation properties', () => { - const result = getCSS(multipleEffectsConfig); - const { names, durations } = getMultiEffectValues(); - const selector = buildFullSelector('multi-effect-element'); - - // Verify both animation-name and animation-duration have correct order - const namePattern = createAnimationRulePattern( - selector, - 'animation-name', - names.join(', '), - ); - const durationPattern = createAnimationRulePattern( - selector, - 'animation-duration', - durations.map((d) => `${d}ms`).join(', '), - ); - - // Find the rule for this element - const rule = result.animationRules.find((r) => - r.includes('[data-interact-key="multi-effect-element"]'), - ); - expect(rule).toBeDefined(); - - // Both patterns should match the same rule - expect(namePattern.test(rule!)).toBe(true); - expect(durationPattern.test(rule!)).toBe(true); - }); + it('should handle infinite iterations (iterations: 0)', () => { + const infiniteEffect: TimeEffect = { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + iterations: 0, + }; + const config = createConfig(infiniteEffect); + const result = getCSS(config); + + const hasInfinite = result.animationRules.some((rule) => + rule.includes('infinite'), + ); + expect(hasInfinite).toBe(true); }); + }); - describe('trigger filtering', () => { - it('should NOT generate animation rules for viewProgress trigger', () => { - const result = getCSS(scrubTriggerConfig); - expect(result.animationRules).toHaveLength(0); - }); + // ============================================================================ + // SUITE 13: Inline Effects + // ============================================================================ + describe('inline effects', () => { + it('should support inline effect definition without effectId reference', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + trigger: 'viewEnter', + key: 'inline-element', + effects: [ + { + key: 'inline-element', + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 400, + } as Effect, + ], + }, + ], + }; + const result = getCSS(config); - it('should NOT generate animation rules for pointerMove trigger', () => { - const result = getCSS(pointerMoveTriggerConfig); - expect(result.animationRules).toHaveLength(0); - }); + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + }); + }); - it('should generate animation rules for all time-based triggers', () => { - const hoverResult = getCSS(hoverTriggerConfig); - const clickResult = getCSS(clickTriggerConfig); - const animEndResult = getCSS(animationEndTriggerConfig); + // ============================================================================ + // SUITE 14: Edge Cases + // ============================================================================ + describe('edge cases', () => { + it('should skip effects without namedEffect, keyframeEffect, or transition', () => { + const invalidEffect = { duration: 500 } as TimeEffect; + const config = createConfig(invalidEffect); + const result = getCSS(config); + + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + expect(result.transitionRules).toEqual([]); + }); - expect(hoverResult.animationRules.length).toBeGreaterThan(0); - expect(clickResult.animationRules.length).toBeGreaterThan(0); - expect(animEndResult.animationRules.length).toBeGreaterThan(0); - }); + it('should skip condition that does not exist in config.conditions', () => { + const config = createConfig(fadeInEffect, { + conditions: { existing: { type: 'media', predicate: 'min-width: 800px' } }, + effectConditions: ['nonexistent', 'existing'], + }); + const result = getCSS(config); + + // Should still generate CSS (with existing condition) + expect(result.animationRules.length).toBeGreaterThan(0); + + // Should have the existing media query + const hasExistingMedia = result.animationRules.some((rule) => + rule.includes('min-width: 800px'), + ); + expect(hasExistingMedia).toBe(true); }); - describe('edge cases', () => { - it('should return empty animation rules for empty config', () => { - const result = getCSS(emptyConfig); - expect(result.animationRules).toEqual([]); - }); + it('should handle empty keyframes array', () => { + const emptyKeyframesEffect: TimeEffect = { + keyframeEffect: { + name: 'empty-keyframes', + keyframes: [], + }, + duration: 500, + }; + const config = createConfig(emptyKeyframesEffect); + const result = getCSS(config); - it('should NOT generate animation rules for customEffect', () => { - const result = getCSS(customEffectConfig); - expect(result.animationRules).toHaveLength(0); - }); + // Empty keyframes should result in empty or no CSS + expect(result.keyframes).toEqual([]); + }); - it('should generate animation rules for inline effect definitions', () => { - const result = getCSS(inlineEffectConfig); - expect(result.animationRules.length).toBeGreaterThan(0); - }); + it('should handle mixed animation and transition effects in same interaction', () => { + const config: InteractConfig = { + effects: { + anim: fadeInEffect, + trans: transitionEffect as Effect, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'mixed', + effects: [ + { key: 'mixed', effectId: 'anim' }, + { key: 'mixed', effectId: 'trans' }, + ], + }, + ], + }; + const result = getCSS(config); + + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + expect(result.transitionRules.length).toBeGreaterThan(0); }); }); }); From 005a624da8db1bd529ae336c64b129de00d105d8 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 4 Jan 2026 15:02:48 +0200 Subject: [PATCH 11/18] PR comments --- packages/interact/src/core/css.ts | 23 +++++++++++++++-------- packages/interact/src/types.ts | 2 +- packages/interact/test/css.spec.ts | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 938aecd4..9eb92f27 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -97,7 +97,10 @@ function resolveEffect( ? { ...effectsMap[effectRef.effectId], ...effectRef } : { ...effectRef }; - if (fullEffect.namedEffect || fullEffect.keyframeEffect || fullEffect.transition) { + if (fullEffect.namedEffect || + fullEffect.keyframeEffect || + fullEffect.transition || + fullEffect.transitionProperties) { if (!fullEffect.key) { fullEffect.key = interaction.key; } @@ -109,11 +112,14 @@ function resolveEffect( const { keyframeEffect } = fullEffect; if (keyframeEffect && !keyframeEffect.name) { - keyframeEffect.name = (effectRef as TimeEffect & { keyframeEffect: MotionKeyframeEffect }).keyframeEffect ? - generateId() : effectRef.effectId; + // use effectId only if the keyframes are not overridden by effectRef or effectRef has a unique effectId (no reference) + const canUseEffectId = + (effectRef.effectId && !effectsMap[effectRef.effectId]) || + !(effectRef as TimeEffect & { keyframeEffect: MotionKeyframeEffect }).keyframeEffect; + keyframeEffect.name = canUseEffectId ? effectRef.effectId : generateId(); } - fullEffect.initial = fullEffect.initial === 'disable' ? + fullEffect.initial = fullEffect.initial === false ? undefined : (fullEffect.initial || DEFAULT_INITIAL); return fullEffect; @@ -242,12 +248,13 @@ export function getCSS(config: InteractConfig): GetCSSResult { if (!effect) continue; const childSelector = getSelector(effect, { - asCombinator: true, // TODO: (ameerf) - correct? - addItemFilter: Boolean(effect.listItemSelector), // TODO: (ameerf) - correct? + asCombinator: true, + addItemFilter: true, + useFirstChild: true, }); const escapedKey = CSS.escape(effect.key); - const keyWithNoSpecialChars = effect.key.replace(/[^a-zA-Z0-9_-]/g, ''); + const keyWithNoSpecialChars = effect.key.replace(/[^\w-]/g, ''); const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; const conditions = (effect.conditions || []); @@ -255,7 +262,7 @@ export function getCSS(config: InteractConfig): GetCSSResult { (effect as TransitionEffect).transition || (effect as TransitionEffect).transitionProperties ) { - const {stateRule, transitions} = getTransitionData(effect, childSelector); + const { stateRule, transitions } = getTransitionData(effect, childSelector); transitionRules.push(stateRule); if (transitions.length === 0) { continue; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index f2941961..e33c5af7 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -138,7 +138,7 @@ export type EffectBase = { conditions?: string[]; selector?: string; effectId?: string; - initial?: 'disable' | Record; + initial?: Record | false; }; export type EffectRef = EffectBase & { effectId: string }; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 491094a3..e80ec21f 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -294,10 +294,10 @@ describe('getCSS', () => { expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*transform:\s*scale\(0\.5\)/); }); - it('should NOT include initial when set to "disable"', () => { + it('should NOT include initial when set to false', () => { const effectWithDisabledInitial: Effect = { ...fadeInEffect, - initial: 'disable', + initial: false, }; const config = createConfig(effectWithDisabledInitial); const result = getCSS(config); From a41f451daf9a5cdd85e778fcd18827f8ad4a2bc8 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 11 Jan 2026 16:23:25 +0200 Subject: [PATCH 12/18] PR fixes --- packages/interact/src/core/css.ts | 285 ++++++++++++------------ packages/interact/src/core/utilities.ts | 34 +-- packages/interact/src/types.ts | 16 ++ 3 files changed, 181 insertions(+), 154 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 9eb92f27..cae15244 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -8,6 +8,8 @@ import type { CreateTransitionCSSParams, Interaction, Condition, + EffectCSSProps, + MotionCSSAnimationResult, } from '../types'; import { createStateRuleAndCSSTransitions, @@ -31,36 +33,10 @@ const DEFAULT_INITIAL = { rotate: 'none', }; -interface AnimationProps { - animation: string, - composition: CompositeOperation, - custom: Keyframe, - conditions: string[], - animationCustomPropName: string, -} - -interface TransitionProps { - transition: string, - conditions: string[], - transitionCustomPropName: string, -} -interface CSSAnimationResult { - animation: string, - composition: CompositeOperation, - custom: Keyframe, - name: string, - keyframes: Keyframe[], -} - -interface CSSTransitionResult { - stateRule: string, - transitions: string[], -} - function getTransitionData( effect: Effect & { key: string }, childSelector: string -): CSSTransitionResult { +) { const args: CreateTransitionCSSParams = { key: effect.key, effectId: (effect as Effect).effectId!, @@ -71,7 +47,7 @@ function getTransitionData( return createStateRuleAndCSSTransitions(args); } -function getAnimationData(effect: Effect): CSSAnimationResult[] { +function getAnimationData(effect: Effect): MotionCSSAnimationResult[] { const animationOptions = effectToAnimationOptions(effect as TimeEffect); const cssAnimations = getCSSAnimation(null, animationOptions); @@ -128,40 +104,13 @@ function resolveEffect( return null; } -function buildCascadingTransitionCustomPropRule( +function buildConditionalRule( selector: string, - props: TransitionProps, - configConditions: Record -) { - const { transitionCustomPropName, transition, conditions } = props; - - const declaration: string = `${transitionCustomPropName}: ${transition};`; - - const selectorCondition = getSelectorCondition(conditions, configConditions); - const targetSelector = selectorCondition ? - applySelectorCondition(selector, selectorCondition) : selector; - - let rule = `${targetSelector} { ${declaration} }`; - - ['container' as const, 'media' as const].forEach((type) => { - const predicate = getFullPredicateByType(conditions, configConditions, type); - if (predicate) { - rule = `@${type} ${predicate} { ${rule} }`; - } - }); - - return rule; -} - -function buildCascadingAnimationCustomPropRule( - selector: string, - props: AnimationProps, + propsToApply: Record, + conditions: string[], configConditions: Record ) { const declarations: string[] = []; - const { animationCustomPropName, animation, custom, conditions } = props; - - const propsToApply = { [animationCustomPropName]: animation, ...custom }; for (const [key, val] of Object.entries(propsToApply)) { if (val !== undefined && val !== null) { declarations.push(`${key}: ${val};`); @@ -184,38 +133,130 @@ function buildCascadingAnimationCustomPropRule( return rule; } -function buildTransitionRule( +function buildAnimationCompositionDeclaration(compositions: CompositeOperation[]) { + const compositionRepeatLength = shortestRepeatingPatternLength(compositions); + let resultCompositions = compositions.slice(0, compositionRepeatLength); + + if ((resultCompositions.length === 1 && resultCompositions[0] === 'replace') + || resultCompositions.length === 0) { + return ''; + } + + return `animation-composition: ${resultCompositions.join(', ')};` +} + +function buildUnconditionalRuleFromCustomProps( selector: string, - propsArray: TransitionProps[], -): string { - const declarations: string[] = []; + declarationPropName: string, + customPropNames: string[], + fallback: string, + extraDeclarations?: string[] +) { + const declarations: string[] = extraDeclarations && extraDeclarations.length ? [...extraDeclarations] : []; - const transitions = propsArray.map((props) => `var(${props.transitionCustomPropName}, _)`); - declarations.push(`transition: ${transitions.join(', ')};`); + const customProps = customPropNames.map((propName) => `var(${propName}, ${fallback})`); + declarations.push(`${declarationPropName}: ${customProps.join(', ')};`); return `${selector} { ${declarations.join(' ')} }`; } -function buildAnimationRule( +function generateTransitions( + selectorTransitionPropsMap: Map, + transitions: string[], selector: string, - propsArray: AnimationProps[], -): string { - const declarations: string[] = []; + escapedTargetKey: string, + conditions: string[] +) { + if (!selectorTransitionPropsMap.has(selector)) { + selectorTransitionPropsMap.set(selector, []); + } + const transitionPropsArray = selectorTransitionPropsMap.get(selector)!; + + for (const transition of transitions) { + const customPropName = `--trans-def-${escapedTargetKey}-${transitionPropsArray.length}`; + transitionPropsArray.push({ + declaration: transition, + conditions, + customPropName + }); + } +} - const animations = propsArray.map((props) => `var(${props.animationCustomPropName}, none)`); - declarations.push(`animation: ${animations.join(', ')};`); +function generateAnimations( + selectorAnimationPropsMap: Map, + keyframeMap: Map, + animationDataList: MotionCSSAnimationResult[], + initial: Effect['initial'], + selector: string, + escapedTargetKey: string, + conditions: string[], +) { + if (!selectorAnimationPropsMap.has(selector)) { + selectorAnimationPropsMap.set(selector, []); + } + const animationPropsArray = selectorAnimationPropsMap.get(selector)!; - let compositions = propsArray.map((props) => props.composition); - const compositionRepeatLength = shortestRepeatingPatternLength(compositions); - compositions = compositions.slice(0, compositionRepeatLength); + for (const data of animationDataList) { + const keyframeCSS = keyframesToCSS(data.name, data.keyframes, initial); + if (keyframeCSS) { + keyframeMap.set(data.name, keyframeCSS); + } - if (compositions.length === 1 && compositions[0] === 'replace') { - compositions = []; + const { animation, composition, custom } = data; + const customPropName = `--anim-def-${escapedTargetKey}-${animationPropsArray.length}`; + + animationPropsArray.push({ + declaration: animation, + composition, + custom, + conditions, + customPropName + }); } +} - declarations.push(`animation-composition: ${compositions.join(', ')};`); +function getRulesFromSelectorPropsMap( + selectorPropsMap: Map, + configConditions: Record, + isAnimation: boolean +) { + const rules: string[] = []; - return `${selector} { ${declarations.join(' ')} }`; + for (const [baseSelector, propsArray] of selectorPropsMap) { + propsArray.forEach((props) => { + const { customPropName, declaration, conditions, custom } = props; + + const propsToApply = { + [customPropName]: declaration, + ...(isAnimation ? custom : {}) + }; + + rules.push(buildConditionalRule( + baseSelector, + propsToApply, + conditions, + configConditions, + )); + }); + + const customPropNames = propsArray.map(({ customPropName }) => customPropName); + + const extraDeclarations = []; + if (isAnimation) { + const compositions = propsArray.map(({ composition }) => composition || 'replace'); + extraDeclarations.push(buildAnimationCompositionDeclaration(compositions)); + } + + rules.push(buildUnconditionalRuleFromCustomProps( + baseSelector, + isAnimation ? 'animation' : 'transition', + customPropNames, + isAnimation ? 'none' : '_', + extraDeclarations, + )); + } + + return rules; } /** @@ -228,11 +269,11 @@ export function getCSS(config: InteractConfig): GetCSSResult { const keyframeMap = new Map(); const selectorTransitionPropsMap = new Map< string, - TransitionProps[] + EffectCSSProps[] >(); const selectorAnimationPropsMap = new Map< string, - AnimationProps[] + EffectCSSProps[] >(); const transitionRules: string[] = []; @@ -266,21 +307,15 @@ export function getCSS(config: InteractConfig): GetCSSResult { transitionRules.push(stateRule); if (transitions.length === 0) { continue; - } - - if (!selectorTransitionPropsMap.has(selector)) { - selectorTransitionPropsMap.set(selector, []); - } - const transitionPropsArray = selectorTransitionPropsMap.get(selector)!; - - for (const transition of transitions) { - const transitionCustomPropName = `--trans-def-${keyWithNoSpecialChars}-${transitionPropsArray.length}`; - transitionPropsArray.push({ - transition, - conditions, - transitionCustomPropName - }); } + + generateTransitions( + selectorTransitionPropsMap, + transitions, + selector, + keyWithNoSpecialChars, + conditions + ); } if ( @@ -292,56 +327,30 @@ export function getCSS(config: InteractConfig): GetCSSResult { continue; } - if (!selectorAnimationPropsMap.has(selector)) { - selectorAnimationPropsMap.set(selector, []); - } - const animationPropsArray = selectorAnimationPropsMap.get(selector)!; - - for (const data of animationDataList) { - const keyframeCSS = keyframesToCSS(data.name, data.keyframes, effect.initial); - if (keyframeCSS) { - keyframeMap.set(data.name, keyframeCSS); - } - - const { animation, composition, custom } = data; - const animationCustomPropName = `--anim-def-${keyWithNoSpecialChars}-${animationPropsArray.length}`; - - animationPropsArray.push({ - animation, - composition, - custom, - conditions, - animationCustomPropName - }); - } + generateAnimations( + selectorAnimationPropsMap, + keyframeMap, + animationDataList, + effect.initial, + selector, + keyWithNoSpecialChars, + conditions + ); } } } - for (const [baseSelector, transitionPropsArray] of selectorTransitionPropsMap) { - transitionPropsArray.forEach((transitionProps) => { - transitionRules.push(buildCascadingTransitionCustomPropRule( - baseSelector, - transitionProps, - configConditions - )); - }); - - transitionRules.push(buildTransitionRule(baseSelector, transitionPropsArray)); - } + transitionRules.push(...getRulesFromSelectorPropsMap( + selectorTransitionPropsMap, + configConditions, + false + )); + const animationRules: string[] = getRulesFromSelectorPropsMap( + selectorTransitionPropsMap, + configConditions, + true + ); - const animationRules: string[] = []; - for (const [baseSelector, animationPropsArray] of selectorAnimationPropsMap) { - animationPropsArray.forEach((animationProps) => { - animationRules.push(buildCascadingAnimationCustomPropRule( - baseSelector, - animationProps, - configConditions - )); - }); - - animationRules.push(buildAnimationRule(baseSelector, animationPropsArray)); - } return { keyframes: Array.from(keyframeMap.values()), diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index 70ba514c..0612cb99 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -69,37 +69,39 @@ function keyframePropertyToCSS(key: string): string { if (key === 'cssOffset') { return 'offset' } + if (key === 'composite') { + return 'animation-composition' + } return key.replace(/([A-Z])/g, '-$1').toLowerCase(); } +function keyframeObjectToKeyframeCSS(keyframeObj: Keyframe, offsetString: string): string { + const props = Object.entries(keyframeObj) + .filter(([key, value]) => key !== 'offset' && value !== undefined && value !== null) + .map(([key, value]) => { + const cssKey = keyframePropertyToCSS(key); + return `${cssKey}: ${value};`; + }) + .join(' '); + return `${offsetString} { ${props} }`; +} + export function keyframesToCSS(name: string, keyframes: Keyframe[], initial?: any): string { + if (!keyframes || keyframes.length === 0) return ''; const interpolated = interpolateKeyframesOffsets(keyframes); - if (keyframes.length === 0) return ''; let keyframeBlocks = interpolated .map((kf) => { const offset = kf.offset as number; const percentage = roundNumber(offset * 100); - const properties = Object.entries(kf) - .filter(([key, value]) => key !== 'offset' && value !== undefined && value !== null) - .map(([key, value]) => { - const cssKey = keyframePropertyToCSS(key); - return `${cssKey}: ${value};`; - }) - .join(' '); - - return `${percentage}% { ${properties} }`; + return keyframeObjectToKeyframeCSS(kf, `${percentage}%`); }) .join(' '); if (initial) { - const fromFrame = Object.entries(initial) - .map(([key, value]) => { - return `${key}: ${value};`; - }) - .join(' '); - keyframeBlocks = `from { ${fromFrame} } ${keyframeBlocks}`; + const fromFrame = keyframeObjectToKeyframeCSS(initial, 'from'); + keyframeBlocks = `${fromFrame} ${keyframeBlocks}`; } return `@keyframes ${name} { ${keyframeBlocks} }`; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 108e5ca0..ad87dd6e 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -287,6 +287,22 @@ export type CreateTransitionCSSParams = { useFirstChild?: boolean; }; +export type EffectCSSProps = { + declaration: string, + conditions: string[], + customPropName: string, + composition?: CompositeOperation, + custom?: Keyframe, +}; + +export type MotionCSSAnimationResult = { + animation: string, + composition: CompositeOperation, + custom: Keyframe, + name: string, + keyframes: Keyframe[], +}; + export type GetCSSResult = { /** @keyframes rules for the animations */ keyframes: string[]; From 29abbe82b55188e50d6ba714b9be18a94ce7a731 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 11 Jan 2026 16:30:24 +0200 Subject: [PATCH 13/18] PR fixes --- packages/interact/src/core/css.ts | 12 +++- packages/interact/src/index.ts | 2 +- packages/interact/test/css.spec.ts | 92 +++++++++++++++--------------- 3 files changed, 56 insertions(+), 50 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index cae15244..a06bc847 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -265,7 +265,7 @@ function getRulesFromSelectorPropsMap( * @param config - The interact configuration containing effects and interactions * @returns GetCSSResult with keyframes and animationRules */ -export function getCSS(config: InteractConfig): GetCSSResult { +export function _generateCSS(config: InteractConfig): GetCSSResult { const keyframeMap = new Map(); const selectorTransitionPropsMap = new Map< string, @@ -359,8 +359,14 @@ export function getCSS(config: InteractConfig): GetCSSResult { }; } -export function generate(config: InteractConfig): string { - const { keyframes, animationRules, transitionRules } = getCSS(config); +/** + * 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 generateCSS(config: InteractConfig): string { + const { keyframes, animationRules, transitionRules } = _generateCSS(config); const css: string[] = [...keyframes, ...animationRules, ...transitionRules]; return css.join('\n'); } diff --git a/packages/interact/src/index.ts b/packages/interact/src/index.ts index bffc5e20..f281ea54 100644 --- a/packages/interact/src/index.ts +++ b/packages/interact/src/index.ts @@ -1,5 +1,5 @@ export { Interact } from './core/Interact'; export { add, remove } from './dom/api'; -export { generate } from './core/css'; +export { generateCSS } from './core/css'; export * from './types'; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index e80ec21f..08f2661d 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; import type { InteractConfig, Effect, TimeEffect, TransitionEffect } from '../src/types'; import type { NamedEffect } from '@wix/motion'; -import { getCSS } from '../src/core/css'; +import { _generateCSS } from '../src/core/css'; import { getCSSAnimation } from '@wix/motion'; import { effectToAnimationOptions } from '../src/handlers/utilities'; @@ -16,13 +16,13 @@ beforeAll(() => { }); /** - * getCSS Test Suite + * _generateCSS Test Suite * * Tests CSS generation for time-based animations and transitions. * - Generates CSS for triggers: viewEnter, animationEnd, hover, click, pageVisible * - Does NOT generate CSS for scrub triggers: viewProgress, pointerMove */ -describe('getCSS', () => { +describe('_generateCSS', () => { // ============================================================================ // Test Helpers // ============================================================================ @@ -139,7 +139,7 @@ describe('getCSS', () => { // ============================================================================ describe('return structure', () => { it('should return an object with keyframes, animationRules, and transitionRules arrays', () => { - const result = getCSS({ effects: {}, interactions: [] }); + const result = _generateCSS({ effects: {}, interactions: [] }); expect(result).toHaveProperty('keyframes'); expect(result).toHaveProperty('animationRules'); @@ -150,7 +150,7 @@ describe('getCSS', () => { }); it('should return empty arrays for empty config', () => { - const result = getCSS({ effects: {}, interactions: [] }); + const result = _generateCSS({ effects: {}, interactions: [] }); expect(result.keyframes).toEqual([]); expect(result.animationRules).toEqual([]); @@ -167,7 +167,7 @@ describe('getCSS', () => { it.each(timeTriggers)('should generate CSS for %s trigger', (trigger) => { const config = createConfig(fadeInEffect, { trigger }); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes.length).toBeGreaterThan(0); expect(result.animationRules.length).toBeGreaterThan(0); @@ -175,7 +175,7 @@ describe('getCSS', () => { it.each(scrubTriggers)('should NOT generate CSS for %s trigger', (trigger) => { const config = createConfig(fadeInEffect, { trigger }); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes).toEqual([]); expect(result.animationRules).toEqual([]); @@ -188,7 +188,7 @@ describe('getCSS', () => { describe('keyframes generation', () => { it('should generate valid @keyframes rule for namedEffect', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const [expectedName] = getKeyframeNames(fadeInEffect); expect(result.keyframes).toHaveLength(1); @@ -199,7 +199,7 @@ describe('getCSS', () => { it('should generate @keyframes with custom name for keyframeEffect', () => { const config = createConfig(keyframeEffect); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes).toHaveLength(1); expect(result.keyframes[0]).toMatch(keyframesPattern('custom-slide')); @@ -213,7 +213,7 @@ describe('getCSS', () => { duration: 1000, }; const config = createConfig(arcInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const expectedNames = getKeyframeNames(arcInEffect); expect(result.keyframes.length).toBe(expectedNames.length); @@ -230,7 +230,7 @@ describe('getCSS', () => { { trigger: 'viewEnter', key: 'el-b', effects: [{ key: 'el-b', effectId: 'shared' }] }, ], }; - const result = getCSS(config); + const result = _generateCSS(config); const [expectedName] = getKeyframeNames(fadeInEffect); const keyframesWithName = result.keyframes.filter((kf) => kf.includes(expectedName)); @@ -250,7 +250,7 @@ describe('getCSS', () => { duration: 1000, }; const config = createConfig(interpolatedEffect); - const result = getCSS(config); + const result = _generateCSS(config); // Should have 0%, 50%, 100% (evenly distributed) expect(result.keyframes[0]).toMatch(/0%\s*\{/); @@ -264,7 +264,7 @@ describe('getCSS', () => { duration: 1000, } as Effect; const config = createConfig(customEffect); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes).toEqual([]); }); @@ -276,7 +276,7 @@ describe('getCSS', () => { describe('initial state in keyframes', () => { it('should include default initial state properties in keyframes', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); // Default initial includes visibility: hidden expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*visibility:\s*hidden/); @@ -288,7 +288,7 @@ describe('getCSS', () => { initial: { opacity: '0', transform: 'scale(0.5)' }, }; const config = createConfig(effectWithInitial); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*opacity:\s*0/); expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*transform:\s*scale\(0\.5\)/); @@ -300,7 +300,7 @@ describe('getCSS', () => { initial: false, }; const config = createConfig(effectWithDisabledInitial); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes[0]).not.toMatch(/from\s*\{/); }); @@ -312,7 +312,7 @@ describe('getCSS', () => { describe('animation rules', () => { it('should generate animation rule with correct selector', () => { const config = createConfig(fadeInEffect, { key: 'my-element' }); - const result = getCSS(config); + const result = _generateCSS(config); const hasCorrectSelector = result.animationRules.some((rule) => rule.includes('[data-interact-key="my-element"]'), @@ -322,7 +322,7 @@ describe('getCSS', () => { it('should use CSS custom properties for animation definition', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); // Animation rules should use --anim-def-* custom properties const hasCustomProp = result.animationRules.some((rule) => @@ -333,7 +333,7 @@ describe('getCSS', () => { it('should include animation property with var() fallback', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const hasAnimationWithVar = result.animationRules.some((rule) => /animation:\s*var\(--anim-def-[^,]+,\s*none\)/.test(rule), @@ -343,7 +343,7 @@ describe('getCSS', () => { it('should include animation string from motion library', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const [expectedAnimation] = getAnimationStrings(fadeInEffect); const hasMotionAnimation = result.animationRules.some((rule) => @@ -354,7 +354,7 @@ describe('getCSS', () => { it('should include animation-composition property', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const hasComposition = result.animationRules.some((rule) => rule.includes('animation-composition:'), @@ -379,7 +379,7 @@ describe('getCSS', () => { }, ], }; - const result = getCSS(config); + const result = _generateCSS(config); // Should have comma-separated var() calls in animation property const hasMultipleVars = result.animationRules.some((rule) => @@ -395,7 +395,7 @@ describe('getCSS', () => { describe('selectors', () => { it('should escape special characters in key', () => { const config = createConfig(fadeInEffect, { key: 'element.with:special#chars' }); - const result = getCSS(config); + const result = _generateCSS(config); // CSS.escape handles special chars const hasEscapedSelector = result.animationRules.some((rule) => @@ -406,7 +406,7 @@ describe('getCSS', () => { it('should include custom selector when specified', () => { const config = createConfig(fadeInEffect, { selector: '.child-target' }); - const result = getCSS(config); + const result = _generateCSS(config); const hasCustomSelector = result.animationRules.some((rule) => rule.includes('.child-target'), @@ -419,7 +419,7 @@ describe('getCSS', () => { listContainer: '.items-container', listItemSelector: '.item', }); - const result = getCSS(config); + const result = _generateCSS(config); const hasListSelector = result.animationRules.some( (rule) => rule.includes('.items-container') && rule.includes('.item'), @@ -429,7 +429,7 @@ describe('getCSS', () => { it('should use default child selector (> :first-child) when no selector specified', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const hasDefaultSelector = result.animationRules.some((rule) => rule.includes('> :first-child'), @@ -447,7 +447,7 @@ describe('getCSS', () => { conditions: { desktop: { type: 'media', predicate: 'min-width: 1024px' } }, effectConditions: ['desktop'], }); - const result = getCSS(config); + const result = _generateCSS(config); const hasMediaQuery = result.animationRules.some((rule) => /@media\s*\(min-width:\s*1024px\)/.test(rule), @@ -463,7 +463,7 @@ describe('getCSS', () => { }, effectConditions: ['desktop', 'landscape'], }); - const result = getCSS(config); + const result = _generateCSS(config); const hasCombinedMedia = result.animationRules.some((rule) => /@media\s*\([^)]+\)\s*and\s*\([^)]+\)/.test(rule), @@ -473,7 +473,7 @@ describe('getCSS', () => { it('should NOT wrap in @media when no conditions', () => { const config = createConfig(fadeInEffect); - const result = getCSS(config); + const result = _generateCSS(config); const hasMediaQuery = result.animationRules.some((rule) => rule.includes('@media')); expect(hasMediaQuery).toBe(false); @@ -489,7 +489,7 @@ describe('getCSS', () => { conditions: { wide: { type: 'container', predicate: 'min-width: 500px' } }, effectConditions: ['wide'], }); - const result = getCSS(config); + const result = _generateCSS(config); const hasContainerQuery = result.animationRules.some((rule) => /@container\s*\(min-width:\s*500px\)/.test(rule), @@ -507,7 +507,7 @@ describe('getCSS', () => { conditions: { active: { type: 'selector', predicate: '.is-active' } }, effectConditions: ['active'], }); - const result = getCSS(config); + const result = _generateCSS(config); const hasSelectorCondition = result.animationRules.some((rule) => rule.includes(':is(.is-active)'), @@ -522,14 +522,14 @@ describe('getCSS', () => { describe('transitions', () => { it('should generate transition rules for transition effects', () => { const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.transitionRules.length).toBeGreaterThan(0); }); it('should generate state rule with :state() and data-interact-effect selectors', () => { const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); - const result = getCSS(config); + const result = _generateCSS(config); const hasStateSelector = result.transitionRules.some((rule) => /:state\(trans-effect\)/.test(rule) || /--trans-effect/.test(rule), @@ -544,7 +544,7 @@ describe('getCSS', () => { it('should include style properties in state rule', () => { const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); - const result = getCSS(config); + const result = _generateCSS(config); const hasOpacity = result.transitionRules.some((rule) => propertyPattern('opacity', '1').test(rule), @@ -559,7 +559,7 @@ describe('getCSS', () => { it('should use CSS custom properties for transition definition', () => { const config = createConfig(transitionEffect as Effect); - const result = getCSS(config); + const result = _generateCSS(config); const hasTransitionCustomProp = result.transitionRules.some((rule) => /--trans-def-\w+/.test(rule), @@ -569,7 +569,7 @@ describe('getCSS', () => { it('should include transition property with var() fallback', () => { const config = createConfig(transitionEffect as Effect); - const result = getCSS(config); + const result = _generateCSS(config); const hasTransitionWithVar = result.transitionRules.some((rule) => /transition:\s*var\(--trans-def-[^,]+,\s*_\)/.test(rule), @@ -579,7 +579,7 @@ describe('getCSS', () => { it('should NOT generate animation rules for transition-only effects', () => { const config = createConfig(transitionEffect as Effect); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes).toEqual([]); expect(result.animationRules).toEqual([]); @@ -602,7 +602,7 @@ describe('getCSS', () => { ], }; const config = createConfig(effect as Effect, { effectId: 'prop-trans' }); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.transitionRules.length).toBeGreaterThan(0); @@ -618,7 +618,7 @@ describe('getCSS', () => { ], }; const config = createConfig(effect as Effect, { effectId: 'prop-trans' }); - const result = getCSS(config); + const result = _generateCSS(config); // No CSS generated because resolveEffect returns null without transition expect(result.transitionRules).toEqual([]); @@ -641,7 +641,7 @@ describe('getCSS', () => { reversed: true, }; const config = createConfig(fullOptionsEffect); - const result = getCSS(config); + const result = _generateCSS(config); const [expectedAnimation] = getAnimationStrings(fullOptionsEffect); expect(result.keyframes.length).toBeGreaterThan(0); @@ -661,7 +661,7 @@ describe('getCSS', () => { iterations: 0, }; const config = createConfig(infiniteEffect); - const result = getCSS(config); + const result = _generateCSS(config); const hasInfinite = result.animationRules.some((rule) => rule.includes('infinite'), @@ -691,7 +691,7 @@ describe('getCSS', () => { }, ], }; - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes.length).toBeGreaterThan(0); expect(result.animationRules.length).toBeGreaterThan(0); @@ -705,7 +705,7 @@ describe('getCSS', () => { it('should skip effects without namedEffect, keyframeEffect, or transition', () => { const invalidEffect = { duration: 500 } as TimeEffect; const config = createConfig(invalidEffect); - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes).toEqual([]); expect(result.animationRules).toEqual([]); @@ -717,7 +717,7 @@ describe('getCSS', () => { conditions: { existing: { type: 'media', predicate: 'min-width: 800px' } }, effectConditions: ['nonexistent', 'existing'], }); - const result = getCSS(config); + const result = _generateCSS(config); // Should still generate CSS (with existing condition) expect(result.animationRules.length).toBeGreaterThan(0); @@ -738,7 +738,7 @@ describe('getCSS', () => { duration: 500, }; const config = createConfig(emptyKeyframesEffect); - const result = getCSS(config); + const result = _generateCSS(config); // Empty keyframes should result in empty or no CSS expect(result.keyframes).toEqual([]); @@ -761,7 +761,7 @@ describe('getCSS', () => { }, ], }; - const result = getCSS(config); + const result = _generateCSS(config); expect(result.keyframes.length).toBeGreaterThan(0); expect(result.animationRules.length).toBeGreaterThan(0); From bb114f41803118a04a737a4662b848150db81813 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Sun, 11 Jan 2026 23:36:13 +0200 Subject: [PATCH 14/18] infra changes --- packages/interact/src/core/css.ts | 10 ++++++---- packages/interact/src/react/index.ts | 2 +- packages/interact/src/web/index.ts | 2 +- packages/interact/test/css.spec.ts | 14 -------------- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index a06bc847..3dfe2aa4 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -137,8 +137,7 @@ function buildAnimationCompositionDeclaration(compositions: CompositeOperation[] const compositionRepeatLength = shortestRepeatingPatternLength(compositions); let resultCompositions = compositions.slice(0, compositionRepeatLength); - if ((resultCompositions.length === 1 && resultCompositions[0] === 'replace') - || resultCompositions.length === 0) { + if (resultCompositions.length === 0) { return ''; } @@ -244,7 +243,10 @@ function getRulesFromSelectorPropsMap( const extraDeclarations = []; if (isAnimation) { const compositions = propsArray.map(({ composition }) => composition || 'replace'); - extraDeclarations.push(buildAnimationCompositionDeclaration(compositions)); + const compositionDeclaration = buildAnimationCompositionDeclaration(compositions); + if (compositionDeclaration) { + extraDeclarations.push(buildAnimationCompositionDeclaration(compositions)); + } } rules.push(buildUnconditionalRuleFromCustomProps( @@ -346,7 +348,7 @@ export function _generateCSS(config: InteractConfig): GetCSSResult { false )); const animationRules: string[] = getRulesFromSelectorPropsMap( - selectorTransitionPropsMap, + selectorAnimationPropsMap, configConditions, true ); diff --git a/packages/interact/src/react/index.ts b/packages/interact/src/react/index.ts index be577ea4..7a29c887 100644 --- a/packages/interact/src/react/index.ts +++ b/packages/interact/src/react/index.ts @@ -2,7 +2,7 @@ export { Interaction } from './Interaction'; export { createInteractRef } from './interactRef'; export { add, remove } from '../dom/api'; -export { generate } from '../core/css'; +export { generateCSS } from '../core/css'; export { Interact } from '../core/Interact'; export type { InteractRef } from './interactRef'; diff --git a/packages/interact/src/web/index.ts b/packages/interact/src/web/index.ts index f0558fd0..1d2a9c99 100644 --- a/packages/interact/src/web/index.ts +++ b/packages/interact/src/web/index.ts @@ -4,7 +4,7 @@ import { Interact } from '../core/Interact'; Interact.defineInteractElement = defineInteractElement; export { add, remove } from '../dom/api'; -export { generate } from '../core/css'; +export { generateCSS } from '../core/css'; export { Interact }; export * from '../types'; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 08f2661d..339e18ad 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -609,20 +609,6 @@ describe('_generateCSS', () => { const hasOpacity = result.transitionRules.some((rule) => rule.includes('opacity')); expect(hasOpacity).toBe(true); }); - - it('should NOT generate CSS for transitionProperties without transition', () => { - // Current implementation requires transition property to be present - const effect: TransitionEffect = { - transitionProperties: [ - { name: 'opacity', value: '1', duration: 300 }, - ], - }; - const config = createConfig(effect as Effect, { effectId: 'prop-trans' }); - const result = _generateCSS(config); - - // No CSS generated because resolveEffect returns null without transition - expect(result.transitionRules).toEqual([]); - }); }); // ============================================================================ From 1dfe84b702c021488358aac9ead6f0eae97db526 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 12 Jan 2026 00:33:31 +0200 Subject: [PATCH 15/18] docs and demo - v1 --- apps/demo/src/react/App.tsx | 2 + apps/demo/src/web/App.tsx | 2 + packages/interact/docs/api/functions.md | 327 ++++++++++++++++++ .../docs/examples/entrance-animations.md | 120 ++++++- .../docs/guides/effects-and-animations.md | 149 ++++++++ packages/interact/docs/integration/react.md | 161 ++++++++- packages/interact/rules/viewenter.md | 303 ++++++++++++++++ 7 files changed, 1036 insertions(+), 28 deletions(-) diff --git a/apps/demo/src/react/App.tsx b/apps/demo/src/react/App.tsx index e8b58723..dcdcc934 100644 --- a/apps/demo/src/react/App.tsx +++ b/apps/demo/src/react/App.tsx @@ -2,6 +2,7 @@ import { Playground } from './components/Playground'; import { ScrollShowcase } from './components/ScrollShowcase'; import { ResponsiveDemo } from './components/ResponsiveDemo'; import { SelectorConditionDemo } from './components/SelectorConditionDemo'; +import { CSSGenerationDemo } from './components/CSSGenerationDemo'; const heroCopy = [ 'Tune triggers, easings, and delays in real time.', @@ -32,6 +33,7 @@ function App() { +
diff --git a/apps/demo/src/web/App.tsx b/apps/demo/src/web/App.tsx index 5c0537b3..f2505885 100644 --- a/apps/demo/src/web/App.tsx +++ b/apps/demo/src/web/App.tsx @@ -2,6 +2,7 @@ import { Playground } from './components/Playground'; import { ScrollShowcase } from './components/ScrollShowcase'; import { ResponsiveDemo } from './components/ResponsiveDemo'; import { SelectorConditionDemo } from './components/SelectorConditionDemo'; +import { CSSGenerationDemo } from './components/CSSGenerationDemo'; const heroCopy = [ 'Tune triggers, easings, and delays in real time.', @@ -32,6 +33,7 @@ function App() { +
diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index e3410842..c210ed61 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -627,6 +627,333 @@ For the generated CSS to work, the `` must have the `data-inte --- +## `generateCSS(config)` + +Generates complete CSS for time-based animations from an `InteractConfig`. This function is designed for **Server-Side Rendering (SSR)** or **efficient Client-Side Rendering (CSR)** by pre-generating CSS animations that can be rendered in a ` + + +
+
+

Welcome

+
+
+ + +`; +``` + +#### Client-Side Rendering with Pre-Generated CSS + +For CSR applications, inject the CSS before the first paint: + +```typescript +import { Interact, generateCSS } from '@wix/interact'; + +const config = {/* your config */}; + +// Generate and inject CSS immediately +const css = generateCSS(config); +const style = document.createElement('style'); +style.id = 'interact-css'; +style.textContent = css; +document.head.appendChild(style); + +// Then initialize Interact (can happen later, even after hydration) +Interact.create(config); +``` + +#### React SSR with Next.js + +```tsx +// app/layout.tsx (App Router) +import { generateCSS } from '@wix/interact'; +import { interactConfig } from './interact-config'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const animationCSS = generateCSS(interactConfig); + + return ( + + + + - +

Welcome to Our Site

This content fades in smoothly without flash

- +
`; ``` +### The `initial` Property + +The `initial` property defines the pre-animation state of elements. This is what gets rendered in the `from` keyframe of the generated CSS: + +```typescript +// Default initial (when not specified) +{ + visibility: 'hidden', + transform: 'none', + translate: 'none', + scale: 'none', + rotate: 'none', +} + +// Custom initial - should match your first keyframe +{ + initial: { + opacity: 0, + transform: 'translateY(40px)', + filter: 'blur(10px)' // for blur effects + } +} + +// Disable initial (element always visible) +{ + initial: false +} +``` + +### Client-Side Rendering with `generateCSS()` + +For CSR applications, inject the CSS before first paint: + +```typescript +import { Interact, generateCSS } from '@wix/interact'; + +const config = {/* your config */}; + +// Generate and inject CSS immediately on load +const css = generateCSS(config); +const style = document.createElement('style'); +style.id = 'interact-animations'; +style.textContent = css; +document.head.appendChild(style); + +// Initialize Interact for trigger handling +Interact.create(config); +``` + +### Using `generate()` (Simple Approach) + +For simpler cases, `generate()` creates CSS that hides elements until JavaScript runs: + +```typescript +import { generate } from '@wix/interact'; + +const css = generate(config); + +// Requires data-interact-initial="true" on elements +const html = ` + + +
Content
+
+`; +``` + ### HTML Markup -Add `data-interact-initial="true"` to the `` that has a child that should be hidden until its entrance animation: +For `generateCSS()`, use `data-interact-key` on any element: ```html - +

Welcome to Our Site

This content fades in smoothly without flash

+
+``` + +For `generate()`, also add `data-interact-initial="true"`: + +```html + +
+

Welcome to Our Site

+
``` ### Accessibility -The generated CSS respects `prefers-reduced-motion`. Users who prefer reduced motion will see content immediately without waiting for animations. +Both functions respect `prefers-reduced-motion`. The generated CSS conditions ensure users who prefer reduced motion see content immediately. -See the [generate() function documentation](../api/functions.md#generate) for more details. +See the [generateCSS() function documentation](../api/functions.md#generatecss) for complete API details. --- diff --git a/packages/interact/docs/guides/effects-and-animations.md b/packages/interact/docs/guides/effects-and-animations.md index e2843007..07714088 100644 --- a/packages/interact/docs/guides/effects-and-animations.md +++ b/packages/interact/docs/guides/effects-and-animations.md @@ -573,6 +573,154 @@ Avoid animating: } ``` +## Initial State and CSS Generation + +### The `initial` Property + +The `initial` property defines the CSS state of an element **before** its animation starts. This is essential for entrance animations to prevent a "flash" of the final state before the animation begins. + +```typescript +type initial = Record | false; +``` + +#### Default Initial State + +When `initial` is not specified on an effect, `generateCSS` applies these defaults: + +```typescript +{ + visibility: 'hidden', + transform: 'none', + translate: 'none', + scale: 'none', + rotate: 'none', +} +``` + +This hides the element and resets transforms so the animation starts from a clean state. + +#### Custom Initial State + +For animations that start from a specific visual state (not just hidden), provide a custom `initial`: + +```typescript +{ + key: 'hero', + keyframeEffect: { + name: 'blur-reveal', + keyframes: [ + { filter: 'blur(20px)', opacity: 0, transform: 'scale(0.9)' }, + { filter: 'blur(0)', opacity: 1, transform: 'scale(1)' } + ] + }, + duration: 1000, + initial: { + filter: 'blur(20px)', + opacity: 0, + transform: 'scale(0.9)' + } +} +``` + +The custom `initial` should match your animation's first keyframe to ensure a seamless transition. + +#### Disabling Initial State + +Set `initial: false` when you don't want elements hidden before animation: + +```typescript +{ + key: 'always-visible', + namedEffect: { type: 'Pulse' }, + duration: 500, + initial: false // Element visible immediately +} +``` + +Use this for: +- Animations on already-visible elements +- Hover/click effects that don't need hiding +- Looping animations + +### Pre-Generating CSS with `generateCSS` + +For optimal performance, especially in SSR scenarios, use `generateCSS` to pre-render animation styles: + +```typescript +import { generateCSS, Interact, InteractConfig } from '@wix/interact'; + +const config: InteractConfig = { + interactions: [{ + key: 'hero', + trigger: 'viewEnter', + params: { type: 'once' }, + effects: [{ + keyframeEffect: { + name: 'fade-up', + keyframes: [ + { opacity: 0, transform: 'translateY(30px)' }, + { opacity: 1, transform: 'translateY(0)' } + ] + }, + duration: 800, + initial: { + opacity: 0, + transform: 'translateY(30px)' + } + }] + }], + effects: {} +}; + +// Generate CSS at build time or on server +const css = generateCSS(config); + +// Inject into document head +const style = document.createElement('style'); +style.textContent = css; +document.head.appendChild(style); + +// Initialize Interact for runtime trigger handling +Interact.create(config); +``` + +#### Benefits of CSS Generation + +| Approach | Initial Render | Animation Start | SSR Compatible | +|----------|---------------|-----------------|----------------| +| JavaScript-only | Flash possible | After hydration | ❌ | +| `generateCSS` | Smooth | Immediate | ✅ | + +#### Generated CSS Structure + +`generateCSS` outputs: + +1. **@keyframes with initial state**: The `from` keyframe contains your `initial` properties +2. **Animation rules**: Apply animations via `[data-interact-key]` selectors +3. **CSS custom properties**: Allow conditional activation via media/container queries + +```css +/* Example output */ +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(30px); + } + 0% { opacity: 0; transform: translateY(30px); } + 100% { opacity: 1; transform: translateY(0); } +} + +[data-interact-key="hero"] > :first-child { + --anim-def-hero-0: fade-up 800ms ease forwards; +} + +[data-interact-key="hero"] > :first-child { + animation: var(--anim-def-hero-0, none); +} +``` + +See [generateCSS API](../api/functions.md#generatecss) for complete documentation. + ## Best Practices ### Animation Timing @@ -632,3 +780,4 @@ Now that you understand effects and animations: - **[Configuration Structure](./configuration-structure.md)** - Organize complex interactions - **[State Management](./state-management.md)** - Advanced state handling - **[Conditions and Media Queries](./conditions-and-media-queries.md)** - Responsive animations +- **[generateCSS API](../api/functions.md#generatecss)** - Pre-generate CSS for SSR and performance diff --git a/packages/interact/docs/integration/react.md b/packages/interact/docs/integration/react.md index 275a667d..48799a3e 100644 --- a/packages/interact/docs/integration/react.md +++ b/packages/interact/docs/integration/react.md @@ -515,44 +515,148 @@ function ProductList() { ## Server-Side Rendering (SSR) -### Next.js App Router +For SSR, use `generateCSS()` to pre-render animation styles on the server, preventing flash of unstyled content (FOUC) and enabling animations before JavaScript hydration. + +### Next.js App Router with `generateCSS()` + +```tsx +// lib/interact-config.ts +import { InteractConfig } from '@wix/interact/react'; + +export const interactConfig: InteractConfig = { + interactions: [{ + key: 'hero', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.2 }, + effects: [{ + keyframeEffect: { + name: 'hero-entrance', + keyframes: [ + { opacity: 0, transform: 'translateY(40px)' }, + { opacity: 1, transform: 'translateY(0)' } + ] + }, + duration: 800, + easing: 'ease-out', + // Initial state for SSR - matches first keyframe + initial: { + opacity: 0, + transform: 'translateY(40px)' + } + }] + }], + effects: {} +}; +``` ```tsx -// app/components/InteractiveCard.tsx +// app/layout.tsx +import { generateCSS } from '@wix/interact/react'; +import { interactConfig } from '@/lib/interact-config'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + // Generate CSS at build time (called once per build) + const animationCSS = generateCSS(interactConfig); + + return ( + + + {/* Inject animation CSS before first paint */} + + +`; + +// Initialize Interact for runtime triggers +Interact.create(config); +``` + +**Example - Full SSR Setup**: +```typescript +import { generateCSS, InteractConfig } from '@wix/interact'; + +const config: InteractConfig = { + conditions: { + 'motion-ok': { + type: 'media', + predicate: '(prefers-reduced-motion: no-preference)' + } + }, + interactions: [ + { + key: 'hero-section', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.2 }, + effects: [ + { + key: 'hero-section', + keyframeEffect: { + name: 'hero-entrance', + keyframes: [ + { opacity: 0, transform: 'translateY(40px) scale(0.95)' }, + { opacity: 1, transform: 'translateY(0) scale(1)' } + ] + }, + duration: 800, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + initial: { + opacity: 0, + transform: 'translateY(40px) scale(0.95)' + }, + conditions: ['motion-ok'] + } + ] + }, + { + key: 'feature-cards', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.3 }, + listContainer: '.cards-grid', + effects: [ + { + key: 'feature-cards', + listContainer: '.cards-grid', + keyframeEffect: { + name: 'card-entrance', + keyframes: [ + { opacity: 0, transform: 'translateY(30px)' }, + { opacity: 1, transform: 'translateY(0)' } + ] + }, + duration: 600, + easing: 'ease-out', + initial: { + opacity: 0, + transform: 'translateY(30px)' + }, + conditions: ['motion-ok'] + } + ] + } + ], + effects: {} +}; + +// Server/build-time +const css = generateCSS(config); + +// Output in HTML +const serverHTML = ` + + + + + + +
+
+

Welcome

+
+
+
+
+
Feature 1
+
Feature 2
+
Feature 3
+
+
+ + +`; +``` + +**Generated CSS Includes**: +1. `@keyframes` rules with `from` block containing initial state +2. Animation rules targeting `[data-interact-key]` selectors +3. Media/container query wrappers for conditional animations +4. Transition rules for transition effects + +**Best Practices**: +1. **Match initial to first keyframe**: Ensures seamless animation start +2. **Use conditions for accessibility**: Wrap in `prefers-reduced-motion` check +3. **Cache the CSS output**: It's deterministic based on config +4. **Inject early**: Place in `` before content renders + +--- + These rules provide comprehensive coverage for ViewEnter trigger interactions in `@wix/interact`, supporting all four behavior types and various intersection observer configurations as outlined in the development plan. From a7d644ddae9e58432393652acbd07333c3e20241 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Mon, 12 Jan 2026 00:37:44 +0200 Subject: [PATCH 16/18] docs and demo - v1 --- .../react/components/CSSGenerationDemo.tsx | 205 +++++++++++++++++ .../src/web/components/CSSGenerationDemo.tsx | 211 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 apps/demo/src/react/components/CSSGenerationDemo.tsx create mode 100644 apps/demo/src/web/components/CSSGenerationDemo.tsx diff --git a/apps/demo/src/react/components/CSSGenerationDemo.tsx b/apps/demo/src/react/components/CSSGenerationDemo.tsx new file mode 100644 index 00000000..27089640 --- /dev/null +++ b/apps/demo/src/react/components/CSSGenerationDemo.tsx @@ -0,0 +1,205 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { InteractConfig } from '@wix/interact/react'; +import { Interact, Interaction, generateCSS } from '@wix/interact/react'; + +const demoConfig: InteractConfig = { + interactions: [ + { + key: 'css-demo-card-1', + trigger: 'viewEnter', + params: { type: 'repeat', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'fade-slide-up', + keyframes: [ + { opacity: 0, transform: 'translateY(30px)' }, + { opacity: 1, transform: 'translateY(0)' } + ] + }, + duration: 600, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + initial: { + opacity: 0, + transform: 'translateY(30px)' + } + } + ] + }, + { + key: 'css-demo-card-2', + trigger: 'viewEnter', + params: { type: 'repeat', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'scale-fade-in', + keyframes: [ + { opacity: 0, transform: 'scale(0.9)' }, + { opacity: 1, transform: 'scale(1)' } + ] + }, + duration: 500, + delay: 150, + easing: 'ease-out', + initial: { + opacity: 0, + transform: 'scale(0.9)' + } + } + ] + }, + { + key: 'css-demo-card-3', + trigger: 'viewEnter', + params: { type: 'repeat', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'blur-reveal', + keyframes: [ + { opacity: 0, filter: 'blur(10px)', transform: 'translateX(-20px)' }, + { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' } + ] + }, + duration: 700, + delay: 300, + easing: 'ease-out', + initial: { + opacity: 0, + filter: 'blur(10px)', + transform: 'translateX(-20px)' + } + } + ] + } + ], + effects: {} +}; + +export const CSSGenerationDemo = () => { + const [useCSSAnimations, setUseCSSAnimations] = useState(true); + const [showCSS, setShowCSS] = useState(false); + + const generatedCSS = useMemo(() => generateCSS(demoConfig), []); + + // Inject or remove CSS based on toggle + useEffect(() => { + const styleId = 'css-generation-demo-styles'; + let styleEl = document.getElementById(styleId) as HTMLStyleElement | null; + + if (useCSSAnimations) { + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + document.head.appendChild(styleEl); + } + styleEl.textContent = generatedCSS; + } else { + styleEl?.remove(); + } + + return () => { + // Cleanup on unmount + document.getElementById(styleId)?.remove(); + }; + }, [useCSSAnimations, generatedCSS]); + + // Initialize Interact for trigger handling (both modes need this for viewEnter detection) + useEffect(() => { + const instance = Interact.create(demoConfig); + return () => instance.destroy(); + }, []); + + return ( +
+
+

CSS Generation Demo

+

+ Use generateCSS() to pre-render animation CSS for SSR or efficient CSR. + The animations run as pure CSS when enabled, reducing JavaScript overhead. +

+
+ +
+
+ + + {useCSSAnimations ? '🎨 CSS Mode' : '⚡ JS Mode'} + +
+ +
+ +
+
+ + {showCSS && ( +
+

Generated CSS Output

+
{generatedCSS || '/* No CSS generated */'}
+
+ )} + +
+ +

Card 1 - Fade Slide Up

+

Entrance Animation

+

+ This card uses initial to set opacity and transform + before the viewEnter animation triggers. +

+
+ + +

Card 2 - Scale Fade In

+

Delayed Start

+

+ A 150ms delay creates a staggered effect. The initial + property ensures the card starts scaled down and invisible. +

+
+ + +

Card 3 - Blur Reveal

+

Complex Initial State

+

+ Custom initial includes blur filter, opacity, and transform + for a sophisticated reveal effect. +

+
+
+ +
+

How it works

+
    +
  • + CSS Mode: generateCSS() outputs @keyframes and + animation rules. Animations run on the compositor thread for better performance. +
  • +
  • + JS Mode: Animations are controlled by JavaScript via the + Web Animations API. More flexible but requires hydration. +
  • +
  • + The initial property: Defines the pre-animation + state, rendered as a from keyframe to prevent content flash. +
  • +
+
+
+ ); +}; + diff --git a/apps/demo/src/web/components/CSSGenerationDemo.tsx b/apps/demo/src/web/components/CSSGenerationDemo.tsx new file mode 100644 index 00000000..c84a9f2e --- /dev/null +++ b/apps/demo/src/web/components/CSSGenerationDemo.tsx @@ -0,0 +1,211 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { InteractConfig } from '@wix/interact/web'; +import { Interact, generateCSS } from '@wix/interact/web'; + +const demoConfig: InteractConfig = { + interactions: [ + { + key: 'css-demo-card-1', + trigger: 'viewEnter', + params: { type: 'repeat', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'fade-slide-up', + keyframes: [ + { opacity: 0, transform: 'translateY(30px)' }, + { opacity: 1, transform: 'translateY(0)' } + ] + }, + duration: 600, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + initial: { + opacity: 0, + transform: 'translateY(30px)' + } + } + ] + }, + { + key: 'css-demo-card-2', + trigger: 'viewEnter', + params: { type: 'repeat', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'scale-fade-in', + keyframes: [ + { opacity: 0, transform: 'scale(0.9)' }, + { opacity: 1, transform: 'scale(1)' } + ] + }, + duration: 500, + delay: 150, + easing: 'ease-out', + initial: { + opacity: 0, + transform: 'scale(0.9)' + } + } + ] + }, + { + key: 'css-demo-card-3', + trigger: 'viewEnter', + params: { type: 'repeat', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'blur-reveal', + keyframes: [ + { opacity: 0, filter: 'blur(10px)', transform: 'translateX(-20px)' }, + { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' } + ] + }, + duration: 700, + delay: 300, + easing: 'ease-out', + initial: { + opacity: 0, + filter: 'blur(10px)', + transform: 'translateX(-20px)' + } + } + ] + } + ], + effects: {} +}; + +export const CSSGenerationDemo = () => { + const [useCSSAnimations, setUseCSSAnimations] = useState(true); + const [showCSS, setShowCSS] = useState(false); + + const generatedCSS = useMemo(() => generateCSS(demoConfig), []); + + // Inject or remove CSS based on toggle + useEffect(() => { + const styleId = 'css-generation-demo-styles'; + let styleEl = document.getElementById(styleId) as HTMLStyleElement | null; + + if (useCSSAnimations) { + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + document.head.appendChild(styleEl); + } + styleEl.textContent = generatedCSS; + } else { + styleEl?.remove(); + } + + return () => { + // Cleanup on unmount + document.getElementById(styleId)?.remove(); + }; + }, [useCSSAnimations, generatedCSS]); + + // Initialize Interact for trigger handling (both modes need this for viewEnter detection) + useEffect(() => { + const instance = Interact.create(demoConfig); + return () => instance.destroy(); + }, []); + + return ( +
+
+

CSS Generation Demo

+

+ Use generateCSS() to pre-render animation CSS for SSR or efficient CSR. + The animations run as pure CSS when enabled, reducing JavaScript overhead. +

+
+ +
+
+ + + {useCSSAnimations ? '🎨 CSS Mode' : '⚡ JS Mode'} + +
+ +
+ +
+
+ + {showCSS && ( +
+

Generated CSS Output

+
{generatedCSS || '/* No CSS generated */'}
+
+ )} + +
+ +
+

Card 1 - Fade Slide Up

+

Entrance Animation

+

+ This card uses initial to set opacity and transform + before the viewEnter animation triggers. +

+
+
+ + +
+

Card 2 - Scale Fade In

+

Delayed Start

+

+ A 150ms delay creates a staggered effect. The initial + property ensures the card starts scaled down and invisible. +

+
+
+ + +
+

Card 3 - Blur Reveal

+

Complex Initial State

+

+ Custom initial includes blur filter, opacity, and transform + for a sophisticated reveal effect. +

+
+
+
+ +
+

How it works

+
    +
  • + CSS Mode: generateCSS() outputs @keyframes and + animation rules. Animations run on the compositor thread for better performance. +
  • +
  • + JS Mode: Animations are controlled by JavaScript via the + Web Animations API. More flexible but requires hydration. +
  • +
  • + The initial property: Defines the pre-animation + state, rendered as a from keyframe to prevent content flash. +
  • +
+
+
+ ); +}; + From fe203335ee24a69bb9727570310d2161968a76b6 Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Tue, 20 Jan 2026 06:29:55 +0200 Subject: [PATCH 17/18] pr fixes --- .../react/components/CSSGenerationDemo.tsx | 165 ++++++------------ .../src/web/components/CSSGenerationDemo.tsx | 141 ++++----------- packages/interact/src/core/css.ts | 6 +- packages/interact/src/core/utilities.ts | 7 +- 4 files changed, 100 insertions(+), 219 deletions(-) diff --git a/apps/demo/src/react/components/CSSGenerationDemo.tsx b/apps/demo/src/react/components/CSSGenerationDemo.tsx index 27089640..f6daf38e 100644 --- a/apps/demo/src/react/components/CSSGenerationDemo.tsx +++ b/apps/demo/src/react/components/CSSGenerationDemo.tsx @@ -1,35 +1,32 @@ -import { useEffect, useMemo, useState } from 'react'; -import type { InteractConfig } from '@wix/interact/react'; -import { Interact, Interaction, generateCSS } from '@wix/interact/react'; +import { useState } from 'react'; +import type { InteractConfig } from '@wix/interact/web'; +import { Interact, generateCSS } from '@wix/interact/web'; const demoConfig: InteractConfig = { interactions: [ { key: 'css-demo-card-1', trigger: 'viewEnter', - params: { type: 'repeat', threshold: 0.5 }, + params: { type: 'once', threshold: 0.5 }, effects: [ { keyframeEffect: { name: 'fade-slide-up', keyframes: [ - { opacity: 0, transform: 'translateY(30px)' }, - { opacity: 1, transform: 'translateY(0)' } + { opacity: 0, transform: 'translateX(-50vw)' }, + { opacity: 1, transform: 'translateX(0)' } ] }, duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', - initial: { - opacity: 0, - transform: 'translateY(30px)' - } + fill: 'backwards', } ] }, { key: 'css-demo-card-2', trigger: 'viewEnter', - params: { type: 'repeat', threshold: 0.5 }, + params: { type: 'once', threshold: 0.5 }, effects: [ { keyframeEffect: { @@ -42,9 +39,9 @@ const demoConfig: InteractConfig = { duration: 500, delay: 150, easing: 'ease-out', + fill: 'backwards', initial: { - opacity: 0, - transform: 'scale(0.9)' + opacity: '0' } } ] @@ -52,24 +49,21 @@ const demoConfig: InteractConfig = { { key: 'css-demo-card-3', trigger: 'viewEnter', - params: { type: 'repeat', threshold: 0.5 }, + params: { type: 'once', threshold: 0.5 }, effects: [ { keyframeEffect: { name: 'blur-reveal', keyframes: [ - { opacity: 0, filter: 'blur(10px)', transform: 'translateX(-20px)' }, + { opacity: 0.5, filter: 'blur(10px)', transform: 'translateX(-20px)' }, { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' } ] }, duration: 700, delay: 300, easing: 'ease-out', - initial: { - opacity: 0, - filter: 'blur(10px)', - transform: 'translateX(-20px)' - } + fill: 'backwards', + initial: false } ] } @@ -78,41 +72,14 @@ const demoConfig: InteractConfig = { }; export const CSSGenerationDemo = () => { - const [useCSSAnimations, setUseCSSAnimations] = useState(true); - const [showCSS, setShowCSS] = useState(false); - - const generatedCSS = useMemo(() => generateCSS(demoConfig), []); + const [showCSS, setShowCSS] = useState(true); - // Inject or remove CSS based on toggle - useEffect(() => { - const styleId = 'css-generation-demo-styles'; - let styleEl = document.getElementById(styleId) as HTMLStyleElement | null; - - if (useCSSAnimations) { - if (!styleEl) { - styleEl = document.createElement('style'); - styleEl.id = styleId; - document.head.appendChild(styleEl); - } - styleEl.textContent = generatedCSS; - } else { - styleEl?.remove(); - } - - return () => { - // Cleanup on unmount - document.getElementById(styleId)?.remove(); - }; - }, [useCSSAnimations, generatedCSS]); - - // Initialize Interact for trigger handling (both modes need this for viewEnter detection) - useEffect(() => { - const instance = Interact.create(demoConfig); - return () => instance.destroy(); - }, []); + const generatedCSS = generateCSS(demoConfig); + Interact.create(demoConfig); return (
+

CSS Generation Demo

@@ -121,21 +88,42 @@ export const CSSGenerationDemo = () => {

-
-
- - - {useCSSAnimations ? '🎨 CSS Mode' : '⚡ JS Mode'} - -
+
+ +
+

Card 1 - Fade Slide Up

+

Entrance Animation

+

+ This card uses the default initial to set visibility to hidden + and transform to none before the viewEnter animation triggers to enable intersection. +

+
+
+ + +
+

Card 2 - Scale Fade In

+

Delayed Start

+

+ Custom initial on this card setting the opacity + property ensures the card starts transparent and invisible. +

+
+
+ + +
+

Card 3 - Blur Reveal

+

Complex Initial State

+

+ When initial is set to false, the first frame of the animation + is set and might affect intersection. +

+
+
+
+
)} - -
- -

Card 1 - Fade Slide Up

-

Entrance Animation

-

- This card uses initial to set opacity and transform - before the viewEnter animation triggers. -

-
- - -

Card 2 - Scale Fade In

-

Delayed Start

-

- A 150ms delay creates a staggered effect. The initial - property ensures the card starts scaled down and invisible. -

-
- - -

Card 3 - Blur Reveal

-

Complex Initial State

-

- Custom initial includes blur filter, opacity, and transform - for a sophisticated reveal effect. -

-
-
- -
-

How it works

-
    -
  • - CSS Mode: generateCSS() outputs @keyframes and - animation rules. Animations run on the compositor thread for better performance. -
  • -
  • - JS Mode: Animations are controlled by JavaScript via the - Web Animations API. More flexible but requires hydration. -
  • -
  • - The initial property: Defines the pre-animation - state, rendered as a from keyframe to prevent content flash. -
  • -
-
); }; diff --git a/apps/demo/src/web/components/CSSGenerationDemo.tsx b/apps/demo/src/web/components/CSSGenerationDemo.tsx index c84a9f2e..f6daf38e 100644 --- a/apps/demo/src/web/components/CSSGenerationDemo.tsx +++ b/apps/demo/src/web/components/CSSGenerationDemo.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useState } from 'react'; import type { InteractConfig } from '@wix/interact/web'; import { Interact, generateCSS } from '@wix/interact/web'; @@ -7,29 +7,26 @@ const demoConfig: InteractConfig = { { key: 'css-demo-card-1', trigger: 'viewEnter', - params: { type: 'repeat', threshold: 0.5 }, + params: { type: 'once', threshold: 0.5 }, effects: [ { keyframeEffect: { name: 'fade-slide-up', keyframes: [ - { opacity: 0, transform: 'translateY(30px)' }, - { opacity: 1, transform: 'translateY(0)' } + { opacity: 0, transform: 'translateX(-50vw)' }, + { opacity: 1, transform: 'translateX(0)' } ] }, duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', - initial: { - opacity: 0, - transform: 'translateY(30px)' - } + fill: 'backwards', } ] }, { key: 'css-demo-card-2', trigger: 'viewEnter', - params: { type: 'repeat', threshold: 0.5 }, + params: { type: 'once', threshold: 0.5 }, effects: [ { keyframeEffect: { @@ -42,9 +39,9 @@ const demoConfig: InteractConfig = { duration: 500, delay: 150, easing: 'ease-out', + fill: 'backwards', initial: { - opacity: 0, - transform: 'scale(0.9)' + opacity: '0' } } ] @@ -52,24 +49,21 @@ const demoConfig: InteractConfig = { { key: 'css-demo-card-3', trigger: 'viewEnter', - params: { type: 'repeat', threshold: 0.5 }, + params: { type: 'once', threshold: 0.5 }, effects: [ { keyframeEffect: { name: 'blur-reveal', keyframes: [ - { opacity: 0, filter: 'blur(10px)', transform: 'translateX(-20px)' }, + { opacity: 0.5, filter: 'blur(10px)', transform: 'translateX(-20px)' }, { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' } ] }, duration: 700, delay: 300, easing: 'ease-out', - initial: { - opacity: 0, - filter: 'blur(10px)', - transform: 'translateX(-20px)' - } + fill: 'backwards', + initial: false } ] } @@ -78,41 +72,14 @@ const demoConfig: InteractConfig = { }; export const CSSGenerationDemo = () => { - const [useCSSAnimations, setUseCSSAnimations] = useState(true); - const [showCSS, setShowCSS] = useState(false); - - const generatedCSS = useMemo(() => generateCSS(demoConfig), []); - - // Inject or remove CSS based on toggle - useEffect(() => { - const styleId = 'css-generation-demo-styles'; - let styleEl = document.getElementById(styleId) as HTMLStyleElement | null; + const [showCSS, setShowCSS] = useState(true); - if (useCSSAnimations) { - if (!styleEl) { - styleEl = document.createElement('style'); - styleEl.id = styleId; - document.head.appendChild(styleEl); - } - styleEl.textContent = generatedCSS; - } else { - styleEl?.remove(); - } - - return () => { - // Cleanup on unmount - document.getElementById(styleId)?.remove(); - }; - }, [useCSSAnimations, generatedCSS]); - - // Initialize Interact for trigger handling (both modes need this for viewEnter detection) - useEffect(() => { - const instance = Interact.create(demoConfig); - return () => instance.destroy(); - }, []); + const generatedCSS = generateCSS(demoConfig); + Interact.create(demoConfig); return (
+

CSS Generation Demo

@@ -121,46 +88,14 @@ export const CSSGenerationDemo = () => {

-
-
- - - {useCSSAnimations ? '🎨 CSS Mode' : '⚡ JS Mode'} - -
- -
- -
-
- - {showCSS && ( -
-

Generated CSS Output

-
{generatedCSS || '/* No CSS generated */'}
-
- )} -

Card 1 - Fade Slide Up

Entrance Animation

- This card uses initial to set opacity and transform - before the viewEnter animation triggers. + This card uses the default initial to set visibility to hidden + and transform to none before the viewEnter animation triggers to enable intersection.

@@ -170,8 +105,8 @@ export const CSSGenerationDemo = () => {

Card 2 - Scale Fade In

Delayed Start

- A 150ms delay creates a staggered effect. The initial - property ensures the card starts scaled down and invisible. + Custom initial on this card setting the opacity + property ensures the card starts transparent and invisible.

@@ -181,30 +116,30 @@ export const CSSGenerationDemo = () => {

Card 3 - Blur Reveal

Complex Initial State

- Custom initial includes blur filter, opacity, and transform - for a sophisticated reveal effect. + When initial is set to false, the first frame of the animation + is set and might affect intersection.

-
-

How it works

-
    -
  • - CSS Mode: generateCSS() outputs @keyframes and - animation rules. Animations run on the compositor thread for better performance. -
  • -
  • - JS Mode: Animations are controlled by JavaScript via the - Web Animations API. More flexible but requires hydration. -
  • -
  • - The initial property: Defines the pre-animation - state, rendered as a from keyframe to prevent content flash. -
  • -
+
+
+ +
+ + {showCSS && ( +
+

Generated CSS Output

+
{generatedCSS || '/* No CSS generated */'}
+
+ )} ); }; diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 3dfe2aa4..dd954857 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -95,7 +95,7 @@ function resolveEffect( keyframeEffect.name = canUseEffectId ? effectRef.effectId : generateId(); } - fullEffect.initial = fullEffect.initial === false ? + fullEffect.initial = fullEffect.initial === false || interaction.trigger !== 'viewEnter' ? undefined : (fullEffect.initial || DEFAULT_INITIAL); return fullEffect; @@ -121,7 +121,7 @@ function buildConditionalRule( const targetSelector = selectorCondition ? applySelectorCondition(selector, selectorCondition) : selector; - let rule = `${targetSelector} { ${declarations.join(' ')} }`; + let rule = `${targetSelector} {\n${declarations.join('\n')}\n}`; ['container' as const, 'media' as const].forEach((type) => { const predicate = getFullPredicateByType(conditions, configConditions, type); @@ -156,7 +156,7 @@ function buildUnconditionalRuleFromCustomProps( const customProps = customPropNames.map((propName) => `var(${propName}, ${fallback})`); declarations.push(`${declarationPropName}: ${customProps.join(', ')};`); - return `${selector} { ${declarations.join(' ')} }`; + return `${selector} {\n${declarations.join('\n')}\n}`; } function generateTransitions( diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index d467f081..60751bad 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -19,6 +19,7 @@ export function getInterpolatedKey(template: string, key: string) { function interpolateKeyframesOffsets( keyframes: Keyframe[], + firstFrameOnEpsilon?: boolean ): Keyframe[] { if (!keyframes || keyframes.length === 0) return []; @@ -57,6 +58,10 @@ function interpolateKeyframesOffsets( } } + if (firstFrameOnEpsilon) { + result[0].offset = 0.0001; + } + return result; } @@ -89,7 +94,7 @@ function keyframeObjectToKeyframeCSS(keyframeObj: Keyframe, offsetString: string export function keyframesToCSS(name: string, keyframes: Keyframe[], initial?: any): string { if (!keyframes || keyframes.length === 0) return ''; - const interpolated = interpolateKeyframesOffsets(keyframes); + const interpolated = interpolateKeyframesOffsets(keyframes, !!initial); let keyframeBlocks = interpolated .map((kf) => { From e51a7bc7ae7a417f51090fa6c873131259d4518b Mon Sep 17 00:00:00 2001 From: ameerabuf Date: Wed, 21 Jan 2026 02:31:36 +0200 Subject: [PATCH 18/18] yarn format --- .../react/components/CSSGenerationDemo.tsx | 56 +++--- .../src/web/components/CSSGenerationDemo.tsx | 56 +++--- packages/interact/docs/api/functions.md | 169 ++++++++++-------- .../docs/examples/entrance-animations.md | 12 +- .../docs/guides/effects-and-animations.md | 67 ++++--- packages/interact/docs/integration/react.md | 95 +++++----- packages/interact/rules/viewenter.md | 125 +++++++------ packages/interact/src/core/css.ts | 121 ++++++------- packages/interact/src/core/utilities.ts | 28 +-- packages/interact/src/types.ts | 20 +-- packages/interact/src/utils.ts | 10 +- packages/interact/test/css.spec.ts | 25 +-- 12 files changed, 405 insertions(+), 379 deletions(-) diff --git a/apps/demo/src/react/components/CSSGenerationDemo.tsx b/apps/demo/src/react/components/CSSGenerationDemo.tsx index f6daf38e..09fe63e6 100644 --- a/apps/demo/src/react/components/CSSGenerationDemo.tsx +++ b/apps/demo/src/react/components/CSSGenerationDemo.tsx @@ -14,14 +14,14 @@ const demoConfig: InteractConfig = { name: 'fade-slide-up', keyframes: [ { opacity: 0, transform: 'translateX(-50vw)' }, - { opacity: 1, transform: 'translateX(0)' } - ] + { opacity: 1, transform: 'translateX(0)' }, + ], }, duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'backwards', - } - ] + }, + ], }, { key: 'css-demo-card-2', @@ -33,18 +33,18 @@ const demoConfig: InteractConfig = { name: 'scale-fade-in', keyframes: [ { opacity: 0, transform: 'scale(0.9)' }, - { opacity: 1, transform: 'scale(1)' } - ] + { opacity: 1, transform: 'scale(1)' }, + ], }, duration: 500, delay: 150, easing: 'ease-out', fill: 'backwards', initial: { - opacity: '0' - } - } - ] + opacity: '0', + }, + }, + ], }, { key: 'css-demo-card-3', @@ -56,19 +56,19 @@ const demoConfig: InteractConfig = { name: 'blur-reveal', keyframes: [ { opacity: 0.5, filter: 'blur(10px)', transform: 'translateX(-20px)' }, - { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' } - ] + { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' }, + ], }, duration: 700, delay: 300, easing: 'ease-out', fill: 'backwards', - initial: false - } - ] - } + initial: false, + }, + ], + }, ], - effects: {} + effects: {}, }; export const CSSGenerationDemo = () => { @@ -83,8 +83,8 @@ export const CSSGenerationDemo = () => {

CSS Generation Demo

- Use generateCSS() to pre-render animation CSS for SSR or efficient CSR. - The animations run as pure CSS when enabled, reducing JavaScript overhead. + Use generateCSS() to pre-render animation CSS for SSR or efficient CSR. The + animations run as pure CSS when enabled, reducing JavaScript overhead.

@@ -94,8 +94,8 @@ export const CSSGenerationDemo = () => {

Card 1 - Fade Slide Up

Entrance Animation

- This card uses the default initial to set visibility to hidden - and transform to none before the viewEnter animation triggers to enable intersection. + This card uses the default initial to set visibility to hidden and + transform to none before the viewEnter animation triggers to enable intersection.

@@ -105,8 +105,8 @@ export const CSSGenerationDemo = () => {

Card 2 - Scale Fade In

Delayed Start

- Custom initial on this card setting the opacity - property ensures the card starts transparent and invisible. + Custom initial on this card setting the opacity property ensures the card + starts transparent and invisible.

@@ -116,8 +116,8 @@ export const CSSGenerationDemo = () => {

Card 3 - Blur Reveal

Complex Initial State

- When initial is set to false, the first frame of the animation - is set and might affect intersection. + When initial is set to false, the first frame of the animation is set and + might affect intersection.

@@ -125,10 +125,7 @@ export const CSSGenerationDemo = () => {
-
@@ -143,4 +140,3 @@ export const CSSGenerationDemo = () => { ); }; - diff --git a/apps/demo/src/web/components/CSSGenerationDemo.tsx b/apps/demo/src/web/components/CSSGenerationDemo.tsx index f6daf38e..09fe63e6 100644 --- a/apps/demo/src/web/components/CSSGenerationDemo.tsx +++ b/apps/demo/src/web/components/CSSGenerationDemo.tsx @@ -14,14 +14,14 @@ const demoConfig: InteractConfig = { name: 'fade-slide-up', keyframes: [ { opacity: 0, transform: 'translateX(-50vw)' }, - { opacity: 1, transform: 'translateX(0)' } - ] + { opacity: 1, transform: 'translateX(0)' }, + ], }, duration: 600, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'backwards', - } - ] + }, + ], }, { key: 'css-demo-card-2', @@ -33,18 +33,18 @@ const demoConfig: InteractConfig = { name: 'scale-fade-in', keyframes: [ { opacity: 0, transform: 'scale(0.9)' }, - { opacity: 1, transform: 'scale(1)' } - ] + { opacity: 1, transform: 'scale(1)' }, + ], }, duration: 500, delay: 150, easing: 'ease-out', fill: 'backwards', initial: { - opacity: '0' - } - } - ] + opacity: '0', + }, + }, + ], }, { key: 'css-demo-card-3', @@ -56,19 +56,19 @@ const demoConfig: InteractConfig = { name: 'blur-reveal', keyframes: [ { opacity: 0.5, filter: 'blur(10px)', transform: 'translateX(-20px)' }, - { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' } - ] + { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' }, + ], }, duration: 700, delay: 300, easing: 'ease-out', fill: 'backwards', - initial: false - } - ] - } + initial: false, + }, + ], + }, ], - effects: {} + effects: {}, }; export const CSSGenerationDemo = () => { @@ -83,8 +83,8 @@ export const CSSGenerationDemo = () => {

CSS Generation Demo

- Use generateCSS() to pre-render animation CSS for SSR or efficient CSR. - The animations run as pure CSS when enabled, reducing JavaScript overhead. + Use generateCSS() to pre-render animation CSS for SSR or efficient CSR. The + animations run as pure CSS when enabled, reducing JavaScript overhead.

@@ -94,8 +94,8 @@ export const CSSGenerationDemo = () => {

Card 1 - Fade Slide Up

Entrance Animation

- This card uses the default initial to set visibility to hidden - and transform to none before the viewEnter animation triggers to enable intersection. + This card uses the default initial to set visibility to hidden and + transform to none before the viewEnter animation triggers to enable intersection.

@@ -105,8 +105,8 @@ export const CSSGenerationDemo = () => {

Card 2 - Scale Fade In

Delayed Start

- Custom initial on this card setting the opacity - property ensures the card starts transparent and invisible. + Custom initial on this card setting the opacity property ensures the card + starts transparent and invisible.

@@ -116,8 +116,8 @@ export const CSSGenerationDemo = () => {

Card 3 - Blur Reveal

Complex Initial State

- When initial is set to false, the first frame of the animation - is set and might affect intersection. + When initial is set to false, the first frame of the animation is set and + might affect intersection.

@@ -125,10 +125,7 @@ export const CSSGenerationDemo = () => {
-
@@ -143,4 +140,3 @@ export const CSSGenerationDemo = () => { ); }; - diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index ec0c8991..782f5d4d 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -335,6 +335,7 @@ For the generated CSS to work, the `` must have the `data-inte Generates complete CSS for time-based animations from an `InteractConfig`. This function is designed for **Server-Side Rendering (SSR)** or **efficient Client-Side Rendering (CSR)** by pre-generating CSS animations that can be rendered in a `