From 08542ac0bd191f2d38e0e0f36aa70d374c201f26 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sat, 5 Sep 2026 22:57:24 +0300 Subject: [PATCH 1/8] refactor at-property --- packages/interact/src/core/css.ts | 471 +++++++++++------------- packages/interact/src/core/cssUtils.ts | 120 ++++-- packages/interact/src/core/resolvers.ts | 6 +- packages/interact/src/core/utilities.ts | 11 - packages/interact/src/types/css.ts | 11 - packages/interact/src/utils.ts | 4 - 6 files changed, 307 insertions(+), 316 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 0ce1bb72..54c4a376 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -5,15 +5,12 @@ import type { ResolvedSequence, Condition, ListPropertyName, - ListCustomProps, - CSSCoordinatedLists, CSSRuleData, InteractPluginStyles, GenerateOptions, } from '../types'; import { PLUGIN_FIELD_PREFIX } from '../types'; import { - kebabCustomProp, camelToKebabCase, getStateStyleProperties, transitionEffectToTransitionsList, @@ -22,8 +19,18 @@ import { } from '../utils'; import { getSelector } from './Interact'; import { resolveEffectForCSS, resolveSequenceForCSS } from './resolvers'; -import { getElementHash, getUniqueEncodedHash } from './utilities'; -import { keyframesToCSS, CSSRuleToString, buildListsRule } from './cssUtils'; +import { getElementHash } from './utilities'; +import { + LIST_ANIMATION_PROPERTY_NAMES, + LIST_PROPERTY_NAMES, + LIST_PROPERTY_FALLBACKS, + keyframesToCSS, + CSSRuleToString, + buildListsRule, + buildAtPropertyRules, + getCustomPropName, + buildSequenceListsRule, +} from './cssUtils'; import { effectToAnimationOptions } from '../handlers/utilities'; import { getCSSAnimation, MotionKeyframeEffect, TriggerVariant } from '@wix/motion'; @@ -35,17 +42,8 @@ export const DEFAULT_INITIAL = [ { name: 'rotate', value: 'none', important: true }, ]; -const LIST_ANIMATION_PROPERTY_NAMES = [ - 'animation', - 'animation-composition', - 'animation-timeline', - 'animation-range', -] as const satisfies readonly ListPropertyName[]; - type AnimationPropertyName = (typeof LIST_ANIMATION_PROPERTY_NAMES)[number]; -const LIST_PROPERTY_NAMES: ListPropertyName[] = ['transition', ...LIST_ANIMATION_PROPERTY_NAMES]; - const LIST_PROPERTY_NAMES_MOTION: Record = { animation: 'animation', 'animation-composition': 'composition', @@ -53,93 +51,6 @@ const LIST_PROPERTY_NAMES_MOTION: Record = { 'animation-range': 'animationRange', }; -const LIST_PROPERTY_FALLBACKS: Record = { - animation: 'none', - 'animation-composition': 'replace', - transition: '_', - 'animation-timeline': 'auto', - 'animation-range': 'normal', -}; - -// ----- Map Updaters ----- - -function accumulateUsedProperties( - map: Map>, - targetHash: string, - props: ListPropertyName[], -) { - const existing = map.get(targetHash); - if (existing) { - props.forEach((p) => existing.add(p)); - } else { - map.set(targetHash, new Set(props)); - } -} - -function pushToTargetCustomPropsLists( - targetToLists: Map, - targetHash: string, - customProps: ListCustomProps, - usedProperties?: Set, -): void { - const { key, childSelector } = customProps; - const propertyNames = usedProperties - ? LIST_PROPERTY_NAMES.filter((n) => usedProperties.has(n)) - : LIST_PROPERTY_NAMES; - - if (!targetToLists.has(targetHash)) { - targetToLists.set(targetHash, { key, childSelector, properties: {} }); - } - const { properties } = targetToLists.get(targetHash)!; - for (const name of propertyNames) { - if (!properties[name]) { - properties[name] = { fallback: LIST_PROPERTY_FALLBACKS[name], varNames: [] }; - } - properties[name]!.varNames.push(customProps[name]); - } -} - -function buildCustomProps( - indices: (string | number)[], - encodedHash: string, -): Record { - return LIST_PROPERTY_NAMES.reduce( - (acc, name) => { - acc[name] = kebabCustomProp([name, ...indices, encodedHash]); - return acc; - }, - {} as Record, - ); -} - -function getInteractionCustomPropsForTarget( - targetHash: string, - key: string, - interactionIdx: number, - targetToCustomProps: Map, - childSelector?: string, -): ListCustomProps { - if (!targetToCustomProps.has(targetHash)) { - targetToCustomProps.set(targetHash, { - key, - childSelector, - ...buildCustomProps([interactionIdx], getUniqueEncodedHash(targetHash)), - }); - } - - return targetToCustomProps.get(targetHash)!; -} - -function generateSequenceCustomProps( - targetHash: string, - interactionIdx: number, - index: number, -): Record { - return buildCustomProps([interactionIdx, index], getUniqueEncodedHash(targetHash)); -} - -// ----- Parsers ----- - function triggerToCSS( interaction: Interaction, configConditions: Record, @@ -162,8 +73,6 @@ function triggerToCSS( media, selectorCondition, childSelector, - // invalidating earlier cascaded custom properties affected from earlier transitionEffects - // to implement same-interaction-cascade declarations: [ { name: 'view-timeline', @@ -173,12 +82,6 @@ function triggerToCSS( }; } -/** - * Collects build-time plugin styles for one effect config object. For every - * `$`-prefixed field with a matching generator in `plugins`, calls the generator with the raw - * value and a context scoped to the element, and return CSS rule(s) data. Interact - * never inspects the field value — it only routes it to the plugin (same contract as `create()`). - */ function collectFieldPluginStyles( scope: 'interaction' | 'effect', source: Record, @@ -207,15 +110,14 @@ function collectFieldPluginStyles( function effectToCSS( effect: ResolvedEffect, configConditions: Record, - customProps: ListCustomProps, trigger: TriggerVariant, + customProps: Record, childSelector?: string, plugins?: InteractPluginStyles, sequence?: ResolvedSequence, ): { rules: CSSRuleData[]; keyframes: MotionKeyframeEffect[]; - usedProperties: ListPropertyName[]; } { const { key, @@ -244,15 +146,11 @@ function effectToCSS( const { declarations } = rules[0]; - let usedProperties: ListPropertyName[] = []; - if (plugins) { rules.push(...collectFieldPluginStyles('effect', effect, key, media, plugins)); } if (namedEffect || keyframeEffect) { - usedProperties = [...LIST_ANIMATION_PROPERTY_NAMES]; - const animationOptions = effectToAnimationOptions(effect); const cssAnimations = getCSSAnimation(null, animationOptions, trigger, sequence).filter( (anim) => anim.name, @@ -307,8 +205,6 @@ function effectToCSS( declarations.push(...animationDeclarations); } } else if (transition || transitionProperties) { - usedProperties = ['transition']; - const properties = getStateStyleProperties(effect); const transitions = transitionEffectToTransitionsList(effect); @@ -337,137 +233,171 @@ function effectToCSS( ); } - return { rules: rules.filter((r) => r.declarations.length), keyframes, usedProperties }; + return { rules: rules.filter((r) => r.declarations.length), keyframes }; } function parseEffect( - configConditions: Record, - interactionIdx: number, effect: ResolvedEffect, - targetToCustomProps: Map, - keyframesMap: Map, + configConditions: Record, trigger: TriggerVariant, + targetsMap: Map, + visited: Set, + keyframesMap: Map, useFirstChild: boolean = true, plugins?: InteractPluginStyles, - sequenceCustomProps?: Record, - precomputedTargetHash?: string, sequence?: ResolvedSequence, -): { rules: CSSRuleData[]; usedProperties: ListPropertyName[] } { +): CSSRuleData[] { const { key } = effect; - const targetHash = precomputedTargetHash ?? getElementHash(effect); + const targetHash = getElementHash(effect); const childSelector = getSelector(effect, { asCombinator: true, useFirstChild, addItemFilter: true, }); - // get existing custom-property names for coordinated-list for this target and interaction - // or generate them if it is first time this interaction uses this target - const customProps = getInteractionCustomPropsForTarget( - targetHash, + const current = targetsMap.get(targetHash) || { key, - interactionIdx, - targetToCustomProps, childSelector, - ); + animationIndex: 0, + transitionIndex: 0, + usedAnimationSlots: 0, + usedTransitionSlots: 0, + reservedAnimationSlots: 0, + reservedTransitionSlots: 0, + sequenceAnimationIndex: 0, + sequenceTransitionIndex: 0, + hasAnimation: false, + hasTransition: false, + }; + visited.add(targetHash); - // in case effect is part of a sequence, we use different custom-proprties names to not override - // the entire interaction, instead we generate unique-per-effect name to allow effects to live together - const localCustomProps = { ...customProps }; - if (sequenceCustomProps) { - Object.assign(localCustomProps, sequenceCustomProps); - } + const customProps = LIST_PROPERTY_NAMES.reduce( + (acc, name) => { + acc[name] = sequence + ? getCustomPropName( + name, + name === 'transition' + ? current.usedTransitionSlots + current.sequenceTransitionIndex + : current.usedAnimationSlots + current.sequenceAnimationIndex, + true + ) : getCustomPropName( + name, + name === 'transition' + ? current.transitionIndex + : current.animationIndex + ); + return acc; + }, + {} as Record + ); - // process effect into css-rules and keyframes - const { rules, keyframes, usedProperties } = effectToCSS( + const { rules, keyframes } = effectToCSS( effect, configConditions, - localCustomProps, trigger, + customProps, childSelector, plugins, sequence, ); - // update keyframes map keyframes.forEach(({ name, keyframes }) => keyframesMap.set(name, keyframes)); - return { rules, usedProperties }; + const hasAnimation = Boolean(effect.namedEffect || effect.keyframeEffect); + const hasTransition = Boolean(effect.transition || effect.transitionProperties); + current.hasAnimation ||= hasAnimation; + current.hasTransition ||= hasTransition; + if (sequence) { + current.sequenceAnimationIndex += hasAnimation ? 1 : 0; + current.sequenceTransitionIndex += hasTransition ? 1 : 0; + } + targetsMap.set(targetHash, current); + + return rules; } function parseSequence( - configConditions: Record, - interactionIdx: number, sequence: ResolvedSequence, - targetToCustomProps: Map, - keyframesMap: Map, + configConditions: Record, trigger: TriggerVariant, + targetsMap: Map, + visited: Set, + keyframesMap: Map, useFirstChild: boolean = true, - targetUsedProperties?: Map>, plugins?: InteractPluginStyles, ): CSSRuleData[] { - // in a similar manner to how we treat different interactions and use lists to concatenate them - // instead of overriding, we use the same mechanism to allow all of the effects of a sequence to - // exist together on the same target - - // targetHash to lists of custom-properties for each coordinated-list type property - // to be populated when parsing effects - const targetToSequenceLists = new Map(); - const targetSequenceIndex = new Map(); - const cssRules: CSSRuleData[] = []; - for (const effect of sequence.effects) { - const targetHash = getElementHash(effect); - const { key } = effect; - const childSelector = getSelector(effect, { - asCombinator: true, - useFirstChild, - addItemFilter: true, - }); - - const index = targetSequenceIndex.get(targetHash) || 0; - targetSequenceIndex.set(targetHash, index + 1); - - const seqCustomProps = generateSequenceCustomProps(targetHash, interactionIdx, index); + const localVisited = new Set(); - const { rules, usedProperties } = parseEffect( - configConditions, - interactionIdx, + for (const effect of sequence.effects) { + const rules = parseEffect( effect, - targetToCustomProps, - keyframesMap, + configConditions, trigger, + targetsMap, + localVisited, + keyframesMap, useFirstChild, plugins, - seqCustomProps, - targetHash, sequence, ); cssRules.push(...rules); + } - const usedSet = new Set(usedProperties); + const { conditions } = sequence; - pushToTargetCustomPropsLists( - targetToSequenceLists, - targetHash, - { key, childSelector, ...seqCustomProps }, - usedSet, + localVisited.forEach((targetHash) => { + visited.add(targetHash); + const current = targetsMap.get(targetHash)!; + + current.reservedAnimationSlots = Math.max(current.reservedAnimationSlots, current.sequenceAnimationIndex); + current.reservedTransitionSlots = Math.max(current.reservedTransitionSlots, current.sequenceTransitionIndex); + const rule = buildSequenceListsRule( + current.sequenceAnimationIndex, + current.sequenceTransitionIndex, + current.animationIndex, + current.transitionIndex, + current.usedAnimationSlots, + current.usedTransitionSlots, + current.key, + current.childSelector, + conditions, + configConditions, ); - - if (targetUsedProperties) { - accumulateUsedProperties(targetUsedProperties, targetHash, usedProperties); + if (rule) { + cssRules.push(rule); } - } - - const { conditions } = sequence; - targetToSequenceLists.forEach((lists, targetHash) => { - const customProps = targetToCustomProps.get(targetHash)!; + current.sequenceAnimationIndex = 0; + current.sequenceTransitionIndex = 0; - // for each target add rule with sequence-conditions for the coordinated lists from interactions targeting it - // here we use the interaction's custom-properties to set the lists as values for them instead of - // directly into the actual coordinated-list type property, to provide cascading in the array of sequences - cssRules.push(buildListsRule(lists, customProps, conditions, configConditions)); }); return cssRules; @@ -477,7 +407,20 @@ function parseInteraction( config: InteractConfig, interaction: Interaction, interactionIdx: number, - targetToLists: Map, + targetsMap: Map, keyframesMap: Map, useFirstChild: boolean = true, plugins?: InteractPluginStyles, @@ -485,22 +428,6 @@ function parseInteraction( const { key, conditions, effects = [], sequences = [] } = interaction; const configConditions = config.conditions || {}; - // targetHash to custom-property per each coordinated-list type property for current interaction - // to be populated when parsing the effects (since it is per target). - // Each interaction uses a single custom-property for each coordinated-list type property, - // to provide cascading in the array of effects - e.g. effects in the interaction array with exact same target - // will populate the same per-interaction custom-property (e.g. `--animation-${interactionIdx}-${targetUniqueSuffix}`) - // and the last one will be applied. - const targetToCustomProps = new Map(); - - const targetUsedProperties = new Map>(); - - const resolvedEffects = effects - .map((effect, effIndex) => - resolveEffectForCSS(effect, interaction, config, `eff-${interactionIdx}-${effIndex}`), - ) - .filter((effect) => effect !== null); - const cssRules = plugins ? collectFieldPluginStyles( 'interaction', @@ -521,22 +448,24 @@ function parseInteraction( cssRules.push(triggerToCSS(interaction, configConditions, motionTrigger.id, useFirstChild)); } - for (const effect of resolvedEffects) { - const targetHash = getElementHash(effect); - const { rules, usedProperties } = parseEffect( - configConditions, - interactionIdx, - effect, - targetToCustomProps, - keyframesMap, - motionTrigger, - useFirstChild, - plugins, - ); - cssRules.push(...rules); + const visited = new Set(); - accumulateUsedProperties(targetUsedProperties, targetHash, usedProperties); - } + const resolvedEffects = effects + .map((effect, effIndex) => + resolveEffectForCSS(effect, interaction, config, `eff-${interactionIdx}-${effIndex}`), + ) + .filter((effect) => effect !== null); + + cssRules.push(...resolvedEffects.flatMap((effect) => parseEffect( + effect, + configConditions, + motionTrigger, + targetsMap, + visited, + keyframesMap, + useFirstChild, + plugins, + ))); const resolvedSequences = sequences .map((sequence, seqIndex) => @@ -544,31 +473,27 @@ function parseInteraction( ) .filter((sequence) => sequence !== null); - cssRules.push( - ...resolvedSequences.flatMap((sequence) => - parseSequence( - configConditions, - interactionIdx, - sequence, - targetToCustomProps, - keyframesMap, - motionTrigger, - useFirstChild, - targetUsedProperties, - plugins, - ), - ), - ); - - // after processing all of the effects, we add to the lists of custom-properties per target - // the new interaction's custom-property names - targetToCustomProps.forEach((customProps, targetHash) => { - pushToTargetCustomPropsLists( - targetToLists, - targetHash, - customProps, - targetUsedProperties.get(targetHash), - ); + cssRules.push(...resolvedSequences.flatMap((sequence) => parseSequence( + sequence, + configConditions, + motionTrigger, + targetsMap, + visited, + keyframesMap, + useFirstChild, + plugins, + ))); + + visited.forEach((targetHash) => { + const current = targetsMap.get(targetHash)!; + current.animationIndex += current.hasAnimation ? 1 : 0; + current.transitionIndex += current.hasTransition ? 1 : 0; + current.usedAnimationSlots += current.reservedAnimationSlots; + current.usedTransitionSlots += current.reservedTransitionSlots; + current.reservedAnimationSlots = 0; + current.reservedTransitionSlots = 0; + current.hasAnimation = false; + current.hasTransition = false; }); return cssRules; @@ -595,13 +520,26 @@ export function _generate( options?: boolean | GenerateOptions, ): { cssRules: CSSRuleData[]; + listsRule: string; keyframes: Map; + atProperty: string[]; } { const { useFirstChild, plugins } = normalizeGenerateOptions(options); - // targetHash to lists of custom-properties for each coordinated-list type property - // to be populated when parsing interactions - const targetToLists = new Map(); + const targetsMap = new Map(); const keyframes = new Map(); const cssRules = config.interactions.flatMap((interaction, interactionIdx) => @@ -609,19 +547,26 @@ export function _generate( config, interaction, interactionIdx, - targetToLists, + targetsMap, keyframes, useFirstChild, plugins, ), ); - // for each target add unconditional rule for the coordinated lists from interactions targeting it - targetToLists.forEach((lists) => { - cssRules.push(buildListsRule(lists)); - }); + const targets = [...targetsMap.values()]; + + const animationLength = Math.max(...targets.map(({ animationIndex }) => animationIndex)); + const transitionLength = Math.max(...targets.map(({ transitionIndex }) => transitionIndex)); + const listsRule = buildListsRule(targets, animationLength, transitionLength); + + const animationSlotLength = Math.max(...targets.map(({ usedAnimationSlots }) => usedAnimationSlots)); + const transitionSlotLength = Math.max(...targets.map(({ usedTransitionSlots }) => usedTransitionSlots)); + const atProperty = buildAtPropertyRules( + animationLength, transitionLength, animationSlotLength, transitionSlotLength + ); - return { keyframes, cssRules }; + return { keyframes, atProperty, cssRules, listsRule }; } /** * Generates CSS for animations from an InteractConfig. @@ -639,11 +584,13 @@ export function _generate( * @returns string containing all of the CSS rules needed for time-based animations */ export function generate(config: InteractConfig, options?: boolean | GenerateOptions): string { - const { cssRules, keyframes } = _generate(config, options); + const { cssRules, keyframes, atProperty, listsRule } = _generate(config, options); const css = [ + ...atProperty, ...[...keyframes.entries()].map(([name, keyframes]) => keyframesToCSS(name, keyframes)), ...cssRules.map(CSSRuleToString), + listsRule ]; return css.join('\n'); diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index f1aca41f..362c7355 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -1,8 +1,6 @@ import type { Condition, ListPropertyName, - ListCustomProps, - CSSCoordinatedLists, CSSRuleData, } from '../types'; import { toCSSPropertyName } from '@wix/motion'; @@ -147,34 +145,72 @@ export function CSSRuleToString(rule: CSSRuleData): string { return media ? `@media ${media} {\n${cssRule}\n}` : cssRule; } -export function buildListsRule( - lists: CSSCoordinatedLists, - customProps?: ListCustomProps, +export const LIST_ANIMATION_PROPERTY_NAMES = [ + 'animation', + 'animation-composition', + 'animation-timeline', + 'animation-range', +] as const satisfies readonly ListPropertyName[]; +export const LIST_PROPERTY_NAMES = [...LIST_ANIMATION_PROPERTY_NAMES, 'transition'] as const satisfies readonly ListPropertyName[]; +export const LIST_PROPERTY_FALLBACKS: Record = { + transition: '_', + animation: 'none', + 'animation-composition': 'replace', + 'animation-timeline': 'auto', + 'animation-range': 'normal', +}; + +// TODO: maybe add `-intrct` to names? --anm-0 or --trns-0 could collide with user-defined names +export function getCustomPropName(name: string, index: number, isSlot: boolean = false): string { + return `--${name.replace(/(? [ + ...Array(name === 'transition' ? transitionLength : animationLength).map( + (_, i) => `@property ${getCustomPropName(name, i)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` + ), + ...Array(name === 'transition' ? animationSlotLength : transitionSlotLength).map( + (_, i) => `@property ${getCustomPropName(name, i)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` + ), + ]) +} + +export function buildSequenceListsRule( + animationLength: number, + transitionLength: number, + animationIndex: number, + transitionIndex: number, + animationSlotIndex: number, + transitionSlotIndex: number, + key: string, + childSelector?: string, conditions?: string[], configConditions?: Record, -): CSSRuleData { - const { key, childSelector, properties } = lists; - - const declarations = Object.entries(properties) - .filter( - (entry: [string, { fallback: string; varNames: string[] }]) => - entry[1] && entry[1].varNames.length, - ) - .map(([name, { fallback, varNames }]) => ({ - name, - value: varNames.map((n) => `var(${n}, ${fallback})`).join(', '), - })); +) : CSSRuleData | null { + const propertyNames = [ + ...(animationLength <= 0 ? [] : LIST_ANIMATION_PROPERTY_NAMES), + ...(transitionLength <= 0 ? [] : ['transition']), + ]; + if (propertyNames.length === 0) { + return null; + } + + const declarations = propertyNames.map( + (name) => ({ + name: getCustomPropName(name, name === 'transition' ? transitionIndex : animationIndex), + value: Array(name === 'transition' ? transitionLength : animationLength).map( + (_, i) => `var(${getCustomPropName(name, i + (name === 'transition' ? transitionSlotIndex : animationSlotIndex), true)})` + ).join(', ')}), + ); const rule: CSSRuleData = { key, childSelector, declarations }; - // option to assign into custom-properties instead of directly into the actual css properties - if (customProps) { - rule.declarations.forEach((declaration) => { - declaration.name = customProps[declaration.name as ListPropertyName]; - }); - } - - // option to add conditions to the rules if (conditions) { rule.media = getFullPredicateByType(conditions, configConditions || {}, 'media'); rule.selectorCondition = getSelectorCondition(conditions, configConditions || {}); @@ -182,3 +218,37 @@ export function buildListsRule( return rule; } + +export function buildListsRule( + targets: { key: string; childSelector?: string }[], + animationLength: number, + transitionLength: number, +) : string { + if (targets.length === 0) { + return ''; + } + + const propertyNames = [ + ...(animationLength <= 0 ? [] : LIST_ANIMATION_PROPERTY_NAMES), + ...(transitionLength <= 0 ? [] : ['transition']), + ]; + if (propertyNames.length === 0) { + return ''; + } + + const declarations = propertyNames.map( + (name) => ({ + name, + value: Array(name === 'transition' ? transitionLength : animationLength).map( + // TODO: maybe add `-intrct` to names? --anm-0 or --trns-0 could collide with user-defined names + (_, i) => `var(${getCustomPropName(name, i)})` + ).join(', ')}), + ); + + const joinedSelector = targets.map( + ({ key, childSelector }) => `[data-interact-key="${key}"]${childSelector ? ` ${childSelector}` : ''}` + ).join(', '); + + + return `${joinedSelector} {\n${declarations.map(({ name, value }) => ` ${name}: ${value};`).join('\n')}\n}`; +} diff --git a/packages/interact/src/core/resolvers.ts b/packages/interact/src/core/resolvers.ts index f13760d8..cf139882 100644 --- a/packages/interact/src/core/resolvers.ts +++ b/packages/interact/src/core/resolvers.ts @@ -35,7 +35,7 @@ export function resolveEffectForCSS( const { key: interactionKey, trigger } = interaction; const isPointerMove = trigger === 'pointerMove'; - // ensuring the original refernce of the effect has an id (required for states) + // ensuring the original reference of the effect has an id (required for states) if (!effect.effectId) { effect.effectId = fallbackId || generateId(); } @@ -137,13 +137,13 @@ export function resolveSequenceForCSS( ...new Set((conditions || []).filter((condition: string) => configConditions[condition])), ]; // resolving effects and cascading the conditions from sequence - const resolvedEffects = effects.map((effect) => { + const resolvedEffects = effects.map((effect, index) => { if (!effect.conditions) { effect.conditions = [...conditions]; } else { effect.conditions.push(...conditions); } - return resolveEffectForCSS({ ...effect, triggerType }, interaction, config); + return resolveEffectForCSS({ ...effect, triggerType }, interaction, config, `${sequenceId}-eff-${index}`); }); // removing unsupported effects and the whole sequence if all are unsupported diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index fc5e3406..f41a445a 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -31,14 +31,3 @@ export function getElementHash(elementIdentifier: ElementIdentifier): string { const { key, listContainer, listItemSelector, selector } = elementIdentifier; return `${key}\0${listContainer || ''}\0${listItemSelector || ''}\0${selector || ''}`; } - -export function getUniqueEncodedHash(hash: string): string { - let h1 = 0; - let h2 = 0; - for (let i = 0; i < hash.length; i++) { - const ch = hash.charCodeAt(i); - h1 = ((h1 << 5) - h1 + ch) | 0; - h2 = ((h2 << 3) ^ (h2 >>> 2) ^ ch) | 0; - } - return ((h1 >>> 0) * 0x100000 + ((h2 >>> 0) % 0x100000)).toString(36); -} diff --git a/packages/interact/src/types/css.ts b/packages/interact/src/types/css.ts index 5b479619..4343d836 100644 --- a/packages/interact/src/types/css.ts +++ b/packages/interact/src/types/css.ts @@ -21,17 +21,6 @@ export type ListPropertyName = | 'animation-timeline' | 'animation-range'; -export type CSSCoordinatedLists = { - key: string; - childSelector?: string; - properties: Partial>; -}; - -export type ListCustomProps = { - key: string; - childSelector?: string; -} & Record; - export type CSSRuleData = { key: string; childSelector?: string; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index ea586bbc..933c7585 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -9,10 +9,6 @@ export function isTemplatedKey(key: string) { return /\[]/g.test(key); } -export function kebabCustomProp(args: (string | number)[]) { - return `--${args.join('-')}`; -} - export function camelToKebabCase(property: string): string { return property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); } From c4389efd18e3cd7260145b56e7d4a636528f1c44 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 00:02:55 +0300 Subject: [PATCH 2/8] tests + fixes --- packages/interact/src/core/css.ts | 8 +- packages/interact/src/core/cssUtils.ts | 26 +-- packages/interact/test/css.spec.ts | 103 +++++----- packages/interact/test/cssUtils.spec.ts | 248 +++++++++++++++++------- 4 files changed, 256 insertions(+), 129 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 54c4a376..6bc95e68 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -163,6 +163,7 @@ function effectToCSS( })); // declare custom parameters + // TODO - register those to define with @property to prevent unintended override declarations.push( ...cssAnimations.flatMap(({ custom }) => Object.entries(custom || {}) @@ -171,15 +172,18 @@ function effectToCSS( ), ); + // TODO - we always set everything to avoid cascade mistakes. but with @property there is initial-value, + // so as long as no non-default value was set for a property in any previous effect/sequence in the same interaction + // we can skip setting it here to default const animationDeclarations = LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ name: customProps[propertyName], value: cssAnimations .map((animation) => { const name = LIST_PROPERTY_NAMES_MOTION[propertyName]; - return (animation as Record)[name]; + return (animation as Record)[name] || LIST_PROPERTY_FALLBACKS[propertyName]; }) - .join(', ') || LIST_PROPERTY_FALLBACKS[propertyName], + .join(', '), })); if (initial) { diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index 362c7355..ebb2f0c8 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -83,8 +83,8 @@ export function keyframeObjectToKeyframeCSS(keyframeObj: Keyframe, percentage: n const cssKey = keyframePropertyToCSS(key); return `${cssKey}: ${value};`; }) - .join('\n'); - return `${percentage}% {\n${properties}\n}`; + .join('\n '); + return `${percentage}% {\n ${properties}\n }`; } export function keyframesToCSS(name: string, keyframes: Keyframe[]): string { @@ -100,9 +100,9 @@ export function keyframesToCSS(name: string, keyframes: Keyframe[]): string { return keyframeObjectToKeyframeCSS(kf, percentage); }) - .join('\n'); + .join('\n '); - return `@keyframes ${name} {\n${keyframeBlocks}\n}`; + return `@keyframes ${name} {\n ${keyframeBlocks}\n}`; } export function CSSRuleToString(rule: CSSRuleData): string { @@ -139,8 +139,8 @@ export function CSSRuleToString(rule: CSSRuleData): string { const declarationsStr = declarations .map(({ name, value, important }) => `${name}: ${value}${important ? ' !important' : ''};`) - .join('\n'); - const cssRule = `${selector} {\n${declarationsStr}\n}`; + .join('\n '); + const cssRule = `${selector} {\n ${declarationsStr}\n}`; return media ? `@media ${media} {\n${cssRule}\n}` : cssRule; } @@ -172,11 +172,13 @@ export function buildAtPropertyRules( transitionSlotLength: number, ) : string[] { return LIST_PROPERTY_NAMES.flatMap((name) => [ - ...Array(name === 'transition' ? transitionLength : animationLength).map( + ...Array.from( + { length: name === 'transition' ? transitionLength : animationLength }, (_, i) => `@property ${getCustomPropName(name, i)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` ), - ...Array(name === 'transition' ? animationSlotLength : transitionSlotLength).map( - (_, i) => `@property ${getCustomPropName(name, i)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` + ...Array.from( + { length: name === 'transition' ? transitionSlotLength : animationSlotLength }, + (_, i) => `@property ${getCustomPropName(name, i, true)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` ), ]) } @@ -204,7 +206,8 @@ export function buildSequenceListsRule( const declarations = propertyNames.map( (name) => ({ name: getCustomPropName(name, name === 'transition' ? transitionIndex : animationIndex), - value: Array(name === 'transition' ? transitionLength : animationLength).map( + value: Array.from( + { length: name === 'transition' ? transitionLength : animationLength }, (_, i) => `var(${getCustomPropName(name, i + (name === 'transition' ? transitionSlotIndex : animationSlotIndex), true)})` ).join(', ')}), ); @@ -239,7 +242,8 @@ export function buildListsRule( const declarations = propertyNames.map( (name) => ({ name, - value: Array(name === 'transition' ? transitionLength : animationLength).map( + value: Array.from( + { length: name === 'transition' ? transitionLength : animationLength }, // TODO: maybe add `-intrct` to names? --anm-0 or --trns-0 could collide with user-defined names (_, i) => `var(${getCustomPropName(name, i)})` ).join(', ')}), diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 7a56a74c..f0859e00 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -397,11 +397,27 @@ describe('css.generate', () => { }); }); -const isAnimationProp = (name: string) => /^--animation-\d/.test(name); -const isCompositionProp = (name: string) => /^--animation-composition-/.test(name); -const isTransitionProp = (name: string) => /^--transition-/.test(name); -const isTimelineProp = (name: string) => /^--animation-timeline-/.test(name); -const isRangeProp = (name: string) => /^--animation-range-/.test(name); +const isAnimationProp = (name: string) => /^--anm-(slot-)?\d/.test(name); +const isCompositionProp = (name: string) => /^--anm-cmps-(slot-)?\d/.test(name); +const isTransitionProp = (name: string) => /^--trns-(slot-)?\d/.test(name); +const isTimelineProp = (name: string) => /^--anm-tmln-(slot-)?\d/.test(name); +const isRangeProp = (name: string) => /^--anm-rng-(slot-)?\d/.test(name); + +function parseListsRule(listsRule: string) { + const [, selector = '', body = ''] = listsRule.match(/^([^{]+)\{([\s\S]*)\}$/) || []; + + return { + selectors: selector.trim().split(', ').filter(Boolean), + declarations: body + .split(';') + .map((declaration) => declaration.trim()) + .filter(Boolean) + .map((declaration) => ({ + name: declaration.slice(0, declaration.indexOf(':')).trim(), + value: declaration.slice(declaration.indexOf(':') + 1).trim(), + })), + }; +} function findDecl( declarations: CSSRuleData['declarations'], @@ -731,20 +747,16 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { listsRule } = _generate(config); + const { declarations } = parseListsRule(listsRule); - const coordListRule = cssRules.find( - (r) => - r.declarations.some((d) => d.name === 'animation-timeline') && - String(r.declarations.find((d) => d.name === 'animation-timeline')?.value).includes( - '), var(', - ), - ); - expect(coordListRule).toBeDefined(); + const timelineListDecl = declarations.find((d) => d.name === 'animation-timeline'); + expect(timelineListDecl).toBeDefined(); + expect(timelineListDecl!.value).toBe('var(--anm-tmln-0), var(--anm-tmln-1)'); - const rangeListDecl = coordListRule!.declarations.find((d) => d.name === 'animation-range'); + const rangeListDecl = declarations.find((d) => d.name === 'animation-range'); expect(rangeListDecl).toBeDefined(); - expect(String(rangeListDecl!.value)).toContain('), var('); + expect(rangeListDecl!.value).toBe('var(--anm-rng-0), var(--anm-rng-1)'); }); it('should set timeline to none and range to normal for non-viewProgress keyframeEffect', () => { @@ -1310,7 +1322,7 @@ describe('css._generate', () => { return cssRules .flatMap((r) => r.declarations) - .filter((d) => isAnimationProp(d.name) && !String(d.value).includes('var(--animation')) + .filter((d) => isAnimationProp(d.name) && !String(d.value).includes('var(--anm')) .map((d) => String(d.value)) .join('\n'); }; @@ -1449,25 +1461,25 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { listsRule } = _generate(config); + const { selectors, declarations } = parseListsRule(listsRule); - const coordListRule = cssRules.find( - (r) => - r.declarations.some((d) => d.name === 'animation') && - String(r.declarations.find((d) => d.name === 'animation')?.value).includes('), var('), - ); - expect(coordListRule).toBeDefined(); + expect(selectors).toEqual(['[data-interact-key="el"] > :first-child']); - const timelineDecl = coordListRule!.declarations.find((d) => d.name === 'animation-timeline'); + const animationDecl = declarations.find((d) => d.name === 'animation'); + expect(animationDecl).toBeDefined(); + expect(animationDecl!.value).toBe('var(--anm-0), var(--anm-1)'); + + const timelineDecl = declarations.find((d) => d.name === 'animation-timeline'); expect(timelineDecl).toBeDefined(); - expect(String(timelineDecl!.value)).toContain('), var('); + expect(timelineDecl!.value).toBe('var(--anm-tmln-0), var(--anm-tmln-1)'); - const rangeDecl = coordListRule!.declarations.find((d) => d.name === 'animation-range'); + const rangeDecl = declarations.find((d) => d.name === 'animation-range'); expect(rangeDecl).toBeDefined(); - expect(String(rangeDecl!.value)).toContain('), var('); + expect(rangeDecl!.value).toBe('var(--anm-rng-0), var(--anm-rng-1)'); }); - it('should produce separate coordinated-list rules for different targets', () => { + it('should produce a single coordinated-list rule covering all targets', () => { const config: InteractConfig = { effects: {}, interactions: [ @@ -1502,18 +1514,17 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { listsRule } = _generate(config); + const { selectors, declarations } = parseListsRule(listsRule); - const coordListRules = cssRules.filter( - (r) => - r.declarations.some((d) => d.name === 'animation') && - String(r.declarations.find((d) => d.name === 'animation')?.value).includes('var('), - ); - expect(coordListRules.length).toBe(2); + expect(selectors).toEqual([ + '[data-interact-key="el-a"] > :first-child', + '[data-interact-key="el-b"] > :first-child', + ]); - const keys = coordListRules.map((r) => r.key); - expect(keys).toContain('el-a'); - expect(keys).toContain('el-b'); + const animationDecl = declarations.find((d) => d.name === 'animation'); + expect(animationDecl).toBeDefined(); + expect(animationDecl!.value).toBe('var(--anm-0)'); }); }); @@ -1654,7 +1665,7 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { cssRules, listsRule } = _generate(config); const effectRules = cssRules.filter((r) => r.declarations.some((d) => isAnimationProp(d.name)), @@ -1666,12 +1677,10 @@ describe('css._generate', () => { ); expect(new Set(animPropNames).size).toBe(1); - const coordListRules = cssRules.filter( - (r) => - r.declarations.some((d) => d.name === 'animation') && - String(r.declarations.find((d) => d.name === 'animation')?.value).includes('var('), - ); - expect(coordListRules).toHaveLength(1); + const { declarations } = parseListsRule(listsRule); + const animationDecl = declarations.find((d) => d.name === 'animation'); + expect(animationDecl).toBeDefined(); + expect(animationDecl!.value).toBe('var(--anm-0)'); }); }); @@ -1756,7 +1765,7 @@ describe('css._generate', () => { expect(calls[0].value).toEqual({ container: '.title', type: 'chars' }); expect(calls[0].ctx.key).toBe('hero'); expect(calls[0].ctx.scope).toBe('interaction'); - expect(result).toContain('[data-interact-key="hero"] .title {\nvisibility: hidden;\n}'); + expect(result).toContain('[data-interact-key="hero"] .title {\n visibility: hidden;\n}'); }); it('does nothing when no plugins option is passed', () => { diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts index 5118f451..5f5c8a09 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -6,8 +6,11 @@ import { keyframesToCSS, CSSRuleToString, buildListsRule, + buildSequenceListsRule, + buildAtPropertyRules, + getCustomPropName, } from '../src/core/cssUtils'; -import type { CSSCoordinatedLists, ListCustomProps, CSSRuleData } from '../src/types/css'; +import type { CSSRuleData } from '../src/types/css'; describe('keyframePropertyToCSS', () => { it('should convert cssFloat to float', () => { @@ -150,8 +153,8 @@ describe('interpolateKeyframesOffsets', () => { describe('keyframeObjectToKeyframeCSS', () => { it('should convert a keyframe object to a CSS block at the given percentage', () => { const result = keyframeObjectToKeyframeCSS({ opacity: '0', transform: 'scale(0.5)' }, 0); - const expected1 = '0% {\nopacity: 0;\ntransform: scale(0.5);\n}'; - const expected2 = '0% {\ntransform: scale(0.5);\nopacity: 0;\n}'; + const expected1 = '0% {\n opacity: 0;\n transform: scale(0.5);\n }'; + const expected2 = '0% {\n transform: scale(0.5);\n opacity: 0;\n }'; expect(result === expected1 || result === expected2).toBe(true); }); @@ -165,7 +168,7 @@ describe('keyframeObjectToKeyframeCSS', () => { { opacity: '1', transform: undefined, color: null }, 50, ); - const expected = '50% {\nopacity: 1;\n}'; + const expected = '50% {\n opacity: 1;\n }'; expect(result).toEqual(expected); }); @@ -181,7 +184,8 @@ describe('keyframesToCSS', () => { { offset: 0, opacity: '0' }, { offset: 1, opacity: '1' }, ]); - const expected = '@keyframes fadeIn {\n0% {\nopacity: 0;\n}\n100% {\nopacity: 1;\n}\n}'; + const expected = + '@keyframes fadeIn {\n 0% {\n opacity: 0;\n }\n 100% {\n opacity: 1;\n }\n}'; expect(result).toEqual(expected); }); @@ -216,7 +220,7 @@ describe('CSSRuleToString', () => { declarations: [{ name: 'opacity', value: '0' }], }; const result = CSSRuleToString(rule); - const expected = '[data-interact-key="my-el"] {\nopacity: 0;\n}'; + const expected = '[data-interact-key="my-el"] {\n opacity: 0;\n}'; expect(result).toEqual(expected); }); @@ -231,7 +235,7 @@ describe('CSSRuleToString', () => { childSelector: '.inner', declarations: [{ name: 'color', value: 'red' }], }; - const expected = '[data-interact-key="my-el"] .inner {\ncolor: red;\n}'; + const expected = '[data-interact-key="my-el"] .inner {\n color: red;\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); @@ -245,7 +249,7 @@ describe('CSSRuleToString', () => { ], }; const expected = - '[data-interact-key="my-el"]:not([data-interact-enter]) {\nvisibility: hidden !important;\ntransform: none !important;\n}'; + '[data-interact-key="my-el"]:not([data-interact-enter]) {\n visibility: hidden !important;\n transform: none !important;\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); @@ -256,7 +260,7 @@ describe('CSSRuleToString', () => { declarations: [{ name: 'opacity', value: '0' }], }; const expected = - '[data-interact-key="my-el"]:not([data-interact-enter="done"]) {\nopacity: 0;\n}'; + '[data-interact-key="my-el"]:not([data-interact-enter="done"]) {\n opacity: 0;\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); @@ -267,7 +271,7 @@ describe('CSSRuleToString', () => { declarations: [{ name: 'opacity', value: '1' }], }; const expected = - '[data-interact-key="my-el"]:is(:state(active), :--active, [data-interact-effect~="active"]) {\nopacity: 1;\n}'; + '[data-interact-key="my-el"]:is(:state(active), :--active, [data-interact-effect~="active"]) {\n opacity: 1;\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); @@ -277,7 +281,7 @@ describe('CSSRuleToString', () => { selectorCondition: ':is(.visible)', declarations: [{ name: 'opacity', value: '1' }], }; - const expected = '[data-interact-key="my-el"]:is(.visible) {\nopacity: 1;\n}'; + const expected = '[data-interact-key="my-el"]:is(.visible) {\n opacity: 1;\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); @@ -288,7 +292,7 @@ describe('CSSRuleToString', () => { declarations: [{ name: 'display', value: 'block' }], }; const expected = - '@media (min-width: 768px) {\n[data-interact-key="my-el"] {\ndisplay: block;\n}\n}'; + '@media (min-width: 768px) {\n[data-interact-key="my-el"] {\n display: block;\n}\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); @@ -305,92 +309,198 @@ describe('CSSRuleToString', () => { ], }; const expected = - '@media (min-width: 1024px) {\n[data-interact-key="my-el"]:is(:state(hover), :--hover, [data-interact-effect~="hover"]) .child:not([data-interact-enter="done"]) {\nopacity: 1;\ncolor: blue;\n}\n}'; + '@media (min-width: 1024px) {\n[data-interact-key="my-el"]:is(:state(hover), :--hover, [data-interact-effect~="hover"]) .child:not([data-interact-enter="done"]) {\n opacity: 1;\n color: blue;\n}\n}'; expect(CSSRuleToString(rule)).toEqual(expected); }); }); +describe('getCustomPropName', () => { + it('should compress property names by stripping vowels', () => { + expect(getCustomPropName('animation', 0)).toBe('--anm-0'); + expect(getCustomPropName('animation-composition', 0)).toBe('--anm-cmps-0'); + expect(getCustomPropName('animation-timeline', 0)).toBe('--anm-tmln-0'); + expect(getCustomPropName('animation-range', 0)).toBe('--anm-rng-0'); + expect(getCustomPropName('transition', 0)).toBe('--trns-0'); + }); + + it('should append the index', () => { + expect(getCustomPropName('animation', 3)).toBe('--anm-3'); + }); + + it('should mark slot names when isSlot is true', () => { + expect(getCustomPropName('animation', 2, true)).toBe('--anm-slot-2'); + expect(getCustomPropName('transition', 0, true)).toBe('--trns-slot-0'); + }); +}); + +describe('buildAtPropertyRules', () => { + it('should declare one @property per list property with its fallback as initial-value', () => { + const rules = buildAtPropertyRules(1, 1, 0, 0); + + expect(rules).toEqual([ + '@property --anm-0 { syntax: "*"; inherits: false; initial-value: none; }', + '@property --anm-cmps-0 { syntax: "*"; inherits: false; initial-value: replace; }', + '@property --anm-tmln-0 { syntax: "*"; inherits: false; initial-value: auto; }', + '@property --anm-rng-0 { syntax: "*"; inherits: false; initial-value: normal; }', + '@property --trns-0 { syntax: "*"; inherits: false; initial-value: _; }', + ]); + }); + + it('should declare one @property per index', () => { + const rules = buildAtPropertyRules(2, 0, 0, 0); + + expect(rules.filter((rule) => rule.startsWith('@property --anm-'))).toHaveLength(8); + expect(rules).toContain( + '@property --anm-1 { syntax: "*"; inherits: false; initial-value: none; }', + ); + }); + + it('should declare slot properties alongside the non-slot ones', () => { + const rules = buildAtPropertyRules(1, 0, 2, 0); + + expect(rules).toContain( + '@property --anm-slot-0 { syntax: "*"; inherits: false; initial-value: none; }', + ); + expect(rules).toContain( + '@property --anm-slot-1 { syntax: "*"; inherits: false; initial-value: none; }', + ); + expect(rules).toContain( + '@property --anm-cmps-slot-1 { syntax: "*"; inherits: false; initial-value: replace; }', + ); + }); + + it('should use the transition lengths for the transition property', () => { + const rules = buildAtPropertyRules(0, 2, 0, 1); + + expect(rules.filter((rule) => rule.startsWith('@property --trns-'))).toEqual([ + '@property --trns-0 { syntax: "*"; inherits: false; initial-value: _; }', + '@property --trns-1 { syntax: "*"; inherits: false; initial-value: _; }', + '@property --trns-slot-0 { syntax: "*"; inherits: false; initial-value: _; }', + ]); + }); + + it('should return no rules when all lengths are zero', () => { + expect(buildAtPropertyRules(0, 0, 0, 0)).toEqual([]); + }); +}); + describe('buildListsRule', () => { - const baseLists: CSSCoordinatedLists = { - key: 'my-el', - properties: { - animation: { - fallback: 'none', - varNames: ['--anim-1', '--anim-2'], - }, - transition: { - fallback: '_', - varNames: ['--trans-1'], - }, - 'animation-composition': { - fallback: 'replace', - varNames: ['--comp-1'], - }, - }, - }; - - it('should build a rule with var() declarations for each prop', () => { - const rule = buildListsRule(baseLists); - expect(rule.key).toBe('my-el'); - expect(rule.declarations).toHaveLength(3); + it('should build one rule assigning each list property from its custom properties', () => { + const rule = buildListsRule([{ key: 'my-el' }], 2, 1); + + expect(rule).toBe( + [ + '[data-interact-key="my-el"] {', + ' animation: var(--anm-0), var(--anm-1);', + ' animation-composition: var(--anm-cmps-0), var(--anm-cmps-1);', + ' animation-timeline: var(--anm-tmln-0), var(--anm-tmln-1);', + ' animation-range: var(--anm-rng-0), var(--anm-rng-1);', + ' transition: var(--trns-0);', + '}', + ].join('\n'), + ); + }); - const animDecl = rule.declarations.find((d) => d.name === 'animation'); - expect(animDecl?.value).toBe('var(--anim-1, none), var(--anim-2, none)'); + it('should append childSelector to the target selector', () => { + const rule = buildListsRule([{ key: 'my-el', childSelector: '> :first-child' }], 1, 0); - const compositionDecl = rule.declarations.find((d) => d.name === 'animation-composition'); - expect(compositionDecl?.value).toBe('var(--comp-1, replace)'); + expect(rule).toContain('[data-interact-key="my-el"] > :first-child {'); + }); - const transDecl = rule.declarations.find((d) => d.name === 'transition'); - expect(transDecl?.value).toBe('var(--trans-1, _)'); + it('should join all targets into a single selector list', () => { + const rule = buildListsRule( + [{ key: 'a', childSelector: '> :first-child' }, { key: 'b' }], + 1, + 0, + ); + + expect(rule).toContain( + '[data-interact-key="a"] > :first-child, [data-interact-key="b"] {', + ); + }); + + it('should omit animation properties when there are no animations', () => { + const rule = buildListsRule([{ key: 'my-el' }], 0, 1); + + expect(rule).not.toContain('animation'); + expect(rule).toContain('transition: var(--trns-0);'); + }); + + it('should omit the transition property when there are no transitions', () => { + const rule = buildListsRule([{ key: 'my-el' }], 1, 0); + + expect(rule).not.toContain('transition'); + expect(rule).toContain('animation: var(--anm-0);'); + }); + + it('should return an empty string when there are no targets', () => { + expect(buildListsRule([], 2, 1)).toBe(''); + }); + + it('should return an empty string when there are no list properties', () => { + expect(buildListsRule([{ key: 'my-el' }], 0, 0)).toBe(''); + }); +}); + +describe('buildSequenceListsRule', () => { + it('should assign the interaction custom property from the sequence slot properties', () => { + const rule = buildSequenceListsRule(2, 0, 0, 0, 0, 0, 'my-el')!; + + expect(rule.key).toBe('my-el'); + expect(rule.declarations).toEqual([ + { name: '--anm-0', value: 'var(--anm-slot-0), var(--anm-slot-1)' }, + { name: '--anm-cmps-0', value: 'var(--anm-cmps-slot-0), var(--anm-cmps-slot-1)' }, + { name: '--anm-tmln-0', value: 'var(--anm-tmln-slot-0), var(--anm-tmln-slot-1)' }, + { name: '--anm-rng-0', value: 'var(--anm-rng-slot-0), var(--anm-rng-slot-1)' }, + ]); + }); + + it('should offset the slot names by the slot index', () => { + const rule = buildSequenceListsRule(2, 0, 1, 0, 3, 0, 'my-el')!; + + expect(rule.declarations[0]).toEqual({ + name: '--anm-1', + value: 'var(--anm-slot-3), var(--anm-slot-4)', + }); }); it('should include childSelector when present', () => { - const lists: CSSCoordinatedLists = { ...baseLists, childSelector: '.target' }; - const rule = buildListsRule(lists); + const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el', '.target')!; + expect(rule.childSelector).toBe('.target'); }); - it('should rename declarations when customProps mapping is provided', () => { - const customProps = { - key: 'my-el', - childSelector: undefined, - animation: '--my-anim', - transition: '--my-trans', - 'animation-composition': '--my-comp', - } as ListCustomProps; - - const rule = buildListsRule(baseLists, customProps); - const names = rule.declarations.map((d) => d.name); - expect(names).toContain('--my-anim'); - expect(names).toContain('--my-trans'); - expect(names).toContain('--my-comp'); - expect(names).not.toContain('animation'); - expect(names).not.toContain('transition'); - expect(names).not.toContain('animation-composition'); + it('should omit animation properties when there are no animations', () => { + const rule = buildSequenceListsRule(0, 1, 0, 0, 0, 0, 'my-el')!; + + expect(rule.declarations).toEqual([{ name: '--trns-0', value: 'var(--trns-slot-0)' }]); + }); + + it('should return null when there are no list properties', () => { + expect(buildSequenceListsRule(0, 0, 0, 0, 0, 0, 'my-el')).toBeNull(); }); it('should add media condition when conditions with media type are provided', () => { - const conditions = ['desktop']; - const configConditions = { + const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el', undefined, ['desktop'], { desktop: { type: 'media' as const, predicate: 'min-width: 1024px' }, - }; - const rule = buildListsRule(baseLists, undefined, conditions, configConditions); + })!; + expect(rule.media).toBe('(min-width: 1024px)'); expect(rule.selectorCondition).toBeFalsy(); }); it('should add selectorCondition when conditions with selector type are provided', () => { - const conditions = ['visible']; - const configConditions = { + const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el', undefined, ['visible'], { visible: { type: 'selector' as const, predicate: '.is-visible' }, - }; - const rule = buildListsRule(baseLists, undefined, conditions, configConditions); + })!; + expect(rule.selectorCondition).toBe(':is(.is-visible)'); expect(rule.media).toBeFalsy(); }); it('should have no media or selectorCondition when no conditions are given', () => { - const rule = buildListsRule(baseLists); + const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el')!; + expect(rule.media).toBeUndefined(); expect(rule.selectorCondition).toBeUndefined(); }); From eb71a666b3b1432291356d54b582dec6e065e087 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 02:52:54 +0300 Subject: [PATCH 3/8] not applying unnecessary defaults --- packages/interact/src/core/css.ts | 65 ++++++++----- packages/interact/test/css.spec.ts | 149 +++++++++++++++++++++++------ 2 files changed, 159 insertions(+), 55 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 6bc95e68..5c195377 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -112,6 +112,7 @@ function effectToCSS( configConditions: Record, trigger: TriggerVariant, customProps: Record, + nonDefaults: Set, childSelector?: string, plugins?: InteractPluginStyles, sequence?: ResolvedSequence, @@ -172,10 +173,8 @@ function effectToCSS( ), ); - // TODO - we always set everything to avoid cascade mistakes. but with @property there is initial-value, - // so as long as no non-default value was set for a property in any previous effect/sequence in the same interaction - // we can skip setting it here to default const animationDeclarations = LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ + _listPropertyName: propertyName, name: customProps[propertyName], value: cssAnimations @@ -184,7 +183,10 @@ function effectToCSS( return (animation as Record)[name] || LIST_PROPERTY_FALLBACKS[propertyName]; }) .join(', '), - })); + })).filter(({ _listPropertyName, name, value }) => + value !== LIST_PROPERTY_FALLBACKS[_listPropertyName] || nonDefaults.has(name) + ); + animationDeclarations.forEach(({ name }) => nonDefaults.add(name)); if (initial) { // declare animation custom properties with initial dependent on data-motion-enter @@ -213,10 +215,13 @@ function effectToCSS( const transitions = transitionEffectToTransitionsList(effect); // declaring transition custom property - declarations.push({ - name: customProps.transition, - value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, - }); + if (transitions.length || nonDefaults.has(customProps.transition)) { + declarations.push({ + name: customProps.transition, + value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, + }); + nonDefaults.add(customProps.transition); + } // adding state rule rules.push({ @@ -230,7 +235,9 @@ function effectToCSS( } else { // setting off animation custom properties declarations.push( - ...LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ + ...LIST_ANIMATION_PROPERTY_NAMES.filter((propertyName) => + nonDefaults.has(customProps[propertyName]) + ).map((propertyName) => ({ name: customProps[propertyName], value: LIST_PROPERTY_FALLBACKS[propertyName], })), @@ -257,6 +264,7 @@ function parseEffect( sequenceTransitionIndex: number; hasAnimation: boolean; hasTransition: boolean; + nonDefaults: Set; }>, visited: Set, keyframesMap: Map, @@ -285,6 +293,7 @@ function parseEffect( sequenceTransitionIndex: 0, hasAnimation: false, hasTransition: false, + nonDefaults: new Set(), }; visited.add(targetHash); @@ -313,6 +322,7 @@ function parseEffect( configConditions, trigger, customProps, + current.nonDefaults, childSelector, plugins, sequence, @@ -320,8 +330,10 @@ function parseEffect( keyframes.forEach(({ name, keyframes }) => keyframesMap.set(name, keyframes)); - const hasAnimation = Boolean(effect.namedEffect || effect.keyframeEffect); - const hasTransition = Boolean(effect.transition || effect.transitionProperties); + const hasAnimation = LIST_ANIMATION_PROPERTY_NAMES.some( + (propertyName) => current.nonDefaults.has(customProps[propertyName]) + ); + const hasTransition = current.nonDefaults.has(customProps['transition']); current.hasAnimation ||= hasAnimation; current.hasTransition ||= hasTransition; if (sequence) { @@ -350,6 +362,7 @@ function parseSequence( sequenceTransitionIndex: number; hasAnimation: boolean; hasTransition: boolean; + nonDefaults: Set; }>, visited: Set, keyframesMap: Map, @@ -360,20 +373,17 @@ function parseSequence( const localVisited = new Set(); - for (const effect of sequence.effects) { - const rules = parseEffect( - effect, - configConditions, - trigger, - targetsMap, - localVisited, - keyframesMap, - useFirstChild, - plugins, - sequence, - ); - cssRules.push(...rules); - } + cssRules.push(...sequence.effects.flatMap((effect) => parseEffect( + effect, + configConditions, + trigger, + targetsMap, + localVisited, + keyframesMap, + useFirstChild, + plugins, + sequence, + ))); const { conditions } = sequence; @@ -396,6 +406,7 @@ function parseSequence( configConditions, ); if (rule) { + rule.declarations.forEach(({ name }) => current.nonDefaults.add(name)); cssRules.push(rule); } @@ -424,6 +435,7 @@ function parseInteraction( sequenceTransitionIndex: number; hasAnimation: boolean; hasTransition: boolean; + nonDefaults: Set; }>, keyframesMap: Map, useFirstChild: boolean = true, @@ -543,6 +555,7 @@ export function _generate( sequenceTransitionIndex: number; hasAnimation: boolean; hasTransition: boolean; + nonDefaults: Set; }>(); const keyframes = new Map(); @@ -595,7 +608,7 @@ export function generate(config: InteractConfig, options?: boolean | GenerateOpt ...[...keyframes.entries()].map(([name, keyframes]) => keyframesToCSS(name, keyframes)), ...cssRules.map(CSSRuleToString), listsRule - ]; + ].filter((rule) => rule); return css.join('\n'); } diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index f0859e00..f80acf5a 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -759,7 +759,7 @@ describe('css._generate', () => { expect(rangeListDecl!.value).toBe('var(--anm-rng-0), var(--anm-rng-1)'); }); - it('should set timeline to none and range to normal for non-viewProgress keyframeEffect', () => { + it('should leave timeline and range at their @property defaults for non-viewProgress keyframeEffect', () => { const config: InteractConfig = { effects: {}, interactions: [ @@ -780,18 +780,22 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { cssRules, atProperty } = _generate(config); const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; - const timelineDecl = findDecl(effectRule.declarations, (d) => isTimelineProp(d.name)); - expect(timelineDecl!.value).toBe('auto'); + expect(findDecl(effectRule.declarations, (d) => isTimelineProp(d.name))).toBeUndefined(); + expect(findDecl(effectRule.declarations, (d) => isRangeProp(d.name))).toBeUndefined(); - const rangeDecl = findDecl(effectRule.declarations, (d) => isRangeProp(d.name)); - expect(rangeDecl!.value).toBe('normal'); + expect(atProperty).toContain( + '@property --anm-tmln-0 { syntax: "*"; inherits: false; initial-value: auto; }', + ); + expect(atProperty).toContain( + '@property --anm-rng-0 { syntax: "*"; inherits: false; initial-value: normal; }', + ); }); - it('should include timeline and range custom props on initial rule for viewEnter', () => { + it('should keep the animation slot on the initial rule for viewEnter, with timeline and range left to @property', () => { const config: InteractConfig = { effects: {}, interactions: [ @@ -812,20 +816,23 @@ describe('css._generate', () => { ], }; - const { cssRules } = _generate(config); + const { cssRules, atProperty } = _generate(config); const initialRule = cssRules.find( (r) => r.selectorSuffix === ':not([data-interact-enter="done"])', )!; expect(initialRule).toBeDefined(); - const timelineDecl = findDecl(initialRule.declarations, (d) => isTimelineProp(d.name)); - expect(timelineDecl).toBeDefined(); - expect(timelineDecl!.value).toBe('auto'); + expect(findDecl(initialRule.declarations, (d) => isAnimationProp(d.name))).toBeDefined(); + expect(findDecl(initialRule.declarations, (d) => isTimelineProp(d.name))).toBeUndefined(); + expect(findDecl(initialRule.declarations, (d) => isRangeProp(d.name))).toBeUndefined(); - const rangeDecl = findDecl(initialRule.declarations, (d) => isRangeProp(d.name)); - expect(rangeDecl).toBeDefined(); - expect(rangeDecl!.value).toBe('normal'); + expect(atProperty).toContain( + '@property --anm-tmln-0 { syntax: "*"; inherits: false; initial-value: auto; }', + ); + expect(atProperty).toContain( + '@property --anm-rng-0 { syntax: "*"; inherits: false; initial-value: normal; }', + ); }); it('should produce a view-timeline rule for viewProgress trigger', () => { @@ -970,7 +977,7 @@ describe('css._generate', () => { }); describe('effectToCSS - no effect property', () => { - it('should set all custom properties to off values when effect has no animation or transition', () => { + it('should emit nothing when the effect has no animation or transition and nothing set the slots', () => { const config: InteractConfig = { effects: {}, interactions: [ @@ -982,22 +989,78 @@ describe('css._generate', () => { ], }; + expect(_generate(config).cssRules).toEqual([]); + expect(generate(config)).toBe(''); + }); + + it('should reset only the slots a previous effect on the same target set to a non-default', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + { effectId: 'empty1' }, + ], + }, + ], + }; + const { cssRules } = _generate(config); - const effectRule = cssRules.find( - (r) => - r.declarations.some((d) => isAnimationProp(d.name) && d.value === 'none') && - r.declarations.some((d) => isCompositionProp(d.name) && d.value === 'replace'), + const offRule = cssRules.find((r) => + r.declarations.some((d) => isAnimationProp(d.name) && d.value === 'none'), ); - expect(effectRule).toBeDefined(); + expect(offRule).toBeDefined(); - const timelineDecl = findDecl(effectRule!.declarations, (d) => isTimelineProp(d.name)); - expect(timelineDecl).toBeDefined(); - expect(timelineDecl!.value).toBe('auto'); + expect(findDecl(offRule!.declarations, (d) => isCompositionProp(d.name))).toBeUndefined(); + expect(findDecl(offRule!.declarations, (d) => isTimelineProp(d.name))).toBeUndefined(); + expect(findDecl(offRule!.declarations, (d) => isRangeProp(d.name))).toBeUndefined(); + }); - const rangeDecl = findDecl(effectRule!.declarations, (d) => isRangeProp(d.name)); - expect(rangeDecl).toBeDefined(); - expect(rangeDecl!.value).toBe('normal'); + it('should not reset a slot an earlier interaction set, since the later interaction gets its own slot', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + { + key: 'el', + trigger: 'hover', + effects: [{ effectId: 'empty1' }], + }, + ], + }; + + const { cssRules } = _generate(config); + + expect( + cssRules.some((r) => r.declarations.some((d) => isAnimationProp(d.name) && d.value !== 'none')), + ).toBe(true); + expect( + cssRules.some((r) => r.declarations.some((d) => isAnimationProp(d.name) && d.value === 'none')), + ).toBe(false); }); it('should produce no keyframes for an effect with no animation', () => { @@ -1566,7 +1629,16 @@ describe('css._generate', () => { { key: 'el', trigger: 'click', - effects: [{ effectId: 'e1' }], + effects: [ + { + effectId: 'e1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, ], }; @@ -1584,13 +1656,23 @@ describe('css._generate', () => { { key: 'el', trigger: 'click', - effects: [{ effectId: 'e1' }], + effects: [ + { + effectId: 'e1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, ], }; const { cssRules } = _generate(config, false); + expect(cssRules).not.toEqual([]); const ruleWithFirstChild = cssRules.find((r) => r.childSelector === '> :first-child'); expect(ruleWithFirstChild).toBeUndefined(); }); @@ -1691,7 +1773,16 @@ describe('css._generate', () => { { key: 'el', trigger: 'click', - effects: [{ effectId: 'e1' }], + effects: [ + { + effectId: 'e1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, ], }; From e079a496f26466049cc5f14a393b24b42f852818 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 03:16:46 +0300 Subject: [PATCH 4/8] wip --- packages/interact/src/core/css.ts | 80 ++++++++----------------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 5c195377..d697d05a 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -43,6 +43,22 @@ export const DEFAULT_INITIAL = [ ]; type AnimationPropertyName = (typeof LIST_ANIMATION_PROPERTY_NAMES)[number]; +type TargetContext = { + key: string; + childSelector?: string; + animationIndex: number; + transitionIndex: number; + usedAnimationSlots: number; + usedTransitionSlots: number; + reservedAnimationSlots: number; + reservedTransitionSlots: number; + sequenceAnimationIndex: number; + sequenceTransitionIndex: number; + hasAnimation: boolean; + hasTransition: boolean; + nonDefaults: Set; +}; +type TargetsMap = Map; const LIST_PROPERTY_NAMES_MOTION: Record = { animation: 'animation', @@ -251,21 +267,7 @@ function parseEffect( effect: ResolvedEffect, configConditions: Record, trigger: TriggerVariant, - targetsMap: Map; - }>, + targetsMap: TargetsMap, visited: Set, keyframesMap: Map, useFirstChild: boolean = true, @@ -349,21 +351,7 @@ function parseSequence( sequence: ResolvedSequence, configConditions: Record, trigger: TriggerVariant, - targetsMap: Map; - }>, + targetsMap: TargetsMap, visited: Set, keyframesMap: Map, useFirstChild: boolean = true, @@ -422,21 +410,7 @@ function parseInteraction( config: InteractConfig, interaction: Interaction, interactionIdx: number, - targetsMap: Map; - }>, + targetsMap: TargetsMap, keyframesMap: Map, useFirstChild: boolean = true, plugins?: InteractPluginStyles, @@ -542,21 +516,7 @@ export function _generate( } { const { useFirstChild, plugins } = normalizeGenerateOptions(options); - const targetsMap = new Map; - }>(); + const targetsMap: TargetsMap = new Map(); const keyframes = new Map(); const cssRules = config.interactions.flatMap((interaction, interactionIdx) => From 3f3f40d7ac324fdf034443cc19fce732ae916d25 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 04:09:18 +0300 Subject: [PATCH 5/8] refac --- packages/interact/src/core/css.ts | 367 +++++++++++------------- packages/interact/src/core/cssUtils.ts | 52 ++-- packages/interact/test/cssUtils.spec.ts | 53 +++- 3 files changed, 252 insertions(+), 220 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index d697d05a..8625171b 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -30,7 +30,10 @@ import { buildAtPropertyRules, getCustomPropName, buildSequenceListsRule, + LIST_KINDS, + listKind, } from './cssUtils'; +import type { ListKind, ListSlots } from './cssUtils'; import { effectToAnimationOptions } from '../handlers/utilities'; import { getCSSAnimation, MotionKeyframeEffect, TriggerVariant } from '@wix/motion'; @@ -43,23 +46,99 @@ export const DEFAULT_INITIAL = [ ]; type AnimationPropertyName = (typeof LIST_ANIMATION_PROPERTY_NAMES)[number]; + +type ListCounters = ListSlots & { + slotsInInteraction: number; + touched: boolean; +}; type TargetContext = { - key: string; - childSelector?: string; - animationIndex: number; - transitionIndex: number; - usedAnimationSlots: number; - usedTransitionSlots: number; - reservedAnimationSlots: number; - reservedTransitionSlots: number; - sequenceAnimationIndex: number; - sequenceTransitionIndex: number; - hasAnimation: boolean; - hasTransition: boolean; - nonDefaults: Set; + key: string; + childSelector?: string; + assigned: Set; + animation: ListCounters; + transition: ListCounters; }; type TargetsMap = Map; +type GenerateContext = { + config: InteractConfig; + configConditions: Record; + targetsMap: TargetsMap; + keyframesMap: Map; + useFirstChild: boolean; + plugins?: InteractPluginStyles; +}; + +function createTargetContext(key: string, childSelector?: string): TargetContext { + const createListCounters = (): ListCounters => ({ + listIndex: 0, + slotCursor: 0, + slotsInInteraction: 0, + slotsInSequence: 0, + touched: false, + }); + + return { + key, + childSelector, + assigned: new Set(), + animation: createListCounters(), + transition: createListCounters(), + }; +} + +function getCustomProps( + target: TargetContext, + inSequence: boolean, +): Record { + return Object.fromEntries( + LIST_PROPERTY_NAMES.map((name) => { + const { listIndex, slotCursor, slotsInSequence } = target[listKind(name)]; + return [ + name, + inSequence + ? getCustomPropName(name, slotCursor + slotsInSequence, true) + : getCustomPropName(name, listIndex), + ]; + }), + ) as Record; +} + +function endEffect( + target: TargetContext, + wrote: Record, + inSequence: boolean, +): void { + LIST_KINDS.forEach((kind) => { + if (!wrote[kind]) { + return; + } + + target[kind].touched = true; + if (inSequence) { + target[kind].slotsInSequence += 1; + } + }); +} + +function endSequence(target: TargetContext): void { + LIST_KINDS.forEach((kind) => { + const counters = target[kind]; + counters.slotsInInteraction = Math.max(counters.slotsInInteraction, counters.slotsInSequence); + counters.slotsInSequence = 0; + }); +} + +function endInteraction(target: TargetContext): void { + LIST_KINDS.forEach((kind) => { + const counters = target[kind]; + counters.listIndex += counters.touched ? 1 : 0; + counters.slotCursor += counters.slotsInInteraction; + counters.slotsInInteraction = 0; + counters.touched = false; + }); +} + const LIST_PROPERTY_NAMES_MOTION: Record = { animation: 'animation', 'animation-composition': 'composition', @@ -68,19 +147,18 @@ const LIST_PROPERTY_NAMES_MOTION: Record = { }; function triggerToCSS( + ctx: GenerateContext, interaction: Interaction, - configConditions: Record, triggerId: string, - useFirstChild: boolean = true, ): CSSRuleData { const { key, conditions } = interaction; - const media = getFullPredicateByType(conditions, configConditions, 'media'); - const selectorCondition = getSelectorCondition(conditions, configConditions); + const media = getFullPredicateByType(conditions, ctx.configConditions, 'media'); + const selectorCondition = getSelectorCondition(conditions, ctx.configConditions); const childSelector = getSelector(interaction, { asCombinator: true, - useFirstChild, + useFirstChild: ctx.useFirstChild, addItemFilter: true, }); @@ -124,18 +202,19 @@ function collectFieldPluginStyles( } function effectToCSS( + ctx: GenerateContext, effect: ResolvedEffect, - configConditions: Record, + target: TargetContext, trigger: TriggerVariant, customProps: Record, - nonDefaults: Set, - childSelector?: string, - plugins?: InteractPluginStyles, sequence?: ResolvedSequence, ): { rules: CSSRuleData[]; keyframes: MotionKeyframeEffect[]; + wrote: Record; } { + const { assigned, childSelector } = target; + const wrote: Record = { animation: false, transition: false }; const { key, effectId, @@ -147,8 +226,8 @@ function effectToCSS( initial, } = effect; - const media = getFullPredicateByType(conditions, configConditions, 'media'); - const selectorCondition = getSelectorCondition(conditions, configConditions); + const media = getFullPredicateByType(conditions, ctx.configConditions, 'media'); + const selectorCondition = getSelectorCondition(conditions, ctx.configConditions); const rules: CSSRuleData[] = [ { @@ -163,8 +242,8 @@ function effectToCSS( const { declarations } = rules[0]; - if (plugins) { - rules.push(...collectFieldPluginStyles('effect', effect, key, media, plugins)); + if (ctx.plugins) { + rules.push(...collectFieldPluginStyles('effect', effect, key, media, ctx.plugins)); } if (namedEffect || keyframeEffect) { @@ -200,9 +279,10 @@ function effectToCSS( }) .join(', '), })).filter(({ _listPropertyName, name, value }) => - value !== LIST_PROPERTY_FALLBACKS[_listPropertyName] || nonDefaults.has(name) + value !== LIST_PROPERTY_FALLBACKS[_listPropertyName] || assigned.has(name) ); - animationDeclarations.forEach(({ name }) => nonDefaults.add(name)); + animationDeclarations.forEach(({ name }) => assigned.add(name)); + wrote.animation = animationDeclarations.length > 0; if (initial) { // declare animation custom properties with initial dependent on data-motion-enter @@ -231,12 +311,13 @@ function effectToCSS( const transitions = transitionEffectToTransitionsList(effect); // declaring transition custom property - if (transitions.length || nonDefaults.has(customProps.transition)) { + if (transitions.length || assigned.has(customProps.transition)) { declarations.push({ name: customProps.transition, value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, }); - nonDefaults.add(customProps.transition); + assigned.add(customProps.transition); + wrote.transition = true; } // adding state rule @@ -252,7 +333,7 @@ function effectToCSS( // setting off animation custom properties declarations.push( ...LIST_ANIMATION_PROPERTY_NAMES.filter((propertyName) => - nonDefaults.has(customProps[propertyName]) + assigned.has(customProps[propertyName]) ).map((propertyName) => ({ name: customProps[propertyName], value: LIST_PROPERTY_FALLBACKS[propertyName], @@ -260,171 +341,97 @@ function effectToCSS( ); } - return { rules: rules.filter((r) => r.declarations.length), keyframes }; + return { rules: rules.filter((r) => r.declarations.length), keyframes, wrote }; } function parseEffect( + ctx: GenerateContext, effect: ResolvedEffect, - configConditions: Record, trigger: TriggerVariant, - targetsMap: TargetsMap, visited: Set, - keyframesMap: Map, - useFirstChild: boolean = true, - plugins?: InteractPluginStyles, sequence?: ResolvedSequence, ): CSSRuleData[] { - const { key } = effect; const targetHash = getElementHash(effect); - const childSelector = getSelector(effect, { - asCombinator: true, - useFirstChild, - addItemFilter: true, - }); - - const current = targetsMap.get(targetHash) || { - key, - childSelector, - animationIndex: 0, - transitionIndex: 0, - usedAnimationSlots: 0, - usedTransitionSlots: 0, - reservedAnimationSlots: 0, - reservedTransitionSlots: 0, - sequenceAnimationIndex: 0, - sequenceTransitionIndex: 0, - hasAnimation: false, - hasTransition: false, - nonDefaults: new Set(), - }; + const current = + ctx.targetsMap.get(targetHash) || + createTargetContext( + effect.key, + getSelector(effect, { + asCombinator: true, + useFirstChild: ctx.useFirstChild, + addItemFilter: true, + }), + ); visited.add(targetHash); - const customProps = LIST_PROPERTY_NAMES.reduce( - (acc, name) => { - acc[name] = sequence - ? getCustomPropName( - name, - name === 'transition' - ? current.usedTransitionSlots + current.sequenceTransitionIndex - : current.usedAnimationSlots + current.sequenceAnimationIndex, - true - ) : getCustomPropName( - name, - name === 'transition' - ? current.transitionIndex - : current.animationIndex - ); - return acc; - }, - {} as Record - ); + const inSequence = Boolean(sequence); + const customProps = getCustomProps(current, inSequence); - const { rules, keyframes } = effectToCSS( + const { rules, keyframes, wrote } = effectToCSS( + ctx, effect, - configConditions, + current, trigger, customProps, - current.nonDefaults, - childSelector, - plugins, sequence, ); - keyframes.forEach(({ name, keyframes }) => keyframesMap.set(name, keyframes)); + keyframes.forEach(({ name, keyframes }) => ctx.keyframesMap.set(name, keyframes)); - const hasAnimation = LIST_ANIMATION_PROPERTY_NAMES.some( - (propertyName) => current.nonDefaults.has(customProps[propertyName]) - ); - const hasTransition = current.nonDefaults.has(customProps['transition']); - current.hasAnimation ||= hasAnimation; - current.hasTransition ||= hasTransition; - if (sequence) { - current.sequenceAnimationIndex += hasAnimation ? 1 : 0; - current.sequenceTransitionIndex += hasTransition ? 1 : 0; - } - targetsMap.set(targetHash, current); + endEffect(current, wrote, inSequence); + ctx.targetsMap.set(targetHash, current); return rules; } function parseSequence( + ctx: GenerateContext, sequence: ResolvedSequence, - configConditions: Record, trigger: TriggerVariant, - targetsMap: TargetsMap, visited: Set, - keyframesMap: Map, - useFirstChild: boolean = true, - plugins?: InteractPluginStyles, ): CSSRuleData[] { const cssRules: CSSRuleData[] = []; const localVisited = new Set(); - cssRules.push(...sequence.effects.flatMap((effect) => parseEffect( - effect, - configConditions, - trigger, - targetsMap, - localVisited, - keyframesMap, - useFirstChild, - plugins, - sequence, - ))); + cssRules.push( + ...sequence.effects.flatMap((effect) => + parseEffect(ctx, effect, trigger, localVisited, sequence), + ), + ); const { conditions } = sequence; localVisited.forEach((targetHash) => { visited.add(targetHash); - const current = targetsMap.get(targetHash)!; - - current.reservedAnimationSlots = Math.max(current.reservedAnimationSlots, current.sequenceAnimationIndex); - current.reservedTransitionSlots = Math.max(current.reservedTransitionSlots, current.sequenceTransitionIndex); - const rule = buildSequenceListsRule( - current.sequenceAnimationIndex, - current.sequenceTransitionIndex, - current.animationIndex, - current.transitionIndex, - current.usedAnimationSlots, - current.usedTransitionSlots, - current.key, - current.childSelector, - conditions, - configConditions, - ); + const current = ctx.targetsMap.get(targetHash)!; + + const rule = buildSequenceListsRule(current, conditions, ctx.configConditions); if (rule) { - rule.declarations.forEach(({ name }) => current.nonDefaults.add(name)); + rule.declarations.forEach(({ name }) => current.assigned.add(name)); cssRules.push(rule); } - current.sequenceAnimationIndex = 0; - current.sequenceTransitionIndex = 0; - + endSequence(current); }); return cssRules; } function parseInteraction( - config: InteractConfig, + ctx: GenerateContext, interaction: Interaction, interactionIdx: number, - targetsMap: TargetsMap, - keyframesMap: Map, - useFirstChild: boolean = true, - plugins?: InteractPluginStyles, ): CSSRuleData[] { const { key, conditions, effects = [], sequences = [] } = interaction; - const configConditions = config.conditions || {}; - const cssRules = plugins + const cssRules = ctx.plugins ? collectFieldPluginStyles( 'interaction', interaction, key, - getFullPredicateByType(conditions, configConditions, 'media'), - plugins, + getFullPredicateByType(conditions, ctx.configConditions, 'media'), + ctx.plugins, ) : []; @@ -435,56 +442,34 @@ function parseInteraction( componentId: '', } as TriggerVariant; if (trigger === 'viewProgress') { - cssRules.push(triggerToCSS(interaction, configConditions, motionTrigger.id, useFirstChild)); + cssRules.push(triggerToCSS(ctx, interaction, motionTrigger.id)); } const visited = new Set(); const resolvedEffects = effects .map((effect, effIndex) => - resolveEffectForCSS(effect, interaction, config, `eff-${interactionIdx}-${effIndex}`), + resolveEffectForCSS(effect, interaction, ctx.config, `eff-${interactionIdx}-${effIndex}`), ) .filter((effect) => effect !== null); - cssRules.push(...resolvedEffects.flatMap((effect) => parseEffect( - effect, - configConditions, - motionTrigger, - targetsMap, - visited, - keyframesMap, - useFirstChild, - plugins, - ))); + cssRules.push( + ...resolvedEffects.flatMap((effect) => parseEffect(ctx, effect, motionTrigger, visited)), + ); const resolvedSequences = sequences .map((sequence, seqIndex) => - resolveSequenceForCSS(sequence, interaction, config, `seq-${interactionIdx}-${seqIndex}`), + resolveSequenceForCSS(sequence, interaction, ctx.config, `seq-${interactionIdx}-${seqIndex}`), ) .filter((sequence) => sequence !== null); - cssRules.push(...resolvedSequences.flatMap((sequence) => parseSequence( - sequence, - configConditions, - motionTrigger, - targetsMap, - visited, - keyframesMap, - useFirstChild, - plugins, - ))); - - visited.forEach((targetHash) => { - const current = targetsMap.get(targetHash)!; - current.animationIndex += current.hasAnimation ? 1 : 0; - current.transitionIndex += current.hasTransition ? 1 : 0; - current.usedAnimationSlots += current.reservedAnimationSlots; - current.usedTransitionSlots += current.reservedTransitionSlots; - current.reservedAnimationSlots = 0; - current.reservedTransitionSlots = 0; - current.hasAnimation = false; - current.hasTransition = false; - }); + cssRules.push( + ...resolvedSequences.flatMap((sequence) => + parseSequence(ctx, sequence, motionTrigger, visited), + ), + ); + + visited.forEach((targetHash) => endInteraction(ctx.targetsMap.get(targetHash)!)); return cssRules; } @@ -516,34 +501,32 @@ export function _generate( } { const { useFirstChild, plugins } = normalizeGenerateOptions(options); - const targetsMap: TargetsMap = new Map(); - const keyframes = new Map(); + const ctx: GenerateContext = { + config, + configConditions: config.conditions || {}, + targetsMap: new Map(), + keyframesMap: new Map(), + useFirstChild, + plugins, + }; const cssRules = config.interactions.flatMap((interaction, interactionIdx) => - parseInteraction( - config, - interaction, - interactionIdx, - targetsMap, - keyframes, - useFirstChild, - plugins, - ), + parseInteraction(ctx, interaction, interactionIdx), ); - const targets = [...targetsMap.values()]; + const targets = [...ctx.targetsMap.values()]; - const animationLength = Math.max(...targets.map(({ animationIndex }) => animationIndex)); - const transitionLength = Math.max(...targets.map(({ transitionIndex }) => transitionIndex)); + const animationLength = Math.max(0, ...targets.map(({ animation }) => animation.listIndex)); + const transitionLength = Math.max(0, ...targets.map(({ transition }) => transition.listIndex)); const listsRule = buildListsRule(targets, animationLength, transitionLength); - const animationSlotLength = Math.max(...targets.map(({ usedAnimationSlots }) => usedAnimationSlots)); - const transitionSlotLength = Math.max(...targets.map(({ usedTransitionSlots }) => usedTransitionSlots)); + const animationSlotLength = Math.max(0, ...targets.map(({ animation }) => animation.slotCursor)); + const transitionSlotLength = Math.max(0, ...targets.map(({ transition }) => transition.slotCursor)); const atProperty = buildAtPropertyRules( animationLength, transitionLength, animationSlotLength, transitionSlotLength ); - return { keyframes, atProperty, cssRules, listsRule }; + return { keyframes: ctx.keyframesMap, atProperty, cssRules, listsRule }; } /** * Generates CSS for animations from an InteractConfig. diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index ebb2f0c8..bcdb16e9 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -152,6 +152,18 @@ export const LIST_ANIMATION_PROPERTY_NAMES = [ 'animation-range', ] as const satisfies readonly ListPropertyName[]; export const LIST_PROPERTY_NAMES = [...LIST_ANIMATION_PROPERTY_NAMES, 'transition'] as const satisfies readonly ListPropertyName[]; +export const LIST_KINDS = ['animation', 'transition'] as const; +export type ListKind = (typeof LIST_KINDS)[number]; +export type ListSlots = { + listIndex: number; + slotCursor: number; + slotsInSequence: number; +}; + +export function listKind(name: ListPropertyName): ListKind { + return name === 'transition' ? 'transition' : 'animation'; +} + export const LIST_PROPERTY_FALLBACKS: Record = { transition: '_', animation: 'none', @@ -184,35 +196,35 @@ export function buildAtPropertyRules( } export function buildSequenceListsRule( - animationLength: number, - transitionLength: number, - animationIndex: number, - transitionIndex: number, - animationSlotIndex: number, - transitionSlotIndex: number, - key: string, - childSelector?: string, + target: { + key: string; + childSelector?: string; + animation: ListSlots; + transition: ListSlots; + }, conditions?: string[], configConditions?: Record, ) : CSSRuleData | null { - const propertyNames = [ - ...(animationLength <= 0 ? [] : LIST_ANIMATION_PROPERTY_NAMES), - ...(transitionLength <= 0 ? [] : ['transition']), - ]; + const propertyNames = LIST_PROPERTY_NAMES.filter( + (name) => target[listKind(name)].slotsInSequence > 0, + ); if (propertyNames.length === 0) { return null; } - const declarations = propertyNames.map( - (name) => ({ - name: getCustomPropName(name, name === 'transition' ? transitionIndex : animationIndex), + const declarations = propertyNames.map((name) => { + const { listIndex, slotCursor, slotsInSequence } = target[listKind(name)]; + + return { + name: getCustomPropName(name, listIndex), value: Array.from( - { length: name === 'transition' ? transitionLength : animationLength }, - (_, i) => `var(${getCustomPropName(name, i + (name === 'transition' ? transitionSlotIndex : animationSlotIndex), true)})` - ).join(', ')}), - ); + { length: slotsInSequence }, + (_, i) => `var(${getCustomPropName(name, slotCursor + i, true)})` + ).join(', '), + }; + }); - const rule: CSSRuleData = { key, childSelector, declarations }; + const rule: CSSRuleData = { key: target.key, childSelector: target.childSelector, declarations }; if (conditions) { rule.media = getFullPredicateByType(conditions, configConditions || {}, 'media'); diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts index 5f5c8a09..d910e55e 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -10,6 +10,7 @@ import { buildAtPropertyRules, getCustomPropName, } from '../src/core/cssUtils'; +import type { ListSlots } from '../src/core/cssUtils'; import type { CSSRuleData } from '../src/types/css'; describe('keyframePropertyToCSS', () => { @@ -443,8 +444,25 @@ describe('buildListsRule', () => { }); describe('buildSequenceListsRule', () => { + const target = ({ + key = 'my-el', + childSelector, + animation, + transition, + }: { + key?: string; + childSelector?: string; + animation?: Partial; + transition?: Partial; + } = {}) => ({ + key, + childSelector, + animation: { listIndex: 0, slotCursor: 0, slotsInSequence: 0, ...animation }, + transition: { listIndex: 0, slotCursor: 0, slotsInSequence: 0, ...transition }, + }); + it('should assign the interaction custom property from the sequence slot properties', () => { - const rule = buildSequenceListsRule(2, 0, 0, 0, 0, 0, 'my-el')!; + const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 2 } }))!; expect(rule.key).toBe('my-el'); expect(rule.declarations).toEqual([ @@ -456,7 +474,9 @@ describe('buildSequenceListsRule', () => { }); it('should offset the slot names by the slot index', () => { - const rule = buildSequenceListsRule(2, 0, 1, 0, 3, 0, 'my-el')!; + const rule = buildSequenceListsRule( + target({ animation: { slotsInSequence: 2, listIndex: 1, slotCursor: 3 } }), + )!; expect(rule.declarations[0]).toEqual({ name: '--anm-1', @@ -465,23 +485,40 @@ describe('buildSequenceListsRule', () => { }); it('should include childSelector when present', () => { - const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el', '.target')!; + const rule = buildSequenceListsRule( + target({ childSelector: '.target', animation: { slotsInSequence: 1 } }), + )!; expect(rule.childSelector).toBe('.target'); }); it('should omit animation properties when there are no animations', () => { - const rule = buildSequenceListsRule(0, 1, 0, 0, 0, 0, 'my-el')!; + const rule = buildSequenceListsRule(target({ transition: { slotsInSequence: 1 } }))!; expect(rule.declarations).toEqual([{ name: '--trns-0', value: 'var(--trns-slot-0)' }]); }); + it('should offset transition slots independently of animation slots', () => { + const rule = buildSequenceListsRule( + target({ + animation: { slotsInSequence: 1, listIndex: 2, slotCursor: 4 }, + transition: { slotsInSequence: 2, listIndex: 1, slotCursor: 3 }, + }), + )!; + + expect(rule.declarations[0]).toEqual({ name: '--anm-2', value: 'var(--anm-slot-4)' }); + expect(rule.declarations[4]).toEqual({ + name: '--trns-1', + value: 'var(--trns-slot-3), var(--trns-slot-4)', + }); + }); + it('should return null when there are no list properties', () => { - expect(buildSequenceListsRule(0, 0, 0, 0, 0, 0, 'my-el')).toBeNull(); + expect(buildSequenceListsRule(target())).toBeNull(); }); it('should add media condition when conditions with media type are provided', () => { - const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el', undefined, ['desktop'], { + const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 1 } }), ['desktop'], { desktop: { type: 'media' as const, predicate: 'min-width: 1024px' }, })!; @@ -490,7 +527,7 @@ describe('buildSequenceListsRule', () => { }); it('should add selectorCondition when conditions with selector type are provided', () => { - const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el', undefined, ['visible'], { + const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 1 } }), ['visible'], { visible: { type: 'selector' as const, predicate: '.is-visible' }, })!; @@ -499,7 +536,7 @@ describe('buildSequenceListsRule', () => { }); it('should have no media or selectorCondition when no conditions are given', () => { - const rule = buildSequenceListsRule(1, 0, 0, 0, 0, 0, 'my-el')!; + const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 1 } }))!; expect(rule.media).toBeUndefined(); expect(rule.selectorCondition).toBeUndefined(); From af8e10d329221d6593c86ece96e1d09f386056f0 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 04:24:55 +0300 Subject: [PATCH 6/8] format --- packages/interact/src/core/css.ts | 34 +++++++++------ packages/interact/src/core/cssUtils.ts | 56 +++++++++++++------------ packages/interact/src/core/resolvers.ts | 7 +++- packages/interact/test/css.spec.ts | 8 +++- packages/interact/test/cssUtils.spec.ts | 24 +++++++---- 5 files changed, 77 insertions(+), 52 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 8625171b..8f2bc101 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -271,15 +271,17 @@ function effectToCSS( const animationDeclarations = LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ _listPropertyName: propertyName, name: customProps[propertyName], - value: - cssAnimations - .map((animation) => { - const name = LIST_PROPERTY_NAMES_MOTION[propertyName]; - return (animation as Record)[name] || LIST_PROPERTY_FALLBACKS[propertyName]; - }) - .join(', '), - })).filter(({ _listPropertyName, name, value }) => - value !== LIST_PROPERTY_FALLBACKS[_listPropertyName] || assigned.has(name) + value: cssAnimations + .map((animation) => { + const name = LIST_PROPERTY_NAMES_MOTION[propertyName]; + return ( + (animation as Record)[name] || LIST_PROPERTY_FALLBACKS[propertyName] + ); + }) + .join(', '), + })).filter( + ({ _listPropertyName, name, value }) => + value !== LIST_PROPERTY_FALLBACKS[_listPropertyName] || assigned.has(name), ); animationDeclarations.forEach(({ name }) => assigned.add(name)); wrote.animation = animationDeclarations.length > 0; @@ -333,7 +335,7 @@ function effectToCSS( // setting off animation custom properties declarations.push( ...LIST_ANIMATION_PROPERTY_NAMES.filter((propertyName) => - assigned.has(customProps[propertyName]) + assigned.has(customProps[propertyName]), ).map((propertyName) => ({ name: customProps[propertyName], value: LIST_PROPERTY_FALLBACKS[propertyName], @@ -521,9 +523,15 @@ export function _generate( const listsRule = buildListsRule(targets, animationLength, transitionLength); const animationSlotLength = Math.max(0, ...targets.map(({ animation }) => animation.slotCursor)); - const transitionSlotLength = Math.max(0, ...targets.map(({ transition }) => transition.slotCursor)); + const transitionSlotLength = Math.max( + 0, + ...targets.map(({ transition }) => transition.slotCursor), + ); const atProperty = buildAtPropertyRules( - animationLength, transitionLength, animationSlotLength, transitionSlotLength + animationLength, + transitionLength, + animationSlotLength, + transitionSlotLength, ); return { keyframes: ctx.keyframesMap, atProperty, cssRules, listsRule }; @@ -550,7 +558,7 @@ export function generate(config: InteractConfig, options?: boolean | GenerateOpt ...atProperty, ...[...keyframes.entries()].map(([name, keyframes]) => keyframesToCSS(name, keyframes)), ...cssRules.map(CSSRuleToString), - listsRule + listsRule, ].filter((rule) => rule); return css.join('\n'); diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index bcdb16e9..f3014818 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -1,8 +1,4 @@ -import type { - Condition, - ListPropertyName, - CSSRuleData, -} from '../types'; +import type { Condition, ListPropertyName, CSSRuleData } from '../types'; import { toCSSPropertyName } from '@wix/motion'; import { roundNumber, @@ -151,7 +147,10 @@ export const LIST_ANIMATION_PROPERTY_NAMES = [ 'animation-timeline', 'animation-range', ] as const satisfies readonly ListPropertyName[]; -export const LIST_PROPERTY_NAMES = [...LIST_ANIMATION_PROPERTY_NAMES, 'transition'] as const satisfies readonly ListPropertyName[]; +export const LIST_PROPERTY_NAMES = [ + ...LIST_ANIMATION_PROPERTY_NAMES, + 'transition', +] as const satisfies readonly ListPropertyName[]; export const LIST_KINDS = ['animation', 'transition'] as const; export type ListKind = (typeof LIST_KINDS)[number]; export type ListSlots = { @@ -182,17 +181,19 @@ export function buildAtPropertyRules( transitionLength: number, animationSlotLength: number, transitionSlotLength: number, -) : string[] { +): string[] { return LIST_PROPERTY_NAMES.flatMap((name) => [ ...Array.from( { length: name === 'transition' ? transitionLength : animationLength }, - (_, i) => `@property ${getCustomPropName(name, i)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` + (_, i) => + `@property ${getCustomPropName(name, i)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }`, ), ...Array.from( { length: name === 'transition' ? transitionSlotLength : animationSlotLength }, - (_, i) => `@property ${getCustomPropName(name, i, true)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }` + (_, i) => + `@property ${getCustomPropName(name, i, true)} { syntax: "*"; inherits: false; initial-value: ${LIST_PROPERTY_FALLBACKS[name]}; }`, ), - ]) + ]); } export function buildSequenceListsRule( @@ -204,7 +205,7 @@ export function buildSequenceListsRule( }, conditions?: string[], configConditions?: Record, -) : CSSRuleData | null { +): CSSRuleData | null { const propertyNames = LIST_PROPERTY_NAMES.filter( (name) => target[listKind(name)].slotsInSequence > 0, ); @@ -219,7 +220,7 @@ export function buildSequenceListsRule( name: getCustomPropName(name, listIndex), value: Array.from( { length: slotsInSequence }, - (_, i) => `var(${getCustomPropName(name, slotCursor + i, true)})` + (_, i) => `var(${getCustomPropName(name, slotCursor + i, true)})`, ).join(', '), }; }); @@ -238,7 +239,7 @@ export function buildListsRule( targets: { key: string; childSelector?: string }[], animationLength: number, transitionLength: number, -) : string { +): string { if (targets.length === 0) { return ''; } @@ -251,20 +252,21 @@ export function buildListsRule( return ''; } - const declarations = propertyNames.map( - (name) => ({ - name, - value: Array.from( - { length: name === 'transition' ? transitionLength : animationLength }, - // TODO: maybe add `-intrct` to names? --anm-0 or --trns-0 could collide with user-defined names - (_, i) => `var(${getCustomPropName(name, i)})` - ).join(', ')}), - ); - - const joinedSelector = targets.map( - ({ key, childSelector }) => `[data-interact-key="${key}"]${childSelector ? ` ${childSelector}` : ''}` - ).join(', '); - + const declarations = propertyNames.map((name) => ({ + name, + value: Array.from( + { length: name === 'transition' ? transitionLength : animationLength }, + // TODO: maybe add `-intrct` to names? --anm-0 or --trns-0 could collide with user-defined names + (_, i) => `var(${getCustomPropName(name, i)})`, + ).join(', '), + })); + + const joinedSelector = targets + .map( + ({ key, childSelector }) => + `[data-interact-key="${key}"]${childSelector ? ` ${childSelector}` : ''}`, + ) + .join(', '); return `${joinedSelector} {\n${declarations.map(({ name, value }) => ` ${name}: ${value};`).join('\n')}\n}`; } diff --git a/packages/interact/src/core/resolvers.ts b/packages/interact/src/core/resolvers.ts index cf139882..77d34a94 100644 --- a/packages/interact/src/core/resolvers.ts +++ b/packages/interact/src/core/resolvers.ts @@ -143,7 +143,12 @@ export function resolveSequenceForCSS( } else { effect.conditions.push(...conditions); } - return resolveEffectForCSS({ ...effect, triggerType }, interaction, config, `${sequenceId}-eff-${index}`); + return resolveEffectForCSS( + { ...effect, triggerType }, + interaction, + config, + `${sequenceId}-eff-${index}`, + ); }); // removing unsupported effects and the whole sequence if all are unsupported diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index f80acf5a..b562454d 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1056,10 +1056,14 @@ describe('css._generate', () => { const { cssRules } = _generate(config); expect( - cssRules.some((r) => r.declarations.some((d) => isAnimationProp(d.name) && d.value !== 'none')), + cssRules.some((r) => + r.declarations.some((d) => isAnimationProp(d.name) && d.value !== 'none'), + ), ).toBe(true); expect( - cssRules.some((r) => r.declarations.some((d) => isAnimationProp(d.name) && d.value === 'none')), + cssRules.some((r) => + r.declarations.some((d) => isAnimationProp(d.name) && d.value === 'none'), + ), ).toBe(false); }); diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts index d910e55e..abb1c2a9 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -415,9 +415,7 @@ describe('buildListsRule', () => { 0, ); - expect(rule).toContain( - '[data-interact-key="a"] > :first-child, [data-interact-key="b"] {', - ); + expect(rule).toContain('[data-interact-key="a"] > :first-child, [data-interact-key="b"] {'); }); it('should omit animation properties when there are no animations', () => { @@ -518,18 +516,26 @@ describe('buildSequenceListsRule', () => { }); it('should add media condition when conditions with media type are provided', () => { - const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 1 } }), ['desktop'], { - desktop: { type: 'media' as const, predicate: 'min-width: 1024px' }, - })!; + const rule = buildSequenceListsRule( + target({ animation: { slotsInSequence: 1 } }), + ['desktop'], + { + desktop: { type: 'media' as const, predicate: 'min-width: 1024px' }, + }, + )!; expect(rule.media).toBe('(min-width: 1024px)'); expect(rule.selectorCondition).toBeFalsy(); }); it('should add selectorCondition when conditions with selector type are provided', () => { - const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 1 } }), ['visible'], { - visible: { type: 'selector' as const, predicate: '.is-visible' }, - })!; + const rule = buildSequenceListsRule( + target({ animation: { slotsInSequence: 1 } }), + ['visible'], + { + visible: { type: 'selector' as const, predicate: '.is-visible' }, + }, + )!; expect(rule.selectorCondition).toBe(':is(.is-visible)'); expect(rule.media).toBeFalsy(); From c019d38662dcb0e1ee86920e1f6d4d742c99e5d4 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 04:58:13 +0300 Subject: [PATCH 7/8] single slot in sequence improvement --- packages/interact/src/core/css.ts | 58 ++++++++++-- packages/interact/test/css.spec.ts | 142 +++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 9 deletions(-) diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 8f2bc101..46f31922 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -51,6 +51,8 @@ type ListCounters = ListSlots & { slotsInInteraction: number; touched: boolean; }; +type SlotUsage = Record; +const NO_SLOTS: SlotUsage = { animation: false, transition: false }; type TargetContext = { key: string; childSelector?: string; @@ -89,14 +91,15 @@ function createTargetContext(key: string, childSelector?: string): TargetContext function getCustomProps( target: TargetContext, - inSequence: boolean, + useSlots: SlotUsage, ): Record { return Object.fromEntries( LIST_PROPERTY_NAMES.map((name) => { - const { listIndex, slotCursor, slotsInSequence } = target[listKind(name)]; + const kind = listKind(name); + const { listIndex, slotCursor, slotsInSequence } = target[kind]; return [ name, - inSequence + useSlots[kind] ? getCustomPropName(name, slotCursor + slotsInSequence, true) : getCustomPropName(name, listIndex), ]; @@ -107,7 +110,7 @@ function getCustomProps( function endEffect( target: TargetContext, wrote: Record, - inSequence: boolean, + useSlots: SlotUsage, ): void { LIST_KINDS.forEach((kind) => { if (!wrote[kind]) { @@ -115,12 +118,47 @@ function endEffect( } target[kind].touched = true; - if (inSequence) { + if (useSlots[kind]) { target[kind].slotsInSequence += 1; } }); } +function getEffectListKind(effect: ResolvedEffect): ListKind | null { + const { namedEffect, keyframeEffect, transition, transitionProperties } = effect; + + if (namedEffect || keyframeEffect) { + return 'animation'; + } + if (transition || transitionProperties) { + return 'transition'; + } + return null; +} + +function getSlotUsage(sequence: ResolvedSequence): Map { + const counts = new Map>(); + + sequence.effects.forEach((effect) => { + const kind = getEffectListKind(effect); + if (!kind) { + return; + } + + const targetHash = getElementHash(effect); + const count = counts.get(targetHash) || { animation: 0, transition: 0 }; + count[kind] += 1; + counts.set(targetHash, count); + }); + + return new Map( + [...counts].map(([targetHash, { animation, transition }]) => [ + targetHash, + { animation: animation > 1, transition: transition > 1 }, + ]), + ); +} + function endSequence(target: TargetContext): void { LIST_KINDS.forEach((kind) => { const counters = target[kind]; @@ -352,6 +390,7 @@ function parseEffect( trigger: TriggerVariant, visited: Set, sequence?: ResolvedSequence, + slotUsage?: Map, ): CSSRuleData[] { const targetHash = getElementHash(effect); const current = @@ -366,8 +405,8 @@ function parseEffect( ); visited.add(targetHash); - const inSequence = Boolean(sequence); - const customProps = getCustomProps(current, inSequence); + const useSlots = slotUsage?.get(targetHash) || NO_SLOTS; + const customProps = getCustomProps(current, useSlots); const { rules, keyframes, wrote } = effectToCSS( ctx, @@ -380,7 +419,7 @@ function parseEffect( keyframes.forEach(({ name, keyframes }) => ctx.keyframesMap.set(name, keyframes)); - endEffect(current, wrote, inSequence); + endEffect(current, wrote, useSlots); ctx.targetsMap.set(targetHash, current); return rules; @@ -395,10 +434,11 @@ function parseSequence( const cssRules: CSSRuleData[] = []; const localVisited = new Set(); + const slotUsage = getSlotUsage(sequence); cssRules.push( ...sequence.effects.flatMap((effect) => - parseEffect(ctx, effect, trigger, localVisited, sequence), + parseEffect(ctx, effect, trigger, localVisited, sequence, slotUsage), ), ); diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index b562454d..a73c0eee 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1355,6 +1355,140 @@ describe('css._generate', () => { expect(rangeDecl).toBeDefined(); }); + it('should write the interaction custom property directly when a sequence target takes a single slot', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + effects: [ + { + effectId: 'kf1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }, + ], + }; + + const { cssRules, atProperty } = _generate(config); + + const animDecls = cssRules + .flatMap((r) => r.declarations) + .filter((d) => isAnimationProp(d.name)); + expect(animDecls.map((d) => d.name)).toContain('--anm-0'); + expect(animDecls.some((d) => d.name.includes('-slot-'))).toBe(false); + expect(animDecls.some((d) => String(d.value).includes('var('))).toBe(false); + expect(atProperty.some((rule) => rule.includes('-slot-'))).toBe(false); + }); + + it('should write the transition custom property directly for a single-effect sequence', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + effects: [ + { + effectId: 'trans1', + transition: { + styleProperties: [{ name: 'opacity', value: '1' }], + duration: 500, + }, + }, + ], + }, + ], + }, + ], + }; + + const { cssRules, atProperty } = _generate(config); + + const transDecls = cssRules + .flatMap((r) => r.declarations) + .filter((d) => isTransitionProp(d.name)); + expect(transDecls.map((d) => d.name)).toEqual(['--trns-0']); + expect(String(transDecls[0].value)).toContain('opacity'); + expect(atProperty.some((rule) => rule.includes('-slot-'))).toBe(false); + }); + + it('should only use slots for the targets that repeat within the sequence', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + sequences: [ + { + effects: [ + { + effectId: 'kf1', + key: 'repeated', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + { + effectId: 'kf2', + key: 'repeated', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, + { + effectId: 'kf3', + key: 'single', + duration: 300, + keyframeEffect: { + name: 'anim3', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], + }, + ], + }, + ], + }; + + const { cssRules } = _generate(config); + + const animPropsByKey = (key: string) => + cssRules + .filter((r) => r.key === key) + .flatMap((r) => r.declarations) + .filter((d) => isAnimationProp(d.name)); + + const repeated = animPropsByKey('repeated'); + expect(repeated.filter((d) => d.name === '--anm-slot-0')).toHaveLength(1); + expect(repeated.filter((d) => d.name === '--anm-slot-1')).toHaveLength(1); + expect(repeated.find((d) => d.name === '--anm-0')!.value).toBe( + 'var(--anm-slot-0), var(--anm-slot-1)', + ); + + const single = animPropsByKey('single'); + expect(single.some((d) => d.name.includes('-slot-'))).toBe(false); + expect(single.find((d) => d.name === '--anm-0')).toBeDefined(); + }); + describe('staggered delay', () => { const staggerConfig = ( sequence: Partial[number] = {}, @@ -1470,6 +1604,14 @@ describe('css._generate', () => { keyframes: [{ opacity: '0' }, { opacity: '1' }], }, }, + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, ], }, ], From ee4fa44aa7f08675968f2a47ed2020facb81fa07 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Sun, 6 Sep 2026 05:06:06 +0300 Subject: [PATCH 8/8] indent test fix --- packages/splittext/test/splitText.integration.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/splittext/test/splitText.integration.spec.ts b/packages/splittext/test/splitText.integration.spec.ts index 451090e9..3653f766 100644 --- a/packages/splittext/test/splitText.integration.spec.ts +++ b/packages/splittext/test/splitText.integration.spec.ts @@ -78,7 +78,7 @@ describe('splitText through the Interact plugin bridge (real @wix/splittext)', ( // SSR: the container is hidden until the split marks it ready. const css = generate(config, { plugins: { splitText: splitTextStyle } }); expect(css).toContain( - '[data-interact-key="hero"] .title:not([data-splittext-ready]) {\nvisibility: hidden;\n}', + '[data-interact-key="hero"] .title:not([data-splittext-ready]) {\n visibility: hidden;\n}', ); // Runtime: after the plugin splits, the container carries the marker, so the hide rule