diff --git a/apps/demo/src/react/App.tsx b/apps/demo/src/react/App.tsx index 3cd09a54..5e8ce3c8 100644 --- a/apps/demo/src/react/App.tsx +++ b/apps/demo/src/react/App.tsx @@ -2,6 +2,7 @@ import { Playground } from './components/Playground'; import { ScrollShowcase } from './components/ScrollShowcase'; import { ResponsiveDemo } from './components/ResponsiveDemo'; import { SelectorConditionDemo } from './components/SelectorConditionDemo'; +import { CSSGenerationDemo } from './components/CSSGenerationDemo'; import { PointerMoveDemo } from './components/PointerMoveDemo'; const heroCopy = [ @@ -35,6 +36,7 @@ function App() { +
diff --git a/apps/demo/src/react/components/CSSGenerationDemo.tsx b/apps/demo/src/react/components/CSSGenerationDemo.tsx new file mode 100644 index 00000000..09fe63e6 --- /dev/null +++ b/apps/demo/src/react/components/CSSGenerationDemo.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import type { InteractConfig } from '@wix/interact/web'; +import { Interact, generateCSS } from '@wix/interact/web'; + +const demoConfig: InteractConfig = { + interactions: [ + { + key: 'css-demo-card-1', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'fade-slide-up', + keyframes: [ + { opacity: 0, transform: 'translateX(-50vw)' }, + { opacity: 1, transform: 'translateX(0)' }, + ], + }, + duration: 600, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + fill: 'backwards', + }, + ], + }, + { + key: 'css-demo-card-2', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'scale-fade-in', + keyframes: [ + { opacity: 0, transform: 'scale(0.9)' }, + { opacity: 1, transform: 'scale(1)' }, + ], + }, + duration: 500, + delay: 150, + easing: 'ease-out', + fill: 'backwards', + initial: { + opacity: '0', + }, + }, + ], + }, + { + key: 'css-demo-card-3', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'blur-reveal', + keyframes: [ + { opacity: 0.5, filter: 'blur(10px)', transform: 'translateX(-20px)' }, + { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' }, + ], + }, + duration: 700, + delay: 300, + easing: 'ease-out', + fill: 'backwards', + initial: false, + }, + ], + }, + ], + effects: {}, +}; + +export const CSSGenerationDemo = () => { + const [showCSS, setShowCSS] = useState(true); + + const generatedCSS = generateCSS(demoConfig); + Interact.create(demoConfig); + + return ( +
+ +
+

CSS Generation Demo

+

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

+
+ +
+ +
+

Card 1 - Fade Slide Up

+

Entrance Animation

+

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

+
+
+ + +
+

Card 2 - Scale Fade In

+

Delayed Start

+

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

+
+
+ + +
+

Card 3 - Blur Reveal

+

Complex Initial State

+

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

+
+
+
+ +
+
+ +
+
+ + {showCSS && ( +
+

Generated CSS Output

+
{generatedCSS || '/* No CSS generated */'}
+
+ )} +
+ ); +}; diff --git a/apps/demo/src/web/App.tsx b/apps/demo/src/web/App.tsx index bf456e85..94eec1fd 100644 --- a/apps/demo/src/web/App.tsx +++ b/apps/demo/src/web/App.tsx @@ -2,6 +2,7 @@ import { Playground } from './components/Playground'; import { ScrollShowcase } from './components/ScrollShowcase'; import { ResponsiveDemo } from './components/ResponsiveDemo'; import { SelectorConditionDemo } from './components/SelectorConditionDemo'; +import { CSSGenerationDemo } from './components/CSSGenerationDemo'; import { PointerMoveDemo } from './components/PointerMoveDemo'; const heroCopy = [ @@ -35,6 +36,7 @@ function App() { +
diff --git a/apps/demo/src/web/components/CSSGenerationDemo.tsx b/apps/demo/src/web/components/CSSGenerationDemo.tsx new file mode 100644 index 00000000..09fe63e6 --- /dev/null +++ b/apps/demo/src/web/components/CSSGenerationDemo.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import type { InteractConfig } from '@wix/interact/web'; +import { Interact, generateCSS } from '@wix/interact/web'; + +const demoConfig: InteractConfig = { + interactions: [ + { + key: 'css-demo-card-1', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'fade-slide-up', + keyframes: [ + { opacity: 0, transform: 'translateX(-50vw)' }, + { opacity: 1, transform: 'translateX(0)' }, + ], + }, + duration: 600, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + fill: 'backwards', + }, + ], + }, + { + key: 'css-demo-card-2', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'scale-fade-in', + keyframes: [ + { opacity: 0, transform: 'scale(0.9)' }, + { opacity: 1, transform: 'scale(1)' }, + ], + }, + duration: 500, + delay: 150, + easing: 'ease-out', + fill: 'backwards', + initial: { + opacity: '0', + }, + }, + ], + }, + { + key: 'css-demo-card-3', + trigger: 'viewEnter', + params: { type: 'once', threshold: 0.5 }, + effects: [ + { + keyframeEffect: { + name: 'blur-reveal', + keyframes: [ + { opacity: 0.5, filter: 'blur(10px)', transform: 'translateX(-20px)' }, + { opacity: 1, filter: 'blur(0)', transform: 'translateX(0)' }, + ], + }, + duration: 700, + delay: 300, + easing: 'ease-out', + fill: 'backwards', + initial: false, + }, + ], + }, + ], + effects: {}, +}; + +export const CSSGenerationDemo = () => { + const [showCSS, setShowCSS] = useState(true); + + const generatedCSS = generateCSS(demoConfig); + Interact.create(demoConfig); + + return ( +
+ +
+

CSS Generation Demo

+

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

+
+ +
+ +
+

Card 1 - Fade Slide Up

+

Entrance Animation

+

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

+
+
+ + +
+

Card 2 - Scale Fade In

+

Delayed Start

+

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

+
+
+ + +
+

Card 3 - Blur Reveal

+

Complex Initial State

+

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

+
+
+
+ +
+
+ +
+
+ + {showCSS && ( +
+

Generated CSS Output

+
{generatedCSS || '/* No CSS generated */'}
+
+ )} +
+ ); +}; diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index ea3dd01d..782f5d4d 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -330,6 +330,352 @@ For the generated CSS to work, the `` must have the `data-inte --- +## `generateCSS(config)` + +Generates complete CSS for time-based animations from an `InteractConfig`. This function is designed for **Server-Side Rendering (SSR)** or **efficient Client-Side Rendering (CSR)** by pre-generating CSS animations that can be rendered in a ` + + +
+
+

