From 6115f62358d7239e9915225ebc164485423774bc Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Mon, 20 Jul 2026 19:40:28 +0300 Subject: [PATCH 01/12] 2nd version + css --- apps/demo/package.json | 1 + apps/demo/src/plugins/splitTextPlugin.ts | 83 ++++++ apps/demo/test/splitText.integration.spec.ts | 113 ++++++++ apps/demo/vitest.config.ts | 8 + packages/interact-validate/README.md | 2 +- .../interact-validate/src/schema/effects.ts | 129 ++++----- .../src/schema/interactions.ts | 44 ++- .../interact-validate/src/schema/plugins.ts | 40 +++ .../interact-validate/test/structural.spec.ts | 82 ++++++ .../test/type-parity.spec.ts | 8 + packages/interact/docs/api/functions.md | 12 +- packages/interact/docs/api/interact-class.md | 35 +++ packages/interact/docs/api/types.md | 89 +++++- packages/interact/docs/guides/README.md | 4 + .../docs/guides/configuration-structure.md | 2 + packages/interact/docs/guides/plugins.md | 223 +++++++++++++++ packages/interact/rules/full-lean.md | 60 +++- packages/interact/rules/plugins.md | 113 ++++++++ packages/interact/rules/validate.md | 1 + packages/interact/src/core/Interact.ts | 19 ++ .../src/core/InteractionController.ts | 20 +- packages/interact/src/core/add.ts | 85 ++++++ packages/interact/src/core/css.ts | 98 ++++++- packages/interact/src/core/cssUtils.ts | 6 +- packages/interact/src/types/config.ts | 3 +- packages/interact/src/types/controller.ts | 5 + packages/interact/src/types/css.ts | 2 +- packages/interact/src/types/effects.ts | 3 +- packages/interact/src/types/external.ts | 12 + packages/interact/src/types/index.ts | 1 + packages/interact/src/types/plugins.ts | 96 +++++++ packages/interact/test/css.spec.ts | 123 ++++++++- packages/interact/test/cssUtils.spec.ts | 6 +- packages/interact/test/plugins.spec.ts | 258 ++++++++++++++++++ packages/splittext/README.md | 31 +++ 35 files changed, 1694 insertions(+), 123 deletions(-) create mode 100644 apps/demo/src/plugins/splitTextPlugin.ts create mode 100644 apps/demo/test/splitText.integration.spec.ts create mode 100644 apps/demo/vitest.config.ts create mode 100644 packages/interact-validate/src/schema/plugins.ts create mode 100644 packages/interact/docs/guides/plugins.md create mode 100644 packages/interact/rules/plugins.md create mode 100644 packages/interact/src/types/plugins.ts create mode 100644 packages/interact/test/plugins.spec.ts diff --git a/apps/demo/package.json b/apps/demo/package.json index 83805966..dba0a4bb 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@wix/interact": "^2.5.2", + "@wix/splittext": "^0.1.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/apps/demo/src/plugins/splitTextPlugin.ts b/apps/demo/src/plugins/splitTextPlugin.ts new file mode 100644 index 00000000..345adcfe --- /dev/null +++ b/apps/demo/src/plugins/splitTextPlugin.ts @@ -0,0 +1,83 @@ +import { splitText, type SplitTextOptions, type SplitTextResult } from '@wix/splittext'; +import type { InteractPlugin, InteractPluginStyleGenerator } from '@wix/interact'; + +/** Config accepted under `$splitText` in an InteractConfig on an interaction or effect. */ +export type SplitTextPluginConfig = { + container: string; + /** + * Hide the container until the split has been applied, to prevent a flash of the un-split text + * before an entrance/scroll animation runs. Emits SSR CSS via {@link splitTextStyle} and is + * revealed once the runtime plugin marks the container ready. + */ + hideUntilReady?: boolean; +} & SplitTextOptions; + +const READY_ATTR = 'data-splittext-ready'; + +/** + * Runtime adapter that lets `@wix/splittext` be driven through an InteractConfig `$splitText` field. + * + * Register once, before `Interact.create()`: + * + * ```ts + * import { Interact } from '@wix/interact'; + * import { splitTextPlugin } from './plugins/splitTextPlugin'; + * Interact.use('splitText', splitTextPlugin); + * ``` + * + * This module is the ONLY place that imports both packages. `@wix/interact` never imports + * `@wix/splittext` and vice-versa — the plugin bridge keeps them fully decoupled. + */ +export const splitTextPlugin: InteractPlugin = (value, { root }) => { + const { container, hideUntilReady, ...options } = value as SplitTextPluginConfig; + + const element = root.querySelector(container); + + if (!element) { + return; + } + + const result: SplitTextResult = splitText(element, options); + + // Reveal the container (see splitTextStyle) now that it holds the individually-animated spans. + if (hideUntilReady) { + element.setAttribute(READY_ATTR, ''); + } + + // Interact runs this on disconnect/teardown, restoring the original text. + return () => { + result.revert(); + element.removeAttribute(READY_ATTR); + }; +}; + +/** + * Build-time (SSR) styling for `$splitText`, passed to `generate()` — NOT the same callback as the + * runtime `splitTextPlugin` above. When `hideUntilReady` is set, hides the container until the + * runtime plugin has split it, preventing a flash of un-split text before the animation. + * + * ```ts + * import { generate } from '@wix/interact'; + * import { splitTextStyle } from './plugins/splitTextPlugin'; + * const css = generate(config, true, { splitText: splitTextStyle }); + * ``` + */ +export const splitTextStyle: InteractPluginStyleGenerator = (value, _) => { + const { container, hideUntilReady } = value as SplitTextPluginConfig; + + if (!hideUntilReady) { + return []; + } + + return [{ + declarations: [{ name: 'visibility', value: 'hidden' }], + selectorSuffix: ` ${container}:not([${READY_ATTR}])`, + }]; +}; + +// Type the `$splitText` value so configs get autocomplete + checking. +declare module '@wix/interact' { + interface InteractPluginConfigMap { + splitText: SplitTextPluginConfig; + } +} diff --git a/apps/demo/test/splitText.integration.spec.ts b/apps/demo/test/splitText.integration.spec.ts new file mode 100644 index 00000000..ecd41fa0 --- /dev/null +++ b/apps/demo/test/splitText.integration.spec.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Interact, add, remove, generate } from '@wix/interact'; +import type { InteractConfig } from '@wix/interact'; +import { splitTextPlugin, splitTextStyle } from '../src/plugins/splitTextPlugin'; + +// End-to-end proof of the plugin bridge with the REAL @wix/splittext: splitText mutates the DOM, +// Interact resolves the generated spans, and disconnect reverts the split. The animation engine +// runs for real but no presets are registered here, so `getAnimation` logs a benign +// "FadeIn not found in registry" — irrelevant to the split/resolve/revert behavior under test. +describe('splitText through the Interact plugin bridge (real @wix/splittext)', () => { + beforeEach(() => { + Interact.use('splitText', splitTextPlugin); + }); + + afterEach(() => { + Interact.destroy(); + }); + + it('splits the container into char spans that the effect selector targets, then reverts', () => { + const element = document.createElement('div'); + element.innerHTML = '

Hi

'; + document.body.appendChild(element); + + const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'hover', + $splitText: { container: '.title', type: 'chars' }, + effects: [ + { + key: 'hero', + selector: '.split-c', + namedEffect: { type: 'FadeIn' } as never, + duration: 300, + }, + ], + }, + ], + }; + + Interact.create(config); + add(element, 'hero'); + + // Real splitText produced char spans inside the container. + const chars = element.querySelectorAll('.split-c'); + expect(chars.length).toBeGreaterThanOrEqual(2); // "H", "i" + + // Teardown reverts: the split spans are gone and the original text is restored. + remove('hero'); + expect(element.querySelectorAll('.split-c').length).toBe(0); + expect(element.querySelector('.title')?.textContent).toContain('Hi'); + + document.body.removeChild(element); + }); + + it('generate() emits SSR FOUC-prevention CSS via splitTextStyle, matched by the runtime marker', () => { + // A `hover` trigger keeps the runtime path off the (jsdom-unsupported) sequence engine; + // the `hideUntilReady` marker is trigger-independent. The SSR rule is emitted for any trigger. + const config: InteractConfig = { + interactions: [ + { + key: 'hero', + trigger: 'hover', + $splitText: { container: '.title', type: 'chars', hideUntilReady: true }, + effects: [ + { key: 'hero', selector: '.split-c', namedEffect: { type: 'FadeIn' } as never, duration: 300 }, + ], + }, + ], + }; + + // SSR: the container is hidden until the split marks it ready. + const css = generate(config, true, { splitText: splitTextStyle }); + expect(css).toContain( + '[data-interact-key="hero"] .title:not([data-splittext-ready]) { visibility: hidden; }', + ); + + // Runtime: after the plugin splits, the container carries the marker, so the hide rule + // stops matching (the generated spans handle their own entrance visibility). + const element = document.createElement('div'); + element.innerHTML = '

Hi

'; + document.body.appendChild(element); + + Interact.create(config); + add(element, 'hero'); + + expect(element.querySelector('.title')?.hasAttribute('data-splittext-ready')).toBe(true); + expect(element.querySelectorAll('.split-c').length).toBeGreaterThanOrEqual(2); + + remove('hero'); + expect(element.querySelector('.title')?.hasAttribute('data-splittext-ready')).toBe(false); + + document.body.removeChild(element); + }); + + it('generate() omits the hide rule when hideUntilReady is not set', () => { + const config: InteractConfig = { + effects: { 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + $splitText: { container: '.title', type: 'chars' }, + sequences: [{ offset: 30, effects: [{ effectId: 'char-fade-up', selector: '.split-c' }] }], + }, + ], + }; + + const css = generate(config, true, { splitText: splitTextStyle }); + expect(css).not.toContain('data-splittext-ready'); + }); +}); diff --git a/apps/demo/vitest.config.ts b/apps/demo/vitest.config.ts new file mode 100644 index 00000000..a660b8b5 --- /dev/null +++ b/apps/demo/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['test/**/*.spec.ts'], + }, +}); diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index 53648fc7..e95fab16 100644 --- a/packages/interact-validate/README.md +++ b/packages/interact-validate/README.md @@ -125,7 +125,7 @@ const ExperienceSchema = z.object({ }); ``` -> `InteractConfigSchema` carries a `.transform()`, so a successful `.parse()` returns the config augmented with an internal `warnings` array (`validateInteractConfig` consumes that for you). `customEffect` and function-valued `offsetEasing` are accepted as opaque functions and not deep-validated. +> `InteractConfigSchema` carries a `.transform()`, so a successful `.parse()` returns the config augmented with an internal `warnings` array (`validateInteractConfig` consumes that for you). `customEffect` and function-valued `offsetEasing` are accepted as opaque functions and not deep-validated. Interactions and effects also accept `$`-prefixed plugin fields (e.g. `$splitText`) — config routed to `Interact.use()` plugins. Their schemas use `.catchall(z.unknown())` + a key check rather than `.strict()`: `$`-prefixed fields are accepted with opaque values, while any non-prefixed unknown key is still rejected as `SCHEMA_UNRECOGNIZED_KEYS`. ## Severity model diff --git a/packages/interact-validate/src/schema/effects.ts b/packages/interact-validate/src/schema/effects.ts index a849f43e..cda3b853 100644 --- a/packages/interact-validate/src/schema/effects.ts +++ b/packages/interact-validate/src/schema/effects.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { Keyframe, RangeOffset } from './primitives'; +import { withPluginFields } from './plugins'; export const StateActionType = z.enum(['add', 'remove', 'toggle', 'clear']); export const TimeTriggerType = z.enum(['once', 'repeat', 'alternate', 'state']); @@ -132,19 +133,19 @@ const EffectBase = { conditions: z.array(z.string().min(1)).optional(), }; -export const StateEffect = TransitionEffectSourceBase.extend({ - ...EffectBase, - stateAction: StateActionType.optional(), -}) - .strict() - .check(checkExactlyOneTransition); -export const StateEffectRef = TransitionEffectSourceBase.extend({ - ...EffectBase, - effectId: z.string().min(1), - stateAction: StateActionType.optional(), -}) - .strict() - .check(checkAtMostOneTransition); +export const StateEffect = withPluginFields( + TransitionEffectSourceBase.extend({ + ...EffectBase, + stateAction: StateActionType.optional(), + }), +).check(checkExactlyOneTransition); +export const StateEffectRef = withPluginFields( + TransitionEffectSourceBase.extend({ + ...EffectBase, + effectId: z.string().min(1), + stateAction: StateActionType.optional(), + }), +).check(checkAtMostOneTransition); const AnimationEffectBase = { ...EffectBase, @@ -167,57 +168,57 @@ const pointerMoveEffectFields = { transitionEasing: z.enum(['linear', 'hardBackOut', 'easeOut', 'elastic', 'bounce']).optional(), }; -export const TimeEffect = EffectSourceBase.extend({ - ...AnimationEffectBase, - iterations: TimeIterations, - duration: z.number().nonnegative(), - delay: z.number().nonnegative().optional(), - triggerType: TimeTriggerType.optional(), -}) - .strict() - .check(checkExactlyOneEffectSource); -export const TimeEffectRef = EffectSourceBase.extend({ - ...AnimationEffectBase, - iterations: TimeIterations, - effectId: z.string().min(1), - duration: z.number().nonnegative().optional(), - delay: z.number().nonnegative().optional(), - triggerType: TimeTriggerType.optional(), -}) - .strict() - .check(checkAtMostOneEffectSource); - -export const ViewProgressEffect = EffectSourceBase.extend({ - ...AnimationEffectBase, - iterations: ScrubIterations, - ...viewProgressEffectFields, -}) - .strict() - .check(checkExactlyOneEffectSource); -export const ViewProgressEffectRef = EffectSourceBase.extend({ - ...AnimationEffectBase, - iterations: ScrubIterations, - effectId: z.string().min(1), - ...viewProgressEffectFields, -}) - .strict() - .check(checkAtMostOneEffectSource); - -export const PointerMoveEffect = EffectSourceBase.extend({ - ...AnimationEffectBase, - iterations: ScrubIterations, - ...pointerMoveEffectFields, -}) - .strict() - .check(checkExactlyOneEffectSource); -export const PointerMoveEffectRef = EffectSourceBase.extend({ - ...AnimationEffectBase, - iterations: ScrubIterations, - effectId: z.string().min(1), - ...pointerMoveEffectFields, -}) - .strict() - .check(checkAtMostOneEffectSource); +export const TimeEffect = withPluginFields( + EffectSourceBase.extend({ + ...AnimationEffectBase, + iterations: TimeIterations, + duration: z.number().nonnegative(), + delay: z.number().nonnegative().optional(), + triggerType: TimeTriggerType.optional(), + }), +).check(checkExactlyOneEffectSource); +export const TimeEffectRef = withPluginFields( + EffectSourceBase.extend({ + ...AnimationEffectBase, + iterations: TimeIterations, + effectId: z.string().min(1), + duration: z.number().nonnegative().optional(), + delay: z.number().nonnegative().optional(), + triggerType: TimeTriggerType.optional(), + }), +).check(checkAtMostOneEffectSource); + +export const ViewProgressEffect = withPluginFields( + EffectSourceBase.extend({ + ...AnimationEffectBase, + iterations: ScrubIterations, + ...viewProgressEffectFields, + }), +).check(checkExactlyOneEffectSource); +export const ViewProgressEffectRef = withPluginFields( + EffectSourceBase.extend({ + ...AnimationEffectBase, + iterations: ScrubIterations, + effectId: z.string().min(1), + ...viewProgressEffectFields, + }), +).check(checkAtMostOneEffectSource); + +export const PointerMoveEffect = withPluginFields( + EffectSourceBase.extend({ + ...AnimationEffectBase, + iterations: ScrubIterations, + ...pointerMoveEffectFields, + }), +).check(checkExactlyOneEffectSource); +export const PointerMoveEffectRef = withPluginFields( + EffectSourceBase.extend({ + ...AnimationEffectBase, + iterations: ScrubIterations, + effectId: z.string().min(1), + ...pointerMoveEffectFields, + }), +).check(checkAtMostOneEffectSource); export const ScrubEffect = z.union([ViewProgressEffect, PointerMoveEffect]); export const ScrubEffectRef = z.union([ViewProgressEffectRef, PointerMoveEffectRef]); diff --git a/packages/interact-validate/src/schema/interactions.ts b/packages/interact-validate/src/schema/interactions.ts index 3631d055..a75a2a6b 100644 --- a/packages/interact-validate/src/schema/interactions.ts +++ b/packages/interact-validate/src/schema/interactions.ts @@ -13,6 +13,7 @@ import { exactlyOne, } from './effects'; import { SequenceConfig, SequenceConfigRef } from './sequences'; +import { withPluginFields } from './plugins'; import type { Path, SemanticIssue } from '../types'; import { walkConfig } from '../walkConfig'; import { collectSemanticWarnings } from '../semantic'; @@ -62,16 +63,15 @@ const InteractionBase = { const hasEffectsOrSequences = (interaction: { effects?: unknown[]; sequences?: unknown[] }) => (interaction.effects?.length ?? 0) > 0 || (interaction.sequences?.length ?? 0) > 0; -export const AnimationEndInteraction = z - .object({ +export const AnimationEndInteraction = withPluginFields( + z.object({ ...InteractionBase, trigger: z.literal('animationEnd'), params: AnimationEndParams, effects: z.array(z.union([TimeEffect, TimeEffectRef])).optional(), sequences: z.array(z.union([SequenceConfig, SequenceConfigRef])).optional(), - }) - .strict() - .superRefine((interaction, ctx) => { + }), +).superRefine((interaction, ctx) => { if (!hasEffectsOrSequences(interaction)) { ctx.addIssue({ code: 'custom', @@ -81,16 +81,15 @@ export const AnimationEndInteraction = z } }); -export const ViewEnterInteraction = z - .object({ +export const ViewEnterInteraction = withPluginFields( + z.object({ ...InteractionBase, trigger: z.literal('viewEnter'), params: ViewEnterParams.optional(), effects: z.array(z.union([TimeEffect, TimeEffectRef])).optional(), sequences: z.array(z.union([SequenceConfig, SequenceConfigRef])).optional(), - }) - .strict() - .superRefine((interaction, ctx) => { + }), +).superRefine((interaction, ctx) => { if (!hasEffectsOrSequences(interaction)) { ctx.addIssue({ code: 'custom', @@ -100,37 +99,36 @@ export const ViewEnterInteraction = z } }); -export const ViewProgressInteraction = z - .object({ +export const ViewProgressInteraction = withPluginFields( + z.object({ ...InteractionBase, trigger: z.literal('viewProgress'), effects: z.array(z.union([ViewProgressEffect, ViewProgressEffectRef])).min(1), - }) - .strict(); + }), +); -export const PointerMoveInteraction = z - .object({ +export const PointerMoveInteraction = withPluginFields( + z.object({ ...InteractionBase, trigger: z.literal('pointerMove'), params: PointerMoveParams.optional(), effects: z.array(z.union([PointerMoveEffect, PointerMoveEffectRef])).min(1), - }) - .strict(); + }), +); export const ScrubInteraction = z.discriminatedUnion('trigger', [ ViewProgressInteraction, PointerMoveInteraction, ]); -export const DiscreteInteraction = z - .object({ +export const DiscreteInteraction = withPluginFields( + z.object({ ...InteractionBase, trigger: z.enum(['hover', 'click', 'activate', 'interest']), effects: z.array(z.union([TimeEffect, TimeEffectRef, StateEffect, StateEffectRef])).optional(), sequences: z.array(z.union([SequenceConfig, SequenceConfigRef])).optional(), - }) - .strict() - .superRefine((interaction, ctx) => { + }), +).superRefine((interaction, ctx) => { if (!hasEffectsOrSequences(interaction)) { ctx.addIssue({ code: 'custom', diff --git a/packages/interact-validate/src/schema/plugins.ts b/packages/interact-validate/src/schema/plugins.ts new file mode 100644 index 00000000..5d438f6a --- /dev/null +++ b/packages/interact-validate/src/schema/plugins.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +/** + * Prefix marking a config field as plugin config (routed to `Interact.use()` plugins at runtime). + * MUST match `PLUGIN_FIELD_PREFIX` in `@wix/interact`. Kept as a local constant so this package + * stays a types-only consumer of `@wix/interact` (no runtime import). + */ +export const PLUGIN_PREFIX = '$'; + +export function isPluginKey(key: string): boolean { + return key.length > PLUGIN_PREFIX.length && key.startsWith(PLUGIN_PREFIX); +} + +/** + * Replacement for `.strict()` that tolerates plugin fields. Keys prefixed with `$` (e.g. + * `$splitText`) are accepted with opaque values (validate never inspects plugin config); every + * other unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS`, preserving typo detection. + */ +export function withPluginFields>(schema: T) { + const knownKeys = new Set(Object.keys(schema.shape)); + + const keyCheck = z.check>((input) => { + for (const key of Object.keys(input.value)) { + if (knownKeys.has(key) || isPluginKey(key)) { + continue; + } + + input.issues.push({ + code: 'custom', + input: input.value, + message: + `Unrecognized key: "${key}". Plugin config must use a "${PLUGIN_PREFIX}"-prefixed ` + + `field (e.g. "${PLUGIN_PREFIX}splitText").`, + params: { domainCode: 'SCHEMA_UNRECOGNIZED_KEYS' }, + }); + } + }); + + return schema.catchall(z.unknown()).check(keyCheck); +} diff --git a/packages/interact-validate/test/structural.spec.ts b/packages/interact-validate/test/structural.spec.ts index 8f66e535..78eb8309 100644 --- a/packages/interact-validate/test/structural.spec.ts +++ b/packages/interact-validate/test/structural.spec.ts @@ -97,6 +97,88 @@ describe('validateStructural', () => { expect(result.errors[0].path).toEqual(['interactions']); }); + it('accepts an interaction-level $-prefixed plugin field without inspecting its value', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + $splitText: { container: '.title', type: 'chars', anything: [1, 2] }, + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + expect(result.ok).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts an effect-level $-prefixed plugin field', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + effects: [ + { + namedEffect: { type: 'FadeIn' }, + duration: 400, + $splitText: { container: '.heading' }, + }, + ], + }, + ], + }); + expect(result.ok).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('accepts a $-prefixed field with any value type (opaque)', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + $anyPlugin: 'a-primitive-is-fine', + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + expect(result.ok).toBe(true); + }); + + it('rejects a non-prefixed unknown key on an interaction (must be $-prefixed)', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + splitText: { container: '.title' }, // missing `$` prefix → unrecognized + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + expect(result.ok).toBe(false); + const err = result.errors.find((e) => e.code === 'SCHEMA_UNRECOGNIZED_KEYS'); + expect(err).toBeDefined(); + expect(err?.path).toEqual(['interactions', 0]); + }); + + it('treats a bare "$" (prefix only) as an unrecognized key', () => { + const result = validateStructural({ + interactions: [ + { + key: 'el', + trigger: 'viewEnter', + $: { container: '.title' }, // prefix with no plugin name + effects: [{ namedEffect: { type: 'FadeIn' }, duration: 400 }], + }, + ], + }); + expect(result.ok).toBe(false); + const err = result.errors.find((e) => e.code === 'SCHEMA_UNRECOGNIZED_KEYS'); + expect(err).toBeDefined(); + }); + it('emits SCHEMA_TOO_SMALL when condition predicate is an empty string', () => { const result = validateStructural({ interactions: [], diff --git a/packages/interact-validate/test/type-parity.spec.ts b/packages/interact-validate/test/type-parity.spec.ts index 8d318ff7..59ac58ba 100644 --- a/packages/interact-validate/test/type-parity.spec.ts +++ b/packages/interact-validate/test/type-parity.spec.ts @@ -51,4 +51,12 @@ describe('schema type parity (drift guard)', () => { Record | undefined >(); }); + + it('interaction/effect schemas accept $-prefixed plugin fields (mirrors PluginFields)', () => { + // Both the zod-inferred type and the canonical type accept an arbitrary `$`-prefixed field. + type InferredInteraction = InferredConfig['interactions'][number]; + type CanonicalInteraction = InteractConfig['interactions'][number]; + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); + }); }); diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index be44558d..4474512f 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -16,7 +16,7 @@ import { add, remove, generate } from '@wix/interact'; | ------------ | -------------------------------------------------------------------------------- | -------------------------- | -------- | | `add()` | Add interactions to an element | `element`, `key?` | `void` | | `remove()` | Remove interactions from an element | `key` | `void` | -| `generate()` | Generate complete CSS for all animations, transitions, and scroll-driven effects | `config`, `useFirstChild?` | `string` | +| `generate()` | Generate complete CSS for all animations, transitions, and scroll-driven effects | `config`, `useFirstChild?`, `plugins?` | `string` | --- @@ -196,14 +196,18 @@ console.log('Interactions removed for hero'); --- -## `generate(config, useFirstChild?)` +## `generate(config, useFirstChild?, plugins?)` Generates a complete CSS string from an `InteractConfig`. The output includes `@keyframes`, animation and transition custom properties, view-timeline declarations, state-selector rules, coordinated-list aggregation, and FOUC-prevention initial rules — everything the browser needs to run the configured animations and transitions natively, without waiting for JavaScript. ### Signature ```typescript -function generate(config: InteractConfig, useFirstChild?: boolean): string; +function generate( + config: InteractConfig, + useFirstChild?: boolean, + plugins?: InteractPluginStyles, +): string; ``` ### Parameters @@ -212,6 +216,8 @@ function generate(config: InteractConfig, useFirstChild?: boolean): string; **`useFirstChild?: boolean`** - When `true` (the default), generated selectors target the first child of each keyed element (e.g. `[data-interact-key="hero"] > :first-child`). This is the correct mode for `` custom elements. Pass `false` when the keyed element itself is the animation target (vanilla JS or React ``). +**`plugins?: InteractPluginStyles`** - Optional map of plugin name → SSR style generator. For every `$` field in the config, the matching generator is called with the field's (opaque) value and a context, and its returned CSS data is appended to the output. Used to emit build-time styling on a plugin's behalf — e.g. hiding pre-split text for FOUC prevention. Like `create()`/`use()`, `generate()` never inspects the field value. See [Plugins → SSR styling](../guides/plugins.md#ssr-styling-foouc-prevention). + ### Returns **`string`** - A CSS string to inject into a `