diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 0ce1bb72..46f31922 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,21 @@ 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, + LIST_KINDS, + listKind, +} from './cssUtils'; +import type { ListKind, ListSlots } from './cssUtils'; import { effectToAnimationOptions } from '../handlers/utilities'; import { getCSSAnimation, MotionKeyframeEffect, TriggerVariant } from '@wix/motion'; @@ -35,125 +45,158 @@ 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', - 'animation-timeline': 'animationTimeline', - 'animation-range': 'animationRange', +type ListCounters = ListSlots & { + slotsInInteraction: number; + touched: boolean; +}; +type SlotUsage = Record; +const NO_SLOTS: SlotUsage = { animation: false, transition: false }; +type TargetContext = { + key: string; + childSelector?: string; + assigned: Set; + animation: ListCounters; + transition: ListCounters; }; +type TargetsMap = Map; -const LIST_PROPERTY_FALLBACKS: Record = { - animation: 'none', - 'animation-composition': 'replace', - transition: '_', - 'animation-timeline': 'auto', - 'animation-range': 'normal', +type GenerateContext = { + config: InteractConfig; + configConditions: Record; + targetsMap: TargetsMap; + keyframesMap: Map; + useFirstChild: boolean; + plugins?: InteractPluginStyles; }; -// ----- Map Updaters ----- +function createTargetContext(key: string, childSelector?: string): TargetContext { + const createListCounters = (): ListCounters => ({ + listIndex: 0, + slotCursor: 0, + slotsInInteraction: 0, + slotsInSequence: 0, + touched: false, + }); -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)); - } + return { + key, + childSelector, + assigned: new Set(), + animation: createListCounters(), + transition: createListCounters(), + }; +} + +function getCustomProps( + target: TargetContext, + useSlots: SlotUsage, +): Record { + return Object.fromEntries( + LIST_PROPERTY_NAMES.map((name) => { + const kind = listKind(name); + const { listIndex, slotCursor, slotsInSequence } = target[kind]; + return [ + name, + useSlots[kind] + ? getCustomPropName(name, slotCursor + slotsInSequence, true) + : getCustomPropName(name, listIndex), + ]; + }), + ) as Record; } -function pushToTargetCustomPropsLists( - targetToLists: Map, - targetHash: string, - customProps: ListCustomProps, - usedProperties?: Set, +function endEffect( + target: TargetContext, + wrote: Record, + useSlots: SlotUsage, ): void { - const { key, childSelector } = customProps; - const propertyNames = usedProperties - ? LIST_PROPERTY_NAMES.filter((n) => usedProperties.has(n)) - : LIST_PROPERTY_NAMES; + LIST_KINDS.forEach((kind) => { + if (!wrote[kind]) { + return; + } - 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: [] }; + target[kind].touched = true; + if (useSlots[kind]) { + target[kind].slotsInSequence += 1; } - properties[name]!.varNames.push(customProps[name]); + }); +} + +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 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 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 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 endSequence(target: TargetContext): void { + LIST_KINDS.forEach((kind) => { + const counters = target[kind]; + counters.slotsInInteraction = Math.max(counters.slotsInInteraction, counters.slotsInSequence); + counters.slotsInSequence = 0; + }); } -function generateSequenceCustomProps( - targetHash: string, - interactionIdx: number, - index: number, -): Record { - return buildCustomProps([interactionIdx, index], getUniqueEncodedHash(targetHash)); +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; + }); } -// ----- Parsers ----- +const LIST_PROPERTY_NAMES_MOTION: Record = { + animation: 'animation', + 'animation-composition': 'composition', + 'animation-timeline': 'animationTimeline', + 'animation-range': 'animationRange', +}; 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, }); @@ -162,8 +205,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 +214,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, @@ -205,18 +240,19 @@ function collectFieldPluginStyles( } function effectToCSS( + ctx: GenerateContext, effect: ResolvedEffect, - configConditions: Record, - customProps: ListCustomProps, + target: TargetContext, trigger: TriggerVariant, - childSelector?: string, - plugins?: InteractPluginStyles, + customProps: Record, sequence?: ResolvedSequence, ): { rules: CSSRuleData[]; keyframes: MotionKeyframeEffect[]; - usedProperties: ListPropertyName[]; + wrote: Record; } { + const { assigned, childSelector } = target; + const wrote: Record = { animation: false, transition: false }; const { key, effectId, @@ -228,8 +264,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[] = [ { @@ -244,15 +280,11 @@ function effectToCSS( const { declarations } = rules[0]; - let usedProperties: ListPropertyName[] = []; - - 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) { - usedProperties = [...LIST_ANIMATION_PROPERTY_NAMES]; - const animationOptions = effectToAnimationOptions(effect); const cssAnimations = getCSSAnimation(null, animationOptions, trigger, sequence).filter( (anim) => anim.name, @@ -265,6 +297,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 || {}) @@ -274,15 +307,22 @@ 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]; - }) - .join(', ') || LIST_PROPERTY_FALLBACKS[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), + ); + animationDeclarations.forEach(({ name }) => assigned.add(name)); + wrote.animation = animationDeclarations.length > 0; if (initial) { // declare animation custom properties with initial dependent on data-motion-enter @@ -307,16 +347,18 @@ function effectToCSS( declarations.push(...animationDeclarations); } } else if (transition || transitionProperties) { - usedProperties = ['transition']; - const properties = getStateStyleProperties(effect); const transitions = transitionEffectToTransitionsList(effect); // declaring transition custom property - declarations.push({ - name: customProps.transition, - value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, - }); + if (transitions.length || assigned.has(customProps.transition)) { + declarations.push({ + name: customProps.transition, + value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, + }); + assigned.add(customProps.transition); + wrote.transition = true; + } // adding state rule rules.push({ @@ -330,184 +372,108 @@ function effectToCSS( } else { // setting off animation custom properties declarations.push( - ...LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ + ...LIST_ANIMATION_PROPERTY_NAMES.filter((propertyName) => + assigned.has(customProps[propertyName]), + ).map((propertyName) => ({ name: customProps[propertyName], value: LIST_PROPERTY_FALLBACKS[propertyName], })), ); } - return { rules: rules.filter((r) => r.declarations.length), keyframes, usedProperties }; + return { rules: rules.filter((r) => r.declarations.length), keyframes, wrote }; } function parseEffect( - configConditions: Record, - interactionIdx: number, + ctx: GenerateContext, effect: ResolvedEffect, - targetToCustomProps: Map, - keyframesMap: Map, trigger: TriggerVariant, - useFirstChild: boolean = true, - plugins?: InteractPluginStyles, - sequenceCustomProps?: Record, - precomputedTargetHash?: string, + visited: Set, sequence?: ResolvedSequence, -): { rules: CSSRuleData[]; usedProperties: ListPropertyName[] } { - const { key } = effect; - const targetHash = precomputedTargetHash ?? getElementHash(effect); - const childSelector = getSelector(effect, { - asCombinator: true, - useFirstChild, - addItemFilter: true, - }); - - // get existing custom-property names for coordinated-list for this target and interaction - // or generate them if it is first time this interaction uses this target - const customProps = getInteractionCustomPropsForTarget( - targetHash, - key, - interactionIdx, - targetToCustomProps, - childSelector, - ); + slotUsage?: Map, +): CSSRuleData[] { + const targetHash = getElementHash(effect); + const current = + ctx.targetsMap.get(targetHash) || + createTargetContext( + effect.key, + getSelector(effect, { + asCombinator: true, + useFirstChild: ctx.useFirstChild, + addItemFilter: true, + }), + ); + 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 useSlots = slotUsage?.get(targetHash) || NO_SLOTS; + const customProps = getCustomProps(current, useSlots); - // process effect into css-rules and keyframes - const { rules, keyframes, usedProperties } = effectToCSS( + const { rules, keyframes, wrote } = effectToCSS( + ctx, effect, - configConditions, - localCustomProps, + current, trigger, - childSelector, - plugins, + customProps, sequence, ); - // update keyframes map - keyframes.forEach(({ name, keyframes }) => keyframesMap.set(name, keyframes)); + keyframes.forEach(({ name, keyframes }) => ctx.keyframesMap.set(name, keyframes)); - return { rules, usedProperties }; + endEffect(current, wrote, useSlots); + ctx.targetsMap.set(targetHash, current); + + return rules; } function parseSequence( - configConditions: Record, - interactionIdx: number, + ctx: GenerateContext, sequence: ResolvedSequence, - targetToCustomProps: Map, - keyframesMap: Map, trigger: TriggerVariant, - useFirstChild: boolean = true, - targetUsedProperties?: Map>, - plugins?: InteractPluginStyles, + visited: Set, ): 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 localVisited = new Set(); + const slotUsage = getSlotUsage(sequence); - const index = targetSequenceIndex.get(targetHash) || 0; - targetSequenceIndex.set(targetHash, index + 1); - - const seqCustomProps = generateSequenceCustomProps(targetHash, interactionIdx, index); - - const { rules, usedProperties } = parseEffect( - configConditions, - interactionIdx, - effect, - targetToCustomProps, - keyframesMap, - trigger, - useFirstChild, - plugins, - seqCustomProps, - targetHash, - sequence, - ); - cssRules.push(...rules); + cssRules.push( + ...sequence.effects.flatMap((effect) => + parseEffect(ctx, effect, trigger, localVisited, sequence, slotUsage), + ), + ); - const usedSet = new Set(usedProperties); + const { conditions } = sequence; - pushToTargetCustomPropsLists( - targetToSequenceLists, - targetHash, - { key, childSelector, ...seqCustomProps }, - usedSet, - ); + localVisited.forEach((targetHash) => { + visited.add(targetHash); + const current = ctx.targetsMap.get(targetHash)!; - if (targetUsedProperties) { - accumulateUsedProperties(targetUsedProperties, targetHash, usedProperties); + const rule = buildSequenceListsRule(current, conditions, ctx.configConditions); + if (rule) { + rule.declarations.forEach(({ name }) => current.assigned.add(name)); + cssRules.push(rule); } - } - - const { conditions } = sequence; - - targetToSequenceLists.forEach((lists, targetHash) => { - const customProps = targetToCustomProps.get(targetHash)!; - // for each target add rule with sequence-conditions for the coordinated lists from interactions targeting it - // here we use the interaction's custom-properties to set the lists as values for them instead of - // directly into the actual coordinated-list type property, to provide cascading in the array of sequences - cssRules.push(buildListsRule(lists, customProps, conditions, configConditions)); + endSequence(current); }); return cssRules; } function parseInteraction( - config: InteractConfig, + ctx: GenerateContext, interaction: Interaction, interactionIdx: number, - targetToLists: Map, - keyframesMap: Map, - useFirstChild: boolean = true, - plugins?: InteractPluginStyles, ): CSSRuleData[] { 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 + const cssRules = ctx.plugins ? collectFieldPluginStyles( 'interaction', interaction, key, - getFullPredicateByType(conditions, configConditions, 'media'), - plugins, + getFullPredicateByType(conditions, ctx.configConditions, 'media'), + ctx.plugins, ) : []; @@ -518,58 +484,34 @@ function parseInteraction( componentId: '', } as TriggerVariant; if (trigger === 'viewProgress') { - cssRules.push(triggerToCSS(interaction, configConditions, motionTrigger.id, useFirstChild)); + cssRules.push(triggerToCSS(ctx, interaction, motionTrigger.id)); } - 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, ctx.config, `eff-${interactionIdx}-${effIndex}`), + ) + .filter((effect) => effect !== null); + + 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( - configConditions, - interactionIdx, - sequence, - targetToCustomProps, - keyframesMap, - motionTrigger, - useFirstChild, - targetUsedProperties, - plugins, - ), + parseSequence(ctx, sequence, motionTrigger, visited), ), ); - // 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), - ); - }); + visited.forEach((targetHash) => endInteraction(ctx.targetsMap.get(targetHash)!)); return cssRules; } @@ -595,33 +537,44 @@ 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 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, - targetToLists, - keyframes, - useFirstChild, - plugins, - ), + parseInteraction(ctx, interaction, interactionIdx), ); - // for each target add unconditional rule for the coordinated lists from interactions targeting it - targetToLists.forEach((lists) => { - cssRules.push(buildListsRule(lists)); - }); + const targets = [...ctx.targetsMap.values()]; + + 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); - return { keyframes, cssRules }; + 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: ctx.keyframesMap, atProperty, cssRules, listsRule }; } /** * Generates CSS for animations from an InteractConfig. @@ -639,12 +592,14 @@ 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, + ].filter((rule) => rule); return css.join('\n'); } diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index f1aca41f..f3014818 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -1,10 +1,4 @@ -import type { - Condition, - ListPropertyName, - ListCustomProps, - CSSCoordinatedLists, - CSSRuleData, -} from '../types'; +import type { Condition, ListPropertyName, CSSRuleData } from '../types'; import { toCSSPropertyName } from '@wix/motion'; import { roundNumber, @@ -85,8 +79,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 { @@ -102,9 +96,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 { @@ -141,40 +135,98 @@ 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; } -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_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', + '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.from( + { length: name === 'transition' ? transitionLength : animationLength }, + (_, 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]}; }`, + ), + ]); +} + +export function buildSequenceListsRule( + target: { + key: string; + childSelector?: string; + animation: ListSlots; + transition: ListSlots; + }, conditions?: string[], configConditions?: Record, -): CSSRuleData { - const { key, childSelector, properties } = lists; +): CSSRuleData | null { + const propertyNames = LIST_PROPERTY_NAMES.filter( + (name) => target[listKind(name)].slotsInSequence > 0, + ); + if (propertyNames.length === 0) { + return null; + } - const declarations = Object.entries(properties) - .filter( - (entry: [string, { fallback: string; varNames: string[] }]) => - entry[1] && entry[1].varNames.length, - ) - .map(([name, { fallback, varNames }]) => ({ - name, - value: varNames.map((n) => `var(${n}, ${fallback})`).join(', '), - })); + const declarations = propertyNames.map((name) => { + const { listIndex, slotCursor, slotsInSequence } = target[listKind(name)]; - const rule: CSSRuleData = { key, childSelector, declarations }; + return { + name: getCustomPropName(name, listIndex), + value: Array.from( + { length: slotsInSequence }, + (_, i) => `var(${getCustomPropName(name, slotCursor + i, true)})`, + ).join(', '), + }; + }); - // 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]; - }); - } + const rule: CSSRuleData = { key: target.key, childSelector: target.childSelector, declarations }; - // option to add conditions to the rules if (conditions) { rule.media = getFullPredicateByType(conditions, configConditions || {}, 'media'); rule.selectorCondition = getSelectorCondition(conditions, configConditions || {}); @@ -182,3 +234,39 @@ 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.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 f13760d8..77d34a94 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,18 @@ 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()}`); } diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index 7a56a74c..a73c0eee 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,23 +747,19 @@ 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', () => { + it('should leave timeline and range at their @property defaults for non-viewProgress keyframeEffect', () => { const config: InteractConfig = { effects: {}, interactions: [ @@ -768,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: [ @@ -800,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', () => { @@ -958,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: [ @@ -970,22 +989,82 @@ 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', () => { @@ -1276,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] = {}, @@ -1310,7 +1523,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'); }; @@ -1391,6 +1604,14 @@ describe('css._generate', () => { keyframes: [{ opacity: '0' }, { opacity: '1' }], }, }, + { + effectId: 'kf2', + duration: 300, + keyframeEffect: { + name: 'anim2', + keyframes: [{ opacity: '1' }, { opacity: '0' }], + }, + }, ], }, ], @@ -1449,25 +1670,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 animationDecl = declarations.find((d) => d.name === 'animation'); + expect(animationDecl).toBeDefined(); + expect(animationDecl!.value).toBe('var(--anm-0), var(--anm-1)'); - const timelineDecl = coordListRule!.declarations.find((d) => d.name === 'animation-timeline'); + 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 +1723,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)'); }); }); @@ -1555,7 +1775,16 @@ describe('css._generate', () => { { key: 'el', trigger: 'click', - effects: [{ effectId: 'e1' }], + effects: [ + { + effectId: 'e1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, ], }; @@ -1573,13 +1802,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(); }); @@ -1654,7 +1893,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 +1905,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)'); }); }); @@ -1682,7 +1919,16 @@ describe('css._generate', () => { { key: 'el', trigger: 'click', - effects: [{ effectId: 'e1' }], + effects: [ + { + effectId: 'e1', + duration: 300, + keyframeEffect: { + name: 'anim1', + keyframes: [{ opacity: '0' }, { opacity: '1' }], + }, + }, + ], }, ], }; @@ -1756,7 +2002,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..abb1c2a9 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -6,8 +6,12 @@ import { keyframesToCSS, CSSRuleToString, buildListsRule, + buildSequenceListsRule, + buildAtPropertyRules, + getCustomPropName, } from '../src/core/cssUtils'; -import type { CSSCoordinatedLists, ListCustomProps, CSSRuleData } from '../src/types/css'; +import type { ListSlots } from '../src/core/cssUtils'; +import type { CSSRuleData } from '../src/types/css'; describe('keyframePropertyToCSS', () => { it('should convert cssFloat to float', () => { @@ -150,8 +154,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 +169,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 +185,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 +221,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 +236,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 +250,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 +261,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 +272,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 +282,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 +293,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 +310,240 @@ 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 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'), + ); + }); - 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 append childSelector to the target selector', () => { + const rule = buildListsRule([{ key: 'my-el', childSelector: '> :first-child' }], 1, 0); - const animDecl = rule.declarations.find((d) => d.name === 'animation'); - expect(animDecl?.value).toBe('var(--anim-1, none), var(--anim-2, none)'); + expect(rule).toContain('[data-interact-key="my-el"] > :first-child {'); + }); - const compositionDecl = rule.declarations.find((d) => d.name === 'animation-composition'); - expect(compositionDecl?.value).toBe('var(--comp-1, replace)'); + 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);'); + }); - const transDecl = rule.declarations.find((d) => d.name === 'transition'); - expect(transDecl?.value).toBe('var(--trans-1, _)'); + 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', () => { + 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(target({ animation: { slotsInSequence: 2 } }))!; + + 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( + target({ animation: { slotsInSequence: 2, listIndex: 1, slotCursor: 3 } }), + )!; + + 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( + target({ childSelector: '.target', animation: { slotsInSequence: 1 } }), + )!; + 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(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(target())).toBeNull(); }); it('should add media condition when conditions with media type are provided', () => { - const conditions = ['desktop']; - const configConditions = { - desktop: { type: 'media' as const, predicate: 'min-width: 1024px' }, - }; - const rule = buildListsRule(baseLists, undefined, conditions, configConditions); + 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 conditions = ['visible']; - const configConditions = { - visible: { type: 'selector' as const, predicate: '.is-visible' }, - }; - const rule = buildListsRule(baseLists, undefined, conditions, configConditions); + 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(); }); it('should have no media or selectorCondition when no conditions are given', () => { - const rule = buildListsRule(baseLists); + const rule = buildSequenceListsRule(target({ animation: { slotsInSequence: 1 } }))!; + expect(rule.media).toBeUndefined(); expect(rule.selectorCondition).toBeUndefined(); }); 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