Welcome

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

Welcome to Our Site

This content fades in smoothly without flash

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

Welcome to Our Site

+

This content fades in smoothly without flash

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

Welcome

+
+
+
+
+
Feature 1
+
Feature 2
+
Feature 3
+
+
+ + +`; +``` + +**Generated CSS Includes**: + +1. `@keyframes` rules with `from` block containing initial state +2. Animation rules targeting `[data-interact-key]` selectors +3. Media/container query wrappers for conditional animations +4. Transition rules for transition effects + +**Best Practices**: + +1. **Match initial to first keyframe**: Ensures seamless animation start +2. **Use conditions for accessibility**: Wrap in `prefers-reduced-motion` check +3. **Cache the CSS output**: It's deterministic based on config +4. **Inject early**: Place in `` before content renders + +--- + These rules provide comprehensive coverage for ViewEnter trigger interactions in `@wix/interact`, supporting all four behavior types and various intersection observer configurations as outlined in the development plan. diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index 0682fe76..bedbe297 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -1,17 +1,363 @@ -import { InteractConfig } from '../types'; +import type { + InteractConfig, + GetCSSResult, + Effect, + EffectRef, + TimeEffect, + TransitionEffect, + CreateTransitionCSSParams, + Interaction, + Condition, + EffectCSSProps, + MotionCSSAnimationResult, +} from '../types'; +import { + createStateRuleAndCSSTransitions, + applySelectorCondition, + generateId, + isTimeTrigger, + shortestRepeatingPatternLength, + getFullPredicateByType, + getSelectorCondition, +} from '../utils'; +import { getSelector } from './Interact'; +import { keyframesToCSS } from './utilities'; +import { effectToAnimationOptions } from '../handlers/utilities'; +import { getCSSAnimation, MotionKeyframeEffect } from '@wix/motion'; -export function generate(_config: InteractConfig): string { - const css: string[] = [ - `@media (prefers-reduced-motion: no-preference) { - [data-interact-initial="true"] > :first-child:not([data-motion-enter="done"]) { - visibility: hidden; - transform: none; - translate: none; - scale: none; - rotate: none; +const DEFAULT_INITIAL = { + visibility: 'hidden', + transform: 'none', + translate: 'none', + scale: 'none', + rotate: 'none', +}; + +function getTransitionData(effect: Effect & { key: string }, childSelector: string) { + const args: CreateTransitionCSSParams = { + key: effect.key, + effectId: (effect as Effect).effectId!, + transition: (effect as TransitionEffect).transition, + properties: (effect as TransitionEffect).transitionProperties, + childSelector, + }; + return createStateRuleAndCSSTransitions(args); +} + +function getAnimationData(effect: Effect): MotionCSSAnimationResult[] { + const animationOptions = effectToAnimationOptions(effect as TimeEffect); + + const cssAnimations = getCSSAnimation(null, animationOptions); + + return cssAnimations + .filter((anim) => anim.name) + .map((anim) => ({ + animation: anim.animation, + composition: anim.composition || 'replace', + custom: anim.custom || {}, + name: anim.name as string, + keyframes: anim.keyframes, + })); +} + +function resolveEffect( + effectRef: Effect | EffectRef, + effectsMap: Record, + interaction: Interaction, + conditionDefinitions: Record, +): (Effect & { key: string }) | null { + const fullEffect: any = effectRef.effectId + ? { ...effectsMap[effectRef.effectId], ...effectRef } + : { ...effectRef }; + + if ( + fullEffect.namedEffect || + fullEffect.keyframeEffect || + fullEffect.transition || + fullEffect.transitionProperties + ) { + if (!fullEffect.key) { + fullEffect.key = interaction.key; + } + + if (interaction.conditions && interaction.conditions.length) { + fullEffect.conditions = [ + ...new Set(...interaction.conditions, ...(fullEffect.conditions || [])), + ].filter((condition) => conditionDefinitions[condition]); + } + + const { keyframeEffect } = fullEffect; + if (keyframeEffect && !keyframeEffect.name) { + // use effectId only if the keyframes are not overridden by effectRef or effectRef has a unique effectId (no reference) + const canUseEffectId = + (effectRef.effectId && !effectsMap[effectRef.effectId]) || + !(effectRef as TimeEffect & { keyframeEffect: MotionKeyframeEffect }).keyframeEffect; + keyframeEffect.name = canUseEffectId ? effectRef.effectId : generateId(); + } + + fullEffect.initial = + fullEffect.initial === false || interaction.trigger !== 'viewEnter' + ? undefined + : fullEffect.initial || DEFAULT_INITIAL; + + return fullEffect; + } + + return null; +} + +function buildConditionalRule( + selector: string, + propsToApply: Record, + conditions: string[], + configConditions: Record, +) { + const declarations: string[] = []; + for (const [key, val] of Object.entries(propsToApply)) { + if (val !== undefined && val !== null) { + declarations.push(`${key}: ${val};`); + } + } + + const selectorCondition = getSelectorCondition(conditions, configConditions); + const targetSelector = selectorCondition + ? applySelectorCondition(selector, selectorCondition) + : selector; + + let rule = `${targetSelector} {\n${declarations.join('\n')}\n}`; + + ['container' as const, 'media' as const].forEach((type) => { + const predicate = getFullPredicateByType(conditions, configConditions, type); + if (predicate) { + rule = `@${type} ${predicate} { ${rule} }`; + } + }); + + return rule; +} + +function buildAnimationCompositionDeclaration(compositions: CompositeOperation[]) { + const compositionRepeatLength = shortestRepeatingPatternLength(compositions); + let resultCompositions = compositions.slice(0, compositionRepeatLength); + + if (resultCompositions.length === 0) { + return ''; + } + + return `animation-composition: ${resultCompositions.join(', ')};`; +} + +function buildUnconditionalRuleFromCustomProps( + selector: string, + declarationPropName: string, + customPropNames: string[], + fallback: string, + extraDeclarations?: string[], +) { + const declarations: string[] = + extraDeclarations && extraDeclarations.length ? [...extraDeclarations] : []; + + const customProps = customPropNames.map((propName) => `var(${propName}, ${fallback})`); + declarations.push(`${declarationPropName}: ${customProps.join(', ')};`); + + return `${selector} {\n${declarations.join('\n')}\n}`; +} + +function generateTransitions( + selectorTransitionPropsMap: Map, + transitions: string[], + selector: string, + escapedTargetKey: string, + conditions: string[], +) { + if (!selectorTransitionPropsMap.has(selector)) { + selectorTransitionPropsMap.set(selector, []); + } + const transitionPropsArray = selectorTransitionPropsMap.get(selector)!; + + for (const transition of transitions) { + const customPropName = `--trans-def-${escapedTargetKey}-${transitionPropsArray.length}`; + transitionPropsArray.push({ + declaration: transition, + conditions, + customPropName, + }); + } +} + +function generateAnimations( + selectorAnimationPropsMap: Map, + keyframeMap: Map, + animationDataList: MotionCSSAnimationResult[], + initial: Effect['initial'], + selector: string, + escapedTargetKey: string, + conditions: string[], +) { + if (!selectorAnimationPropsMap.has(selector)) { + selectorAnimationPropsMap.set(selector, []); + } + const animationPropsArray = selectorAnimationPropsMap.get(selector)!; + + for (const data of animationDataList) { + const keyframeCSS = keyframesToCSS(data.name, data.keyframes, initial); + if (keyframeCSS) { + keyframeMap.set(data.name, keyframeCSS); + } + + const { animation, composition, custom } = data; + const customPropName = `--anim-def-${escapedTargetKey}-${animationPropsArray.length}`; + + animationPropsArray.push({ + declaration: animation, + composition, + custom, + conditions, + customPropName, + }); + } +} + +function getRulesFromSelectorPropsMap( + selectorPropsMap: Map, + configConditions: Record, + isAnimation: boolean, +) { + const rules: string[] = []; + + for (const [baseSelector, propsArray] of selectorPropsMap) { + propsArray.forEach((props) => { + const { customPropName, declaration, conditions, custom } = props; + + const propsToApply = { + [customPropName]: declaration, + ...(isAnimation ? custom : {}), + }; + + rules.push(buildConditionalRule(baseSelector, propsToApply, conditions, configConditions)); + }); + + const customPropNames = propsArray.map(({ customPropName }) => customPropName); + + const extraDeclarations = []; + if (isAnimation) { + const compositions = propsArray.map(({ composition }) => composition || 'replace'); + const compositionDeclaration = buildAnimationCompositionDeclaration(compositions); + if (compositionDeclaration) { + extraDeclarations.push(buildAnimationCompositionDeclaration(compositions)); + } + } + + rules.push( + buildUnconditionalRuleFromCustomProps( + baseSelector, + isAnimation ? 'animation' : 'transition', + customPropNames, + isAnimation ? 'none' : '_', + extraDeclarations, + ), + ); } -}`, - ]; + return rules; +} + +/** + * Generates CSS for time-based animations from an InteractConfig. + * + * @param config - The interact configuration containing effects and interactions + * @returns GetCSSResult with keyframes and animationRules + */ +export function _generateCSS(config: InteractConfig): GetCSSResult { + const keyframeMap = new Map(); + const selectorTransitionPropsMap = new Map(); + const selectorAnimationPropsMap = new Map(); + const transitionRules: string[] = []; + + const configConditions = config.conditions || {}; + + for (const interaction of config.interactions) { + if (!isTimeTrigger(interaction.trigger)) { + continue; + } + + for (const effectRef of interaction.effects) { + const effect = resolveEffect(effectRef, config.effects, interaction, configConditions); + if (!effect) continue; + + const childSelector = getSelector(effect, { + asCombinator: true, + addItemFilter: true, + useFirstChild: true, + }); + + const escapedKey = CSS.escape(effect.key); + const keyWithNoSpecialChars = effect.key.replace(/[^\w-]/g, ''); + const selector = `[data-interact-key="${escapedKey}"] ${childSelector}`; + const conditions = effect.conditions || []; + + if ( + (effect as TransitionEffect).transition || + (effect as TransitionEffect).transitionProperties + ) { + const { stateRule, transitions } = getTransitionData(effect, childSelector); + transitionRules.push(stateRule); + if (transitions.length === 0) { + continue; + } + + generateTransitions( + selectorTransitionPropsMap, + transitions, + selector, + keyWithNoSpecialChars, + conditions, + ); + } + + if ((effect as any).namedEffect || (effect as any).keyframeEffect) { + const animationDataList = getAnimationData(effect); + if (animationDataList.length === 0) { + continue; + } + + generateAnimations( + selectorAnimationPropsMap, + keyframeMap, + animationDataList, + effect.initial, + selector, + keyWithNoSpecialChars, + conditions, + ); + } + } + } + + transitionRules.push( + ...getRulesFromSelectorPropsMap(selectorTransitionPropsMap, configConditions, false), + ); + const animationRules: string[] = getRulesFromSelectorPropsMap( + selectorAnimationPropsMap, + configConditions, + true, + ); + + return { + keyframes: Array.from(keyframeMap.values()), + animationRules, + transitionRules, + }; +} + +/** + * Generates CSS for animations from an InteractConfig. + * + * @param config - The interact configuration containing effects and interactions + * @returns string containing all of the CSS rules needed for time-based animations + */ +export function generateCSS(config: InteractConfig): string { + const { keyframes, animationRules, transitionRules } = _generateCSS(config); + const css: string[] = [...keyframes, ...animationRules, ...transitionRules]; return css.join('\n'); } diff --git a/packages/interact/src/core/utilities.ts b/packages/interact/src/core/utilities.ts index b521a287..e4d60e1c 100644 --- a/packages/interact/src/core/utilities.ts +++ b/packages/interact/src/core/utilities.ts @@ -1,3 +1,5 @@ +import { roundNumber } from '../utils'; + export function _processKeysForInterpolation(key: string) { return [...key.matchAll(/\[([-\w]+)]/g)].map(([_, _instanceKey]) => _instanceKey); } @@ -13,3 +15,100 @@ export function getInterpolatedKey(template: string, key: string) { }) : template; } + +function interpolateKeyframesOffsets( + keyframes: Keyframe[], + firstFrameOnEpsilon?: boolean, +): Keyframe[] { + if (!keyframes || keyframes.length === 0) return []; + + const result = keyframes.map((kf) => ({ ...kf })); + + // Set first and last if not present + if (result[0].offset === undefined) { + result[0].offset = 0; + } + if (result[result.length - 1].offset === undefined) { + result[result.length - 1].offset = 1; + } + + // Find segments between defined offsets and interpolate + let lastDefinedIndex = 0, + currentOffset = result[lastDefinedIndex].offset as number; + for (let i = 1; i < result.length; i++) { + if (result[i].offset !== undefined) { + const endOffset = result[i].offset as number; + + if (endOffset < currentOffset) { + console.error('Offsets must be monotonically non-decreasing'); + return []; + } else if (endOffset > 1) { + console.error('Offsets must be in the range [0,1]'); + return []; + } + const gap = i - lastDefinedIndex; + + for (let j = lastDefinedIndex + 1; j < i; j++) { + const progress = (j - lastDefinedIndex) / gap; + result[j].offset = currentOffset + (endOffset - currentOffset) * progress; + } + + lastDefinedIndex = i; + currentOffset = endOffset; + } + } + + if (firstFrameOnEpsilon) { + result[0].offset = 0.0001; + } + + return result; +} + +function keyframePropertyToCSS(key: string): string { + if (key === 'cssFloat') { + return 'float'; + } + if (key === 'easing') { + return 'animation-timing-function'; + } + if (key === 'cssOffset') { + return 'offset'; + } + if (key === 'composite') { + return 'animation-composition'; + } + return key.replace(/([A-Z])/g, '-$1').toLowerCase(); +} + +function keyframeObjectToKeyframeCSS(keyframeObj: Keyframe, offsetString: string): string { + const props = Object.entries(keyframeObj) + .filter(([key, value]) => key !== 'offset' && value !== undefined && value !== null) + .map(([key, value]) => { + const cssKey = keyframePropertyToCSS(key); + return `${cssKey}: ${value};`; + }) + .join(' '); + return `${offsetString} { ${props} }`; +} + +export function keyframesToCSS(name: string, keyframes: Keyframe[], initial?: any): string { + if (!keyframes || keyframes.length === 0) return ''; + const interpolated = interpolateKeyframesOffsets(keyframes, !!initial); + + let keyframeBlocks = interpolated + .map((kf) => { + const offset = kf.offset as number; + const percentage = roundNumber(offset * 100); + + return keyframeObjectToKeyframeCSS(kf, `${percentage}%`); + }) + .join(' '); + + if (initial) { + const fromFrame = keyframeObjectToKeyframeCSS(initial, 'from'); + keyframeBlocks = `${fromFrame} ${keyframeBlocks}`; + } + + return `@keyframes ${name} { ${keyframeBlocks} }`; +} diff --git a/packages/interact/src/index.ts b/packages/interact/src/index.ts index bffc5e20..f281ea54 100644 --- a/packages/interact/src/index.ts +++ b/packages/interact/src/index.ts @@ -1,5 +1,5 @@ export { Interact } from './core/Interact'; export { add, remove } from './dom/api'; -export { generate } from './core/css'; +export { generateCSS } from './core/css'; export * from './types'; diff --git a/packages/interact/src/react/index.ts b/packages/interact/src/react/index.ts index 0856e3b4..d8f71bbc 100644 --- a/packages/interact/src/react/index.ts +++ b/packages/interact/src/react/index.ts @@ -2,7 +2,7 @@ export { Interaction } from './Interaction'; export { createInteractRef } from './interactRef'; export { add, remove } from '../dom/api'; -export { generate } from '../core/css'; +export { generateCSS } from '../core/css'; export { Interact } from '../core/Interact'; export type { InteractRef } from './interactRef'; diff --git a/packages/interact/src/types.ts b/packages/interact/src/types.ts index 7bffa724..404a7152 100644 --- a/packages/interact/src/types.ts +++ b/packages/interact/src/types.ts @@ -141,6 +141,7 @@ export type EffectBase = { conditions?: string[]; selector?: string; effectId?: string; + initial?: Record | false; }; export type EffectRef = EffectBase & { effectId: string }; @@ -286,3 +287,28 @@ export type CreateTransitionCSSParams = { selectorCondition?: string; useFirstChild?: boolean; }; + +export type EffectCSSProps = { + declaration: string; + conditions: string[]; + customPropName: string; + composition?: CompositeOperation; + custom?: Keyframe; +}; + +export type MotionCSSAnimationResult = { + animation: string; + composition: CompositeOperation; + custom: Keyframe; + name: string; + keyframes: Keyframe[]; +}; + +export type GetCSSResult = { + /** @keyframes rules for the animations */ + keyframes: string[]; + /** Full animation property rules per element (animation, composition, custom, etc.) */ + animationRules: string[]; + /** Full transition properties rules per element */ + transitionRules: string[]; +}; diff --git a/packages/interact/src/utils.ts b/packages/interact/src/utils.ts index c2271102..70abd4bc 100644 --- a/packages/interact/src/utils.ts +++ b/packages/interact/src/utils.ts @@ -1,12 +1,34 @@ import { getEasing } from '@wix/motion'; -import type { Condition, CreateTransitionCSSParams } from './types'; +import type { TriggerType, Condition, CreateTransitionCSSParams } from './types'; + +export function isTimeTrigger(trigger: TriggerType): boolean { + return !['viewProgress', 'pointerMove'].includes(trigger); +} + +export function roundNumber(num: number, precision = 2): number { + return parseFloat(num.toFixed(precision)); +} + +export function shortestRepeatingPatternLength(values: string[] | number[]): number { + let patternLength = 1; + let index = 1; + while (index < values.length) { + if (values[index] === values[index % patternLength]) { + index++; + } else { + patternLength = Math.max(index - patternLength, patternLength) + 1; + index = patternLength; + } + } + return patternLength; +} /** * Applies a selector condition predicate to a base selector. * - If `&` is in the predicate, replace `&` with the base selector * - If no `&`, assume `&` (append predicate to base selector) */ -function applySelectorCondition(baseSelector: string, predicate: string): string { +export function applySelectorCondition(baseSelector: string, predicate: string): string { if (predicate.includes('&')) { return predicate.replace(/&/g, baseSelector); } @@ -23,14 +45,16 @@ export function generateId() { ); } -export function createTransitionCSS({ - key, +export function createStateRuleAndCSSTransitions({ effectId, transition, properties, childSelector = '> :first-child', selectorCondition, -}: CreateTransitionCSSParams): string[] { +}: CreateTransitionCSSParams): { + stateRule: string; + transitions: string[]; +} { let transitions: string[] = []; if (transition?.styleProperties) { @@ -72,7 +96,6 @@ export function createTransitionCSS({ const styleProperties = properties?.map((property) => `${property.name}: ${property.value};`) || []; - const escapedKey = key.replace(/"/g, "'"); // Build selectors, applying condition if present const stateSelector = `:is(:state(${effectId}), :--${effectId}) ${childSelector}`; @@ -85,14 +108,34 @@ export function createTransitionCSS({ ? applySelectorCondition(dataAttrSelector, selectorCondition) : dataAttrSelector; - const result = [ - `${finalStateSelector}, + const stateRule = `${finalStateSelector}, ${finalDataAttrSelector} { ${styleProperties.join(` `)} - }`, - ]; + }`; + return { stateRule, transitions }; +} + +export function createTransitionCSS({ + key, + effectId, + transition, + properties, + childSelector = '> :first-child', + selectorCondition, +}: CreateTransitionCSSParams): string[] { + const { stateRule, transitions } = createStateRuleAndCSSTransitions({ + key, + effectId, + transition, + properties, + childSelector, + selectorCondition, + }); + const result = [stateRule]; + + const escapedKey = key.replace(/"/g, "'"); if (transitions.length) { const transitionSelector = `[data-interact-key="${escapedKey}"] ${childSelector}`; const finalTransitionSelector = selectorCondition @@ -106,20 +149,28 @@ export function createTransitionCSS({ return result; } -export function getMediaQuery( +export function getFullPredicateByType( conditionNames: string[] | undefined, conditions: Record, + type: 'media' | 'container', ) { const conditionContent = (conditionNames || []) .filter((conditionName) => { - return conditions[conditionName]?.type === 'media' && conditions[conditionName].predicate; + return conditions[conditionName]?.type === type && conditions[conditionName].predicate; }) .map((conditionName) => { return conditions[conditionName].predicate; }) .join(') and ('); - const condition = conditionContent && `(${conditionContent})`; + return conditionContent && `(${conditionContent})`; +} + +export function getMediaQuery( + conditionNames: string[] | undefined, + conditions: Record, +) { + const condition = getFullPredicateByType(conditionNames, conditions, 'media'); const mql = condition && window.matchMedia(condition); return mql; @@ -129,11 +180,12 @@ export function getSelectorCondition( conditionNames: string[] | undefined, conditions: Record, ): string | undefined { - for (const name of conditionNames || []) { - const condition = conditions[name]; - if (condition?.type === 'selector' && condition.predicate) { - return condition.predicate; - } - } - return; + return (conditionNames || []) + .filter((conditionName) => { + return conditions[conditionName]?.type === 'selector' && conditions[conditionName].predicate; + }) + .map((conditionName) => { + return `:is(${conditions[conditionName].predicate})`; + }) + .join(''); } diff --git a/packages/interact/src/web/index.ts b/packages/interact/src/web/index.ts index f0558fd0..1d2a9c99 100644 --- a/packages/interact/src/web/index.ts +++ b/packages/interact/src/web/index.ts @@ -4,7 +4,7 @@ import { Interact } from '../core/Interact'; Interact.defineInteractElement = defineInteractElement; export { add, remove } from '../dom/api'; -export { generate } from '../core/css'; +export { generateCSS } from '../core/css'; export { Interact }; export * from '../types'; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts new file mode 100644 index 00000000..cdab8292 --- /dev/null +++ b/packages/interact/test/css.spec.ts @@ -0,0 +1,748 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import type { InteractConfig, Effect, TimeEffect, TransitionEffect } from '../src/types'; +import type { NamedEffect } from '@wix/motion'; +import { _generateCSS } from '../src/core/css'; +import { getCSSAnimation } from '@wix/motion'; +import { effectToAnimationOptions } from '../src/handlers/utilities'; + +// Mock CSS.escape for jsdom environment +beforeAll(() => { + if (typeof CSS === 'undefined') { + (globalThis as any).CSS = {}; + } + if (typeof CSS.escape !== 'function') { + CSS.escape = (value: string) => value.replace(/([^\w-])/g, '\\$1'); + } +}); + +/** + * _generateCSS Test Suite + * + * Tests CSS generation for time-based animations and transitions. + * - Generates CSS for triggers: viewEnter, animationEnd, hover, click, pageVisible + * - Does NOT generate CSS for scrub triggers: viewProgress, pointerMove + */ +describe('_generateCSS', () => { + // ============================================================================ + // Test Helpers + // ============================================================================ + + /** Escapes special regex characters */ + const escapeRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + /** Creates regex to match a @keyframes rule with the given name */ + const keyframesPattern = (name: string) => + new RegExp(`@keyframes\\s+${escapeRegex(name)}\\s*\\{[^}]*\\}`); + + /** Creates regex to match a CSS property in a rule */ + const propertyPattern = (prop: string, value: string) => + new RegExp(`${escapeRegex(prop)}:\\s*${escapeRegex(value)}`); + + /** Gets animation data from motion library for a given effect */ + const getMotionAnimationData = (effect: TimeEffect) => { + const options = effectToAnimationOptions(effect); + return getCSSAnimation(null, options); + }; + + /** Extracts keyframe names from motion library for a given effect */ + const getKeyframeNames = (effect: TimeEffect): string[] => { + const data = getMotionAnimationData(effect); + return data.map((d) => d.name).filter(Boolean) as string[]; + }; + + /** Extracts animation strings from motion library */ + const getAnimationStrings = (effect: TimeEffect): string[] => { + const data = getMotionAnimationData(effect); + return data.map((d) => d.animation); + }; + + /** Creates a basic config with a single effect and interaction */ + const createConfig = ( + effect: Effect, + options: { + key?: string; + trigger?: InteractConfig['interactions'][0]['trigger']; + effectId?: string; + conditions?: InteractConfig['conditions']; + effectConditions?: string[]; + selector?: string; + listContainer?: string; + listItemSelector?: string; + } = {}, + ): InteractConfig => { + const { + key = 'test-element', + trigger = 'viewEnter', + effectId = 'test-effect', + conditions, + effectConditions, + selector, + listContainer, + listItemSelector, + } = options; + + const effectRef: any = { key, effectId }; + if (effectConditions) effectRef.conditions = effectConditions; + if (selector) effectRef.selector = selector; + if (listContainer) effectRef.listContainer = listContainer; + if (listItemSelector) effectRef.listItemSelector = listItemSelector; + + return { + conditions, + effects: { [effectId]: effect }, + interactions: [ + { + trigger, + key, + listContainer, + listItemSelector, + effects: [effectRef], + }, + ], + }; + }; + + // ============================================================================ + // Common Test Effects + // ============================================================================ + + const fadeInEffect: TimeEffect = { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + }; + + const keyframeEffect: TimeEffect = { + keyframeEffect: { + name: 'custom-slide', + keyframes: [ + { offset: 0, transform: 'translateX(-100px)', opacity: '0' }, + { offset: 1, transform: 'translateX(0)', opacity: '1' }, + ], + }, + duration: 800, + }; + + const transitionEffect: TransitionEffect = { + transition: { + duration: 300, + easing: 'ease-out', + styleProperties: [ + { name: 'opacity', value: '1' }, + { name: 'transform', value: 'scale(1)' }, + ], + }, + }; + + // ============================================================================ + // SUITE 1: Return Structure + // ============================================================================ + describe('return structure', () => { + it('should return an object with keyframes, animationRules, and transitionRules arrays', () => { + const result = _generateCSS({ effects: {}, interactions: [] }); + + expect(result).toHaveProperty('keyframes'); + expect(result).toHaveProperty('animationRules'); + expect(result).toHaveProperty('transitionRules'); + expect(Array.isArray(result.keyframes)).toBe(true); + expect(Array.isArray(result.animationRules)).toBe(true); + expect(Array.isArray(result.transitionRules)).toBe(true); + }); + + it('should return empty arrays for empty config', () => { + const result = _generateCSS({ effects: {}, interactions: [] }); + + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + expect(result.transitionRules).toEqual([]); + }); + }); + + // ============================================================================ + // SUITE 2: Trigger Filtering + // ============================================================================ + describe('trigger filtering', () => { + const timeTriggers = ['viewEnter', 'hover', 'click', 'animationEnd', 'pageVisible'] as const; + const scrubTriggers = ['viewProgress', 'pointerMove'] as const; + + it.each(timeTriggers)('should generate CSS for %s trigger', (trigger) => { + const config = createConfig(fadeInEffect, { trigger }); + const result = _generateCSS(config); + + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + }); + + it.each(scrubTriggers)('should NOT generate CSS for %s trigger', (trigger) => { + const config = createConfig(fadeInEffect, { trigger }); + const result = _generateCSS(config); + + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + }); + }); + + // ============================================================================ + // SUITE 3: Keyframes Generation + // ============================================================================ + describe('keyframes generation', () => { + it('should generate valid @keyframes rule for namedEffect', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + const [expectedName] = getKeyframeNames(fadeInEffect); + + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]).toMatch(keyframesPattern(expectedName)); + expect(result.keyframes[0]).toMatch(/0%\s*\{[^}]*opacity/); + expect(result.keyframes[0]).toMatch(/100%\s*\{[^}]*opacity/); + }); + + it('should generate @keyframes with custom name for keyframeEffect', () => { + const config = createConfig(keyframeEffect); + const result = _generateCSS(config); + + expect(result.keyframes).toHaveLength(1); + expect(result.keyframes[0]).toMatch(keyframesPattern('custom-slide')); + expect(result.keyframes[0]).toMatch(/0%\s*\{[^}]*transform/); + expect(result.keyframes[0]).toMatch(/100%\s*\{[^}]*transform/); + }); + + it('should generate multiple @keyframes for multi-part effects (like ArcIn)', () => { + const arcInEffect: TimeEffect = { + namedEffect: { type: 'ArcIn', direction: 'right' } as NamedEffect, + duration: 1000, + }; + const config = createConfig(arcInEffect); + const result = _generateCSS(config); + const expectedNames = getKeyframeNames(arcInEffect); + + expect(result.keyframes.length).toBe(expectedNames.length); + expectedNames.forEach((name) => { + expect(result.keyframes.some((kf) => kf.includes(`@keyframes ${name}`))).toBe(true); + }); + }); + + it('should deduplicate keyframes when same effect is used multiple times', () => { + const config: InteractConfig = { + effects: { shared: fadeInEffect }, + interactions: [ + { trigger: 'viewEnter', key: 'el-a', effects: [{ key: 'el-a', effectId: 'shared' }] }, + { trigger: 'viewEnter', key: 'el-b', effects: [{ key: 'el-b', effectId: 'shared' }] }, + ], + }; + const result = _generateCSS(config); + const [expectedName] = getKeyframeNames(fadeInEffect); + + const keyframesWithName = result.keyframes.filter((kf) => kf.includes(expectedName)); + expect(keyframesWithName).toHaveLength(1); + }); + + it('should interpolate keyframe offsets when not provided', () => { + const interpolatedEffect: TimeEffect = { + keyframeEffect: { + name: 'interpolated', + keyframes: [{ opacity: '0' }, { opacity: '0.5' }, { opacity: '1' }], + }, + duration: 1000, + }; + const config = createConfig(interpolatedEffect); + const result = _generateCSS(config); + + // Should have 0%, 50%, 100% (evenly distributed) + expect(result.keyframes[0]).toMatch(/0%\s*\{/); + expect(result.keyframes[0]).toMatch(/50%\s*\{/); + expect(result.keyframes[0]).toMatch(/100%\s*\{/); + }); + + it('should NOT generate keyframes for customEffect', () => { + const customEffect = { + customEffect: () => {}, + duration: 1000, + } as Effect; + const config = createConfig(customEffect); + const result = _generateCSS(config); + + expect(result.keyframes).toEqual([]); + }); + }); + + // ============================================================================ + // SUITE 4: Initial State in Keyframes + // ============================================================================ + describe('initial state in keyframes', () => { + it('should include default initial state properties in keyframes', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + + // Default initial includes visibility: hidden + expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*visibility:\s*hidden/); + }); + + it('should use custom initial state when provided', () => { + const effectWithInitial: Effect = { + ...fadeInEffect, + initial: { opacity: '0', transform: 'scale(0.5)' }, + }; + const config = createConfig(effectWithInitial); + const result = _generateCSS(config); + + expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*opacity:\s*0/); + expect(result.keyframes[0]).toMatch(/from\s*\{[^}]*transform:\s*scale\(0\.5\)/); + }); + + it('should NOT include initial when set to false', () => { + const effectWithDisabledInitial: Effect = { + ...fadeInEffect, + initial: false, + }; + const config = createConfig(effectWithDisabledInitial); + const result = _generateCSS(config); + + expect(result.keyframes[0]).not.toMatch(/from\s*\{/); + }); + }); + + // ============================================================================ + // SUITE 5: Animation Rules + // ============================================================================ + describe('animation rules', () => { + it('should generate animation rule with correct selector', () => { + const config = createConfig(fadeInEffect, { key: 'my-element' }); + const result = _generateCSS(config); + + const hasCorrectSelector = result.animationRules.some((rule) => + rule.includes('[data-interact-key="my-element"]'), + ); + expect(hasCorrectSelector).toBe(true); + }); + + it('should use CSS custom properties for animation definition', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + + // Animation rules should use --anim-def-* custom properties + const hasCustomProp = result.animationRules.some((rule) => /--anim-def-\w+/.test(rule)); + expect(hasCustomProp).toBe(true); + }); + + it('should include animation property with var() fallback', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + + const hasAnimationWithVar = result.animationRules.some((rule) => + /animation:\s*var\(--anim-def-[^,]+,\s*none\)/.test(rule), + ); + expect(hasAnimationWithVar).toBe(true); + }); + + it('should include animation string from motion library', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + const [expectedAnimation] = getAnimationStrings(fadeInEffect); + + const hasMotionAnimation = result.animationRules.some((rule) => + rule.includes(expectedAnimation), + ); + expect(hasMotionAnimation).toBe(true); + }); + + it('should include animation-composition property', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + + const hasComposition = result.animationRules.some((rule) => + rule.includes('animation-composition:'), + ); + expect(hasComposition).toBe(true); + }); + + it('should generate multiple animation custom props for multiple effects on same element', () => { + const config: InteractConfig = { + effects: { + effect1: fadeInEffect, + effect2: keyframeEffect, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'multi-effect', + effects: [ + { key: 'multi-effect', effectId: 'effect1' }, + { key: 'multi-effect', effectId: 'effect2' }, + ], + }, + ], + }; + const result = _generateCSS(config); + + // Should have comma-separated var() calls in animation property + const hasMultipleVars = result.animationRules.some((rule) => + /animation:\s*var\([^)]+\),\s*var\([^)]+\)/.test(rule), + ); + expect(hasMultipleVars).toBe(true); + }); + }); + + // ============================================================================ + // SUITE 6: Selectors + // ============================================================================ + describe('selectors', () => { + it('should escape special characters in key', () => { + const config = createConfig(fadeInEffect, { key: 'element.with:special#chars' }); + const result = _generateCSS(config); + + // CSS.escape handles special chars + const hasEscapedSelector = result.animationRules.some((rule) => + rule.includes('data-interact-key='), + ); + expect(hasEscapedSelector).toBe(true); + }); + + it('should include custom selector when specified', () => { + const config = createConfig(fadeInEffect, { selector: '.child-target' }); + const result = _generateCSS(config); + + const hasCustomSelector = result.animationRules.some((rule) => + rule.includes('.child-target'), + ); + expect(hasCustomSelector).toBe(true); + }); + + it('should include listContainer and listItemSelector in selector', () => { + const config = createConfig(fadeInEffect, { + listContainer: '.items-container', + listItemSelector: '.item', + }); + const result = _generateCSS(config); + + const hasListSelector = result.animationRules.some( + (rule) => rule.includes('.items-container') && rule.includes('.item'), + ); + expect(hasListSelector).toBe(true); + }); + + it('should use default child selector (> :first-child) when no selector specified', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + + const hasDefaultSelector = result.animationRules.some((rule) => + rule.includes('> :first-child'), + ); + expect(hasDefaultSelector).toBe(true); + }); + }); + + // ============================================================================ + // SUITE 7: Conditions - Media Queries + // ============================================================================ + describe('conditions - media queries', () => { + it('should wrap rule in @media when media condition is specified', () => { + const config = createConfig(fadeInEffect, { + conditions: { desktop: { type: 'media', predicate: 'min-width: 1024px' } }, + effectConditions: ['desktop'], + }); + const result = _generateCSS(config); + + const hasMediaQuery = result.animationRules.some((rule) => + /@media\s*\(min-width:\s*1024px\)/.test(rule), + ); + expect(hasMediaQuery).toBe(true); + }); + + it('should combine multiple media conditions with "and"', () => { + const config = createConfig(fadeInEffect, { + conditions: { + desktop: { type: 'media', predicate: 'min-width: 1024px' }, + landscape: { type: 'media', predicate: 'orientation: landscape' }, + }, + effectConditions: ['desktop', 'landscape'], + }); + const result = _generateCSS(config); + + const hasCombinedMedia = result.animationRules.some((rule) => + /@media\s*\([^)]+\)\s*and\s*\([^)]+\)/.test(rule), + ); + expect(hasCombinedMedia).toBe(true); + }); + + it('should NOT wrap in @media when no conditions', () => { + const config = createConfig(fadeInEffect); + const result = _generateCSS(config); + + const hasMediaQuery = result.animationRules.some((rule) => rule.includes('@media')); + expect(hasMediaQuery).toBe(false); + }); + }); + + // ============================================================================ + // SUITE 8: Conditions - Container Queries + // ============================================================================ + describe('conditions - container queries', () => { + it('should wrap rule in @container when container condition is specified', () => { + const config = createConfig(fadeInEffect, { + conditions: { wide: { type: 'container', predicate: 'min-width: 500px' } }, + effectConditions: ['wide'], + }); + const result = _generateCSS(config); + + const hasContainerQuery = result.animationRules.some((rule) => + /@container\s*\(min-width:\s*500px\)/.test(rule), + ); + expect(hasContainerQuery).toBe(true); + }); + }); + + // ============================================================================ + // SUITE 9: Conditions - Selector Conditions + // ============================================================================ + describe('conditions - selector conditions', () => { + it('should apply selector condition to the target selector', () => { + const config = createConfig(fadeInEffect, { + conditions: { active: { type: 'selector', predicate: '.is-active' } }, + effectConditions: ['active'], + }); + const result = _generateCSS(config); + + const hasSelectorCondition = result.animationRules.some((rule) => + rule.includes(':is(.is-active)'), + ); + expect(hasSelectorCondition).toBe(true); + }); + }); + + // ============================================================================ + // SUITE 10: Transitions + // ============================================================================ + describe('transitions', () => { + it('should generate transition rules for transition effects', () => { + const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); + const result = _generateCSS(config); + + expect(result.transitionRules.length).toBeGreaterThan(0); + }); + + it('should generate state rule with :state() and data-interact-effect selectors', () => { + const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); + const result = _generateCSS(config); + + const hasStateSelector = result.transitionRules.some( + (rule) => /:state\(trans-effect\)/.test(rule) || /--trans-effect/.test(rule), + ); + const hasDataAttrSelector = result.transitionRules.some((rule) => + rule.includes('[data-interact-effect~="trans-effect"]'), + ); + + expect(hasStateSelector).toBe(true); + expect(hasDataAttrSelector).toBe(true); + }); + + it('should include style properties in state rule', () => { + const config = createConfig(transitionEffect as Effect, { effectId: 'trans-effect' }); + const result = _generateCSS(config); + + const hasOpacity = result.transitionRules.some((rule) => + propertyPattern('opacity', '1').test(rule), + ); + const hasTransform = result.transitionRules.some((rule) => + propertyPattern('transform', 'scale(1)').test(rule), + ); + + expect(hasOpacity).toBe(true); + expect(hasTransform).toBe(true); + }); + + it('should use CSS custom properties for transition definition', () => { + const config = createConfig(transitionEffect as Effect); + const result = _generateCSS(config); + + const hasTransitionCustomProp = result.transitionRules.some((rule) => + /--trans-def-\w+/.test(rule), + ); + expect(hasTransitionCustomProp).toBe(true); + }); + + it('should include transition property with var() fallback', () => { + const config = createConfig(transitionEffect as Effect); + const result = _generateCSS(config); + + const hasTransitionWithVar = result.transitionRules.some((rule) => + /transition:\s*var\(--trans-def-[^,]+,\s*_\)/.test(rule), + ); + expect(hasTransitionWithVar).toBe(true); + }); + + it('should NOT generate animation rules for transition-only effects', () => { + const config = createConfig(transitionEffect as Effect); + const result = _generateCSS(config); + + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + }); + }); + + // ============================================================================ + // SUITE 11: TransitionProperties (alternative syntax) + // ============================================================================ + describe('transitionProperties', () => { + it('should support transitionProperties when used with transition', () => { + // Note: transitionProperties alone doesn't work; it needs transition to be present + const effect: TransitionEffect = { + transition: { + duration: 300, + styleProperties: [{ name: 'opacity', value: '1' }], + }, + transitionProperties: [ + { name: 'transform', value: 'translateY(0)', duration: 500, delay: 100 }, + ], + }; + const config = createConfig(effect as Effect, { effectId: 'prop-trans' }); + const result = _generateCSS(config); + + expect(result.transitionRules.length).toBeGreaterThan(0); + + const hasOpacity = result.transitionRules.some((rule) => rule.includes('opacity')); + expect(hasOpacity).toBe(true); + }); + }); + + // ============================================================================ + // SUITE 12: Effect Options + // ============================================================================ + describe('effect options', () => { + it('should handle all TimeEffect options correctly', () => { + const fullOptionsEffect: TimeEffect = { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 1000, + delay: 200, + easing: 'ease-in-out', + iterations: 3, + alternate: true, + fill: 'both', + reversed: true, + }; + const config = createConfig(fullOptionsEffect); + const result = _generateCSS(config); + const [expectedAnimation] = getAnimationStrings(fullOptionsEffect); + + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + + // Animation string from motion should be in the rules + const hasExpectedAnimation = result.animationRules.some((rule) => + rule.includes(expectedAnimation), + ); + expect(hasExpectedAnimation).toBe(true); + }); + + it('should handle infinite iterations (iterations: 0)', () => { + const infiniteEffect: TimeEffect = { + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 500, + iterations: 0, + }; + const config = createConfig(infiniteEffect); + const result = _generateCSS(config); + + const hasInfinite = result.animationRules.some((rule) => rule.includes('infinite')); + expect(hasInfinite).toBe(true); + }); + }); + + // ============================================================================ + // SUITE 13: Inline Effects + // ============================================================================ + describe('inline effects', () => { + it('should support inline effect definition without effectId reference', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + trigger: 'viewEnter', + key: 'inline-element', + effects: [ + { + key: 'inline-element', + namedEffect: { type: 'FadeIn' } as NamedEffect, + duration: 400, + } as Effect, + ], + }, + ], + }; + const result = _generateCSS(config); + + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + }); + }); + + // ============================================================================ + // SUITE 14: Edge Cases + // ============================================================================ + describe('edge cases', () => { + it('should skip effects without namedEffect, keyframeEffect, or transition', () => { + const invalidEffect = { duration: 500 } as TimeEffect; + const config = createConfig(invalidEffect); + const result = _generateCSS(config); + + expect(result.keyframes).toEqual([]); + expect(result.animationRules).toEqual([]); + expect(result.transitionRules).toEqual([]); + }); + + it('should skip condition that does not exist in config.conditions', () => { + const config = createConfig(fadeInEffect, { + conditions: { existing: { type: 'media', predicate: 'min-width: 800px' } }, + effectConditions: ['nonexistent', 'existing'], + }); + const result = _generateCSS(config); + + // Should still generate CSS (with existing condition) + expect(result.animationRules.length).toBeGreaterThan(0); + + // Should have the existing media query + const hasExistingMedia = result.animationRules.some((rule) => + rule.includes('min-width: 800px'), + ); + expect(hasExistingMedia).toBe(true); + }); + + it('should handle empty keyframes array', () => { + const emptyKeyframesEffect: TimeEffect = { + keyframeEffect: { + name: 'empty-keyframes', + keyframes: [], + }, + duration: 500, + }; + const config = createConfig(emptyKeyframesEffect); + const result = _generateCSS(config); + + // Empty keyframes should result in empty or no CSS + expect(result.keyframes).toEqual([]); + }); + + it('should handle mixed animation and transition effects in same interaction', () => { + const config: InteractConfig = { + effects: { + anim: fadeInEffect, + trans: transitionEffect as Effect, + }, + interactions: [ + { + trigger: 'viewEnter', + key: 'mixed', + effects: [ + { key: 'mixed', effectId: 'anim' }, + { key: 'mixed', effectId: 'trans' }, + ], + }, + ], + }; + const result = _generateCSS(config); + + expect(result.keyframes.length).toBeGreaterThan(0); + expect(result.animationRules.length).toBeGreaterThan(0); + expect(result.transitionRules.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/interact/tsconfig.build.json b/packages/interact/tsconfig.build.json index 8ff2a8a2..a03597ac 100644 --- a/packages/interact/tsconfig.build.json +++ b/packages/interact/tsconfig.build.json @@ -11,5 +11,10 @@ "noUnusedLocals": false, "noUnusedParameters": false }, - "include": ["src"] + "include": ["src"], + "references": [ + { + "path": "../motion/tsconfig.build.json" + } + ] } diff --git a/packages/motion/.gitignore b/packages/motion/.gitignore index 1d99561b..24142a1b 100644 --- a/packages/motion/.gitignore +++ b/packages/motion/.gitignore @@ -12,3 +12,4 @@ test/e2e/screenshots .env*.local storybook-static .vscode +*.d.ts* \ No newline at end of file