diff --git a/.github/workflows/release-splittext.yml b/.github/workflows/release-splittext.yml index 8857633d..9316e9bb 100644 --- a/.github/workflows/release-splittext.yml +++ b/.github/workflows/release-splittext.yml @@ -80,6 +80,14 @@ jobs: - name: Build splittext package run: yarn workspace @wix/splittext build + # @wix/interact is a devDependency used only by the plugin-bridge integration test — + # splittext itself neither builds nor runs against it (note the build step above runs first). + - name: Build motion package (test-only dependency of @wix/interact) + run: yarn workspace @wix/motion build + + - name: Build interact package (test-only dependency) + run: yarn workspace @wix/interact build + - name: Test splittext package run: yarn workspace @wix/splittext test diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef509c3..32c2b7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## @wix/splittext +### [0.2.0] - unreleased + +#### Added + +- `@wix/splittext/plugin` entry points: `splitTextPlugin` for `Interact.use()`, and `splitTextStyle` for `generate()` (#275) +- `hideUntilReady`: `splitTextStyle` hides the container until the runtime split sets `data-splittext-ready` (#275) + ### [0.1.2] - 2026-07-14 #### Added @@ -26,6 +33,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## @wix/interact-validate +### [0.2.0] - unreleased + +#### Added + +- Plugin fields: `$`-prefixed keys on interactions and effects are accepted; every other unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS` (#275) + ### [0.1.2] - 2026-07-29 #### Added @@ -63,6 +76,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## @wix/interact +### [2.6.0] - unreleased + +#### Added + +- Generic plugin bridge: `Interact.use(name, plugin)` registers a plugin, and a `$` field on an interaction or effect (#275) +- `generate()` accepts a `plugins` option — a map of plugin name → build-time style generator (#275) + +#### Changed + +- `generate(config, options?)`: the second argument now accepts an options bag — `{ useFirstChild?, plugins? }` — exported as the `GenerateOptions` (#275) + ### [2.5.5] - 2026-07-29 #### Fixed diff --git a/apps/demo/package.json b/apps/demo/package.json index b9e7f247..5264b86d 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -7,11 +7,11 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "tsc --noEmit", - "test": "vitest run" + "lint": "tsc --noEmit" }, "dependencies": { "@wix/interact": "^2.5.5", + "@wix/splittext": "^0.1.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/apps/demo/src/plugins/splitText.ts b/apps/demo/src/plugins/splitText.ts new file mode 100644 index 00000000..9c077dff --- /dev/null +++ b/apps/demo/src/plugins/splitText.ts @@ -0,0 +1,29 @@ +/** + * Demo-side glue for the reusable `@wix/splittext` Interact plugin. + * + * The plugin itself now lives in `@wix/splittext/plugin`, which ships WITHOUT a dependency on + * `@wix/interact` so the two packages stay decoupled. This file is where the demo — the one place + * that depends on BOTH — "resolves the typing": + * + * 1. It binds the package's structurally-typed callbacks to Interact's real `InteractPlugin` / + * `InteractPluginStyleGenerator` contract. The assignments below double as a compile-time + * check that `@wix/splittext/plugin` stays compatible with `@wix/interact`. + * 2. It ties the `$splitText` config field to {@link SplitTextPluginConfig} via declaration + * merging on `InteractPluginConfigMap`, so demo configs get autocomplete + checking. + */ +import type { InteractPlugin, InteractPluginStyleGenerator } from '@wix/interact'; +import { + splitTextPlugin as splitTextPluginImpl, + splitTextStyle as splitTextStyleImpl, + type SplitTextPluginConfig, +} from '@wix/splittext/plugin'; + +export const splitTextPlugin: InteractPlugin = splitTextPluginImpl; +export const splitTextStyle: InteractPluginStyleGenerator = splitTextStyleImpl; +export type { SplitTextPluginConfig }; + +declare module '@wix/interact' { + interface InteractPluginConfigMap { + splitText: SplitTextPluginConfig; + } +} diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json index 49a04821..c0089158 100644 --- a/apps/demo/tsconfig.json +++ b/apps/demo/tsconfig.json @@ -8,7 +8,8 @@ "@/*": ["./src/*"], "@wix/interact/web": ["../../packages/interact/src/web"], "@wix/interact/react": ["../../packages/interact/src/react"], - "@wix/interact": ["../../packages/interact/src/index"] + "@wix/interact": ["../../packages/interact/src/index"], + "@wix/splittext/plugin": ["../../packages/splittext/dist/types/plugin/index"] } }, "include": ["src"], diff --git a/packages/interact-validate/README.md b/packages/interact-validate/README.md index 5d6de63d..972e28c6 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..ae90230a 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,83 +63,80 @@ 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) => { - if (!hasEffectsOrSequences(interaction)) { - ctx.addIssue({ - code: 'custom', - message: 'Interaction must have at least one effect or sequence', - params: { domainCode: 'INTERACTION_EMPTY' }, - } as any); - } - }); + }), +).superRefine((interaction, ctx) => { + if (!hasEffectsOrSequences(interaction)) { + ctx.addIssue({ + code: 'custom', + message: 'Interaction must have at least one effect or sequence', + params: { domainCode: 'INTERACTION_EMPTY' }, + } as any); + } +}); -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) => { - if (!hasEffectsOrSequences(interaction)) { - ctx.addIssue({ - code: 'custom', - message: 'Interaction must have at least one effect or sequence', - params: { domainCode: 'INTERACTION_EMPTY' }, - } as any); - } - }); + }), +).superRefine((interaction, ctx) => { + if (!hasEffectsOrSequences(interaction)) { + ctx.addIssue({ + code: 'custom', + message: 'Interaction must have at least one effect or sequence', + params: { domainCode: 'INTERACTION_EMPTY' }, + } as any); + } +}); -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) => { - if (!hasEffectsOrSequences(interaction)) { - ctx.addIssue({ - code: 'custom', - message: 'Interaction must have at least one effect or sequence', - params: { domainCode: 'INTERACTION_EMPTY' }, - } as any); - } - }); + }), +).superRefine((interaction, ctx) => { + if (!hasEffectsOrSequences(interaction)) { + ctx.addIssue({ + code: 'custom', + message: 'Interaction must have at least one effect or sequence', + params: { domainCode: 'INTERACTION_EMPTY' }, + } as any); + } +}); export const Interaction = z.discriminatedUnion('trigger', [ AnimationEndInteraction, 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/README.md b/packages/interact/README.md index c2ecbfb4..ffe8a4dc 100644 --- a/packages/interact/README.md +++ b/packages/interact/README.md @@ -430,7 +430,7 @@ Each example is a complete `InteractConfig` — pass it to `Interact.create(conf - **Hit-area shift on `hover` / `pointerMove`** — Animating size/position of the hovered element shifts the hit area and causes jitter. Instead, animate a child via `selector` or a different `key`. - **`registerEffects()` must run before `Interact.create()`/`generate()`** when using `namedEffect`. - **FOUC prevention** — requires injecting the output of `generate(config)` into ``. -- **`generate(config, useFirstChild)`** — Pass `true` for `` (web), `false` for vanilla and React ``. +- **`generate(config, options?)`** — `options` is `{ useFirstChild?, plugins? }`, or a bare boolean used as `useFirstChild`. Pass `true` for `` (web), `false` for vanilla and React ``. - **`` must wrap exactly one child** — the library targets `:first-child` by default. ## AI & Agent Support diff --git a/packages/interact/docs/api/README.md b/packages/interact/docs/api/README.md index 4be1c94f..b9c97740 100644 --- a/packages/interact/docs/api/README.md +++ b/packages/interact/docs/api/README.md @@ -18,7 +18,7 @@ Complete reference documentation for all public APIs in `@wix/interact`. - [Error handling](functions.md#error-handling) and [performance considerations](functions.md#performance-considerations) - [**remove(path)**](functions.md#remove) - Remove interactions from an element - [Cleanup behavior](functions.md#behavior-details) and [advanced usage](functions.md#advanced-usage) -- [**generate(config, useFirstChild?)**](functions.md#generate) - Generate complete CSS for all animations, transitions, scroll-driven effects, and FOUC prevention +- [**generate(config, options?)**](functions.md#generate) - Generate complete CSS for all animations, transitions, scroll-driven effects, and FOUC prevention - [What it generates](functions.md#what-it-generates), [benefits](functions.md#benefits), and [use cases](functions.md#use-cases) - [FOUC prevention](functions.md#fouc-prevention-viewenter), [scroll-driven CSS](functions.md#scroll-driven-css-viewprogress), and [SSR](functions.md#server-side-rendering-ssr) - [**addListItems(root, key, listContainer, elements)**](functions.md#addlistitems) - Add interactions to new list items diff --git a/packages/interact/docs/api/functions.md b/packages/interact/docs/api/functions.md index 64f82da3..c95a29ea 100644 --- a/packages/interact/docs/api/functions.md +++ b/packages/interact/docs/api/functions.md @@ -12,11 +12,11 @@ import { add, remove, generate } from '@wix/interact'; ## Functions Overview -| Function | Purpose | Parameters | Returns | -| ------------ | -------------------------------------------------------------------------------- | -------------------------- | -------- | -| `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` | +| Function | Purpose | Parameters | Returns | +| ------------ | -------------------------------------------------------------------------------- | -------------------------------------- | -------- | +| `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?`, `plugins?` | `string` | --- @@ -196,21 +196,29 @@ console.log('Interactions removed for hero'); --- -## `generate(config, useFirstChild?)` +## `generate(config, options?)` 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, options?: boolean | GenerateOptions): string; + +type GenerateOptions = { + useFirstChild?: boolean; + plugins?: InteractPluginStyles; +}; ``` ### Parameters **`config: InteractConfig`** - The full interaction configuration. Every interaction in the config is processed — not just `viewEnter`. -**`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 ``). +**`options?: boolean | GenerateOptions`** - Either an options object or — for backwards compatibility — a bare boolean used as `useFirstChild` (`generate(config, false)` ≡ `generate(config, { useFirstChild: false })`). + +- **`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 diff --git a/packages/interact/docs/api/interact-class.md b/packages/interact/docs/api/interact-class.md index 17e359bf..43cd1d2e 100644 --- a/packages/interact/docs/api/interact-class.md +++ b/packages/interact/docs/api/interact-class.md @@ -31,6 +31,9 @@ class Interact { }): void; static registerEffects(effects: Record): void; static getController(key: string | undefined): IInteractionController | undefined; + static use(name: string, plugin: InteractPlugin): void; + static getPlugin(name: string): InteractPlugin | undefined; + static getPluginNames(): Set; // Instance methods init(config: InteractConfig, options?: { useCustomElement?: boolean }): void; @@ -230,6 +233,38 @@ Interact.registerEffects({ - Effects must be registered before calling `Interact.create()` with configurations that reference them - Registration is global — once registered, effects are available to all Interact instances +### `Interact.use(name, plugin)` + +Registers a plugin under a name. When an interaction or effect carries a `$` field, Interact invokes the plugin with that field's value and a context. Interact is agnostic to what the plugin does — see the [Plugins guide](../guides/plugins.md). + +**Parameters:** + +- `name: string` - The plugin name, matched against `$` config fields +- `plugin: InteractPlugin` - `(value, context) => void | (() => void)`; the returned cleanup runs on disconnect/teardown + +**Example:** + +```typescript +import { Interact } from '@wix/interact'; +import { splitTextPlugin } from '@wix/splittext/plugin'; + +Interact.use('splitText', splitTextPlugin); +Interact.create(config); // configs may now use a `$splitText: { ... }` field +``` + +**Notes:** + +- Register plugins **before** `Interact.create()`. +- Registration is global. A `$` field naming an unregistered plugin is ignored. + +### `Interact.getPlugin(name)` + +Returns the plugin registered under `name`, or `undefined`. + +### `Interact.getPluginsNames()` + +Returns the set of registered plugins names (non-prefixed). + ### `Interact.getController(key)` Retrieves a cached `InteractionController` by its key. diff --git a/packages/interact/docs/api/types.md b/packages/interact/docs/api/types.md index 082eea6b..cf622cde 100644 --- a/packages/interact/docs/api/types.md +++ b/packages/interact/docs/api/types.md @@ -117,7 +117,7 @@ type Interaction = { params?: TriggerParams; conditions?: string[]; effects: ((Effect | EffectRef) & { interactionId?: string })[]; -}; +} & PluginFields; // `$` plugin fields, e.g. `$splitText` ``` **Properties:** @@ -128,6 +128,7 @@ type Interaction = { - `listContainer` - Optional selector for list container when targeting list items - `params` - Optional parameters for the trigger - `conditions` - Optional array of condition IDs to evaluate +- `$` - Optional `$`-prefixed plugin fields (see [Plugin Types](#plugin-types)). Also available on effects. - `effects` - Array of effects to apply when triggered **Example:** @@ -937,6 +938,96 @@ Union type of all trigger parameter types. type TriggerParams = ViewEnterParams | PointerMoveParams | AnimationEndParams; ``` +## Plugin Types + +See the [Plugins guide](../guides/plugins.md) for the full picture. + +### `InteractPlugin` + +A plugin callback registered via `Interact.use()`. + +```typescript +type InteractPlugin = ( + value: unknown, + context: InteractPluginContext, +) => void | InteractPluginCleanup; +``` + +### `InteractPluginContext` + +```typescript +type InteractPluginContext = { + root: HTMLElement; // the interaction's (or effect target's) root element + key: string; // the interaction/effect key + scope: 'interaction' | 'effect'; + config: Record; // the interaction/effect object the plugin field was on +}; +``` + +### `InteractPluginCleanup` + +```typescript +type InteractPluginCleanup = () => void; // runs on disconnect/teardown +``` + +### `InteractPluginConfigMap` + +Augmentable interface for typing plugin fields, keyed by the **unprefixed** plugin name. Empty by default; consumers merge into it: + +```typescript +import type { SplitTextPluginConfig } from '@wix/splittext/plugin'; + +declare module '@wix/interact' { + interface InteractPluginConfigMap { + splitText: SplitTextPluginConfig; + } +} +// types the `$splitText` field on interactions and effects +``` + +Prefer the config type exported by the plugin package over re-declaring its shape by hand. + +### `PluginFields` + +The `$`-prefixed plugin fields allowed on interactions and effects. Augmented plugins keep their value types (as `$`); any other `$`-prefixed field is still allowed with an `unknown` value. + +```typescript +type PluginFields = { + [K in keyof InteractPluginConfigMap as `$${K & string}`]?: InteractPluginConfigMap[K]; +} & { + [pluginField: `$${string}`]: unknown; +}; +``` + +### `InteractPluginStyleGenerator` + +A plugin's **build-time** styling callback, passed in the `plugins` argument of `generate()` (distinct from the runtime callback given to `Interact.use()`). Returns initial CSS declarations for the element — e.g. hiding pre-plugin content for FOUC prevention. + +```typescript +type InteractPluginStyleGenerator = ( + value: unknown, + context: InteractPluginStyleContext, +) => { declarations: { name: string; value: string | number }[]; selectorSuffix?: string }[]; +``` + +### `InteractPluginStyleContext` + +```typescript +type InteractPluginStyleContext = { + key: string; // the interaction (or effect target) key the field is on + scope: 'interaction' | 'effect'; + config: Record; // the interaction/effect object the field is on +}; +``` + +### `InteractPluginStyles` + +Map of plugin name → SSR style generator, passed as the `plugins` argument to `generate()`. + +```typescript +type InteractPluginStyles = Record; +``` + ## See Also - [Sequences & Staggering Guide](../guides/sequences.md) - Comprehensive sequences guide diff --git a/packages/interact/docs/guides/README.md b/packages/interact/docs/guides/README.md index 92813f4a..0285b9cd 100644 --- a/packages/interact/docs/guides/README.md +++ b/packages/interact/docs/guides/README.md @@ -40,6 +40,10 @@ Working with dynamic lists, list containers, staggered animations, and automatic Coordinate multiple effects with staggered timing using easing-driven delay offsets. Covers inline and reusable sequences, cross-element orchestration, `listContainer` integration, dynamic add/remove, and conditional sequences. +### 🔌 [Plugins](./plugins.md) + +Extend Interact with external code via `Interact.use()` and `$`-prefixed config fields. Covers the generic bridge, the plugin contract and cleanup lifecycle, type-safe config, and using `@wix/splittext` to split text for staggered reveals. + ## Learning Path If you're new to `@wix/interact`, we recommend following the guides in this order: diff --git a/packages/interact/docs/guides/configuration-structure.md b/packages/interact/docs/guides/configuration-structure.md index 99bae272..b39940b1 100644 --- a/packages/interact/docs/guides/configuration-structure.md +++ b/packages/interact/docs/guides/configuration-structure.md @@ -43,6 +43,8 @@ Each interaction defines a complete cause-and-effect relationship: Time-based playback (`once`, `repeat`, `alternate`, `state`) is set with `triggerType` on each time effect, or with `triggerType` on a sequence object when using `sequences`. Optional `params` on the interaction are for observer options (`viewEnter`), pointer/`animationEnd` settings, and other non-playback trigger configuration. +An interaction (or effect) may also carry `$`-prefixed fields (e.g. `$splitText`) — config routed to external plugins registered via `Interact.use()`. See the [Plugins guide](./plugins.md). + ## Element Selection with Selectors The `selector` property allows you to specify exactly which element should be used for interactions, instead of being limited to the first child element. diff --git a/packages/interact/docs/guides/plugins.md b/packages/interact/docs/guides/plugins.md new file mode 100644 index 00000000..1fa91328 --- /dev/null +++ b/packages/interact/docs/guides/plugins.md @@ -0,0 +1,192 @@ +# Plugins + +`@wix/interact` can route parts of your config to **plugins** — external code registered at runtime. Interact acts purely as a bridge: it knows a plugin's _name_, and when an interaction or effect carries a field named `$` it hands that field's value to the plugin. Interact never knows what the plugin does. + +This keeps Interact free of any plugin-specific code, and keeps plugins (like [`@wix/splittext`](https://www.npmjs.com/package/@wix/splittext)) free of any dependency on Interact. A plugin package can still ship its own adapter — `@wix/splittext/plugin` does — by typing it _structurally_ against the contract below rather than importing `@wix/interact`. Your app then only supplies the type glue. + +## How it works + +1. Register a plugin by name, before `Interact.create()`: + + ```ts + import { Interact } from '@wix/interact'; + + Interact.use('myPlugin', (value, context) => { + // `value` is whatever the config put in the `$myPlugin` field + // `context` describes where it was found (see below) + // return an optional cleanup function + }); + ``` + +2. Reference it with a `$`-prefixed field on an **interaction** or an **effect**: + + ```ts + Interact.create({ + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + $myPlugin: { any: 'value' }, + effects: [{ effectId: 'fade-in' }], + }, + ], + }); + ``` + +When the `hero` element connects, Interact sees the `$myPlugin` field, looks up the `myPlugin` plugin, and calls it with `{ any: 'value' }`. Plugins run **before** target resolution, so any DOM a plugin creates is visible to the `selector` / `listContainer` queries that follow. + +If a `$`-prefixed field names a plugin that was never registered, Interact ignores it. + +> **Why the `$` prefix?** It marks a field as plugin config unambiguously (no clash with real config fields), it's a valid unquoted key in JS/TS and valid JSON, and it lets `@wix/interact-validate` accept plugin fields (via `catchall`) while still flagging genuinely-unknown keys. + +## The plugin contract + +```ts +type InteractPlugin = ( + value: unknown, + context: { + root: HTMLElement; // the interaction's (or effect target's) root element + key: string; // the interaction/effect key + scope: 'interaction' | 'effect'; + config: Record; // the interaction/effect object the field was on + }, +) => void | (() => void); // optionally return a cleanup function +``` + +- Plugins run at **connect** time (when the element enters the DOM / is added). +- A returned **cleanup** runs on disconnect, on teardown (`Interact.destroy()`), and when an interaction re-connects after a media-query change — so plugins can fully undo their work. +- Each distinct plugin value is applied **once per connect**, even if referenced by multiple triggers on the same element. + +### Type-safe config (optional) + +Augment `InteractPluginConfigMap` — keyed by the **unprefixed** plugin name — so the `$` field is type-checked: + +```ts +declare module '@wix/interact' { + interface InteractPluginConfigMap { + myPlugin: { any: string }; + } +} +// now `$myPlugin` is typed as `{ any: string }` on interactions and effects +``` + +## Example: `@wix/splittext` + +`@wix/splittext` splits an element's text into `` wrappers (`.split-c` for chars, `.split-w` for words, `.split-l` for lines, `.split-s` for sentences). You don't need to write the adapter — it ships from the `@wix/splittext/plugin` entry point, written against the contract above _structurally_ so `@wix/splittext` keeps no dependency on `@wix/interact`. Register it, then target the generated spans with a normal `selector`: + +```ts +// splitTextTypes.ts — the ONLY module that needs both packages, and only for types +import type { SplitTextPluginConfig } from '@wix/splittext/plugin'; + +declare module '@wix/interact' { + interface InteractPluginConfigMap { + splitText: SplitTextPluginConfig; + } +} +``` + +```ts +import { Interact } from '@wix/interact'; +import { splitTextPlugin } from '@wix/splittext/plugin'; + +Interact.use('splitText', splitTextPlugin); + +Interact.create({ + effects: { + 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 }, + }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + // split `.title` into characters before the sequence resolves + $splitText: { container: '.title', type: 'chars' }, + sequences: [ + { + offset: 30, + offsetEasing: 'quadIn', + // stagger the generated char spans + effects: [{ effectId: 'char-fade-up', selector: '.split-c' }], + }, + ], + }, + ], +}); +``` + +### Effect-level (cross-element) splitting + +A `$`-prefixed field can also sit on an individual effect, using that effect's **target** element as the root — useful for splitting a different element inside a multi-effect sequence: + +```ts +{ + key: 'cta', + trigger: 'click', + sequences: [ + { + offset: 60, + effects: [ + { + key: 'heading', + $splitText: { container: '.heading-text', type: 'chars' }, + selector: '.split-c', + effectId: 'scatter', + }, + { key: 'subtitle', effectId: 'fade-out' }, + ], + }, + ], +} +``` + +## SSR styling (FOUC prevention) + +A plugin often needs initial CSS _before_ it runs — e.g. hiding the un-split text so an entrance animation doesn't flash the raw content. That's a build-time concern, so it lives in [`generate()`](../api/functions.md#generateconfig-options), not in the runtime `use()` callback. + +Pass a **second, separate callback per plugin** in the `plugins` option of `generate()`'s options bag — `generate(config, { useFirstChild, plugins })`. For every `$` field, `generate()` calls the matching generator with the field's (opaque) value and a context, and appends the returned CSS. Like `create()`, `generate()` never looks inside the value — the plugin decides what to emit (and defines its own selectors under `selectorSuffix`). + +```ts +type InteractPluginStyleGenerator = ( + value: unknown, + context: { + key: string; + scope: 'interaction' | 'effect'; + config: Record; + }, +) => { declarations: { name: string; value: string | number }[]; selectorSuffix?: string }[]; +``` + +`selectorSuffix` is concatenated to the base selector (`[data-interact-key=""]`), to refine the target for the styling. + +### SplitText example — hide until split + +`@wix/splittext/plugin` ships this pairing ready-made: `splitTextStyle` is the SSR counterpart to `splitTextPlugin`, and the two agree on a `data-splittext-ready` marker. Opt in per-field with `hideUntilReady`: + +```ts +import { generate } from '@wix/interact'; +import { splitTextStyle } from '@wix/splittext/plugin'; + +const config = { + effects: { 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + $splitText: { container: '.title', type: 'chars', hideUntilReady: true }, + sequences: [{ offset: 30, effects: [{ effectId: 'char-fade-up', selector: '.split-c' }] }], + }, + ], +}; + +// Embed this CSS in at build/SSR time. +const css = generate(config, { plugins: { splitText: splitTextStyle } }); +// → `[data-interact-key="hero"] .title:not([data-splittext-ready]) { visibility: hidden; }` +``` + +On first paint the container is hidden; once the runtime plugin splits it and sets `data-splittext-ready`, the hide rule stops matching and the (individually-hidden) spans take over their entrance animation — no flash of un-split text. Without `hideUntilReady`, `splitTextStyle` emits nothing. + +> Plugin styles are emitted verbatim and unconditionally. If a rule should be scoped to a media query or condition, have the generator build that itself (it receives the interaction/effect `config`). + +## Validation + +`@wix/interact-validate` accepts any `$`-prefixed field as opaque plugin config (via zod's `catchall`) — it does not inspect plugin-specific shapes (it has no knowledge of any plugin). Non-prefixed unknown keys are still rejected, so a typo like `slector` (or forgetting the `$` on a plugin field) is caught. diff --git a/packages/interact/rules/full-lean.md b/packages/interact/rules/full-lean.md index 5a3587fd..3d97071c 100644 --- a/packages/interact/rules/full-lean.md +++ b/packages/interact/rules/full-lean.md @@ -24,6 +24,7 @@ Declarative configuration-driven interaction library. Binds animations to trigge - [Conditions](#conditions) - [CSS Generation & FOUC Prevention](#css-generation--fouc-prevention) - [Element Resolution](#element-resolution) +- [Plugins](#plugins) - [Static API](#static-api) --- @@ -705,17 +706,62 @@ The target element is what the effect animates. Resolved in priority order: --- +## Plugins + +Interact can route config to external plugins registered with `Interact.use(name, plugin)`. Interact is only a bridge — it matches a `$` config field to a registered plugin name and passes the value in; it never inspects plugin behavior. Neither Interact nor the plugin package depend on each other. A plugin package MAY ship its own adapter typed structurally against the contract (e.g. `@wix/splittext/plugin`) — use it instead of hand-rolling one; your app supplies only the type glue. + +- Register before `create()`: `Interact.use('splitText', splitTextPlugin)`. +- Reference with a `$` field on an **interaction** or **effect**: `$splitText: { container: '.title', type: 'chars' }`. +- Plugins run at connect time, **before** target resolution — so DOM they create (e.g. `.split-c` spans) is visible to `selector` queries that follow. +- A plugin may return a cleanup function; Interact runs it on disconnect/teardown. +- A `$` field with no registered plugin is ignored. +- Plugin fields MUST be `$`-prefixed — a non-prefixed unknown key on an interaction/effect is rejected by `@wix/interact-validate`. +- **SSR styling:** for FOUC prevention (e.g. hiding un-split text before an entrance animation), pass a **separate** per-plugin callback in the `plugins` option of `generate()`'s options bag: `generate(config, { useFirstChild: true, plugins: { myPlugin: (value, context) => { declarations, selectorSuffix }[] } })`. `generate()` does not inspect the value; the callback returns CSS rule(s) data and scopes them under the base selector (`[data-interact-key=""]`). It is NOT the `use()` callback. If the plugin package ships a generator (e.g. `splitTextStyle` from `@wix/splittext/plugin`), pass that instead of writing one. + +**Example — split text, then stagger the generated char spans.** Use the adapter shipped from `@wix/splittext/plugin` (`splitTextPlugin` + its SSR counterpart `splitTextStyle`); do NOT hand-roll it: + +```js +import { Interact, generate } from '@wix/interact'; +import { splitTextPlugin, splitTextStyle } from '@wix/splittext/plugin'; + +const config = { + effects: { 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + // `hideUntilReady` opts into the SSR hide rule emitted by splitTextStyle + $splitText: { container: '.title', type: 'chars', hideUntilReady: true }, + sequences: [{ offset: 30, effects: [{ effectId: 'char-fade-up', selector: '.split-c' }] }], + }, + ], +}; + +const css = generate(config, { useFirstChild: true, plugins: { splitText: splitTextStyle } }); +// Embed css in HTML — see CSS Generation & FOUC Prevention + +Interact.use('splitText', splitTextPlugin); + +Interact.create(config); +``` + +- `$splitText` takes `{ container, hideUntilReady?, ...SplitTextOptions }`; `container` is resolved within the element and every match is split. +- Type the field in your app: `declare module '@wix/interact' { interface InteractPluginConfigMap { splitText: SplitTextPluginConfig } }` (type from `@wix/splittext/plugin`). + +Default split wrapper classes: `.split-c` (chars), `.split-w` (words), `.split-l` (lines), `.split-s` (sentences). + ## Static API -| Method / Property | Description | -| :---------------------------------- | :------------------------------------------------------------------------------------------------------------ | -| `generate(config, useFirstChild?)` | Produce complete CSS for all interactions. Call at build/generation time; embed in HTML. | -| `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | -| `Interact.registerEffects(presets)` | Register named effect presets. MUST be called before `generate()` and `create`. | -| `Interact.destroy()` | Tear down all instances. Call on unmount or route change to prevent memory leaks. | -| `Interact.forceReducedMotion` | `boolean` (default: `false`) — force reduced-motion behavior regardless of OS setting. | -| `Interact.allowA11yTriggers` | `boolean` (default: `true`) — enable accessibility trigger variants (`interest`, `activate`). | -| `Interact.setup(options)` | Configure global options for scroll, pointer, and viewEnter systems. Call before `create`. See options below. | +| Method / Property | Description | +| :---------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate(config, options?)` | Produce complete CSS for all interactions. Call at build/generation time; embed in HTML. `options` = `{ useFirstChild?, plugins? }`, or a bare boolean for legacy `useFirstChild`. `plugins` = per-plugin SSR style generators (see [Plugins](#plugins)). | +| `Interact.create(config)` | Initialize with a config. Returns the instance. Store the instance to manage its lifecycle. | +| `Interact.registerEffects(presets)` | Register named effect presets. MUST be called before `generate()` and `create`. | +| `Interact.destroy()` | Tear down all instances. Call on unmount or route change to prevent memory leaks. | +| `Interact.forceReducedMotion` | `boolean` (default: `false`) — force reduced-motion behavior regardless of OS setting. | +| `Interact.allowA11yTriggers` | `boolean` (default: `true`) — enable accessibility trigger variants (`interest`, `activate`). | +| `Interact.setup(options)` | Configure global options for scroll, pointer, and viewEnter systems. Call before `create`. See options below. | +| `Interact.use(name, plugin)` | Register an external plugin by name (see [Plugins](#plugins)). Call before `create`. | **`Interact.setup(options)`** — optional configuration object: diff --git a/packages/interact/rules/integration.md b/packages/interact/rules/integration.md index a08aaa7d..664bb754 100644 --- a/packages/interact/rules/integration.md +++ b/packages/interact/rules/integration.md @@ -273,7 +273,7 @@ Define reusable sequences in `InteractConfig.sequences` and reference by `sequen ## CSS Generation & FOUC Prevention -`generate(config, useFirstChild)` produces complete CSS for **all** interactions in the config — `@keyframes`, animation/transition custom properties, `view-timeline` declarations, state-selector rules, coordinated-list aggregation, and FOUC-prevention initial rules. +`generate(config, options?)` produces complete CSS for **all** interactions in the config — `@keyframes`, animation/transition custom properties, `view-timeline` declarations, state-selector rules, coordinated-list aggregation, and FOUC-prevention initial rules. **Static site policy:** For static or pre-rendered HTML (agent-generated pages, SSG, static export), prefer calling `generate()` for the complete config at @@ -284,7 +284,7 @@ browser before its `Interact.create()` call. If splitting is impractical, generating the complete CSS at runtime is an acceptable fallback. Call `registerEffects()` before each `generate()` when using `namedEffect`. -The `useFirstChild` argument tells Interact whether to render `:first-child` selectors for custom-element (`web`) integration: `true` for **web**, `false` for **react** and **vanilla**. +The `useFirstChild` option tells Interact whether to render `:first-child` selectors for custom-element (`web`) integration: `true` for **web**, `false` for **react** and **vanilla**. Pass it either as a bare boolean (`generate(config, true)`) or in the options bag (`generate(config, { useFirstChild: true })`) — the bag is also where `plugins` goes. ```javascript import { generate } from '@wix/interact/web'; @@ -344,12 +344,12 @@ See [viewenter.md](./viewenter.md) for full details. Each `Interact.create(config)` call returns an instance. Keep a reference if you need to add/remove elements dynamically (vanilla JS) or to destroy a specific instance. Call `Interact.destroy()` to tear down all instances at once (e.g. on page navigation). -| Method / Property | Description | -| :---------------------------------- | :------------------------------------------------------------------------------------------- | -| `generate(config, useFirstChild?)` | Produce complete CSS for all interactions. Call at build/generation time; embed in HTML. | -| `Interact.create(config)` | Initialize with a config. Returns the instance. Multiple configs create separate instances. | -| `Interact.registerEffects(presets)` | Register named effect presets before `generate()` and `create`. Required for `namedEffect`. | -| `Interact.destroy()` | Tear down all instances. | -| `Interact.forceReducedMotion` | `boolean` — force reduced-motion behavior regardless of OS setting. Default: `false`. | -| `Interact.allowA11yTriggers` | `boolean` — enable accessibility triggers (`interest`, `activate`). Default: `false`. | -| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params. Call before `create`. | +| Method / Property | Description | +| :---------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generate(config, options?)` | Produce complete CSS for all interactions. Call at build/generation time; embed in HTML. `options` = `{ useFirstChild?, plugins? }`, or a bare boolean for legacy `useFirstChild`. | +| `Interact.create(config)` | Initialize with a config. Returns the instance. Multiple configs create separate instances. | +| `Interact.registerEffects(presets)` | Register named effect presets before `generate()` and `create`. Required for `namedEffect`. | +| `Interact.destroy()` | Tear down all instances. | +| `Interact.forceReducedMotion` | `boolean` — force reduced-motion behavior regardless of OS setting. Default: `false`. | +| `Interact.allowA11yTriggers` | `boolean` — enable accessibility triggers (`interest`, `activate`). Default: `false`. | +| `Interact.setup(options)` | Configure global defaults for scroll/pointer/viewEnter trigger params. Call before `create`. | diff --git a/packages/interact/rules/plugins.md b/packages/interact/rules/plugins.md new file mode 100644 index 00000000..8a1cb6c5 --- /dev/null +++ b/packages/interact/rules/plugins.md @@ -0,0 +1,122 @@ +# @wix/interact Plugin Rules + +Rules for extending `@wix/interact` with external plugins via `Interact.use()` and `$`-prefixed config fields. + +## Model + +Interact is a **generic bridge**. It knows a plugin only by the name it was registered under. When an interaction or effect carries a field named `$`, Interact passes that field's value to the matching plugin and (optionally) stores a cleanup. Interact never inspects what the plugin does. + +- `@wix/interact` has **no** plugin-specific code and does **not** depend on any plugin package. +- A plugin package (e.g. `@wix/splittext`) does **not** depend on `@wix/interact`. +- A plugin package MAY still ship a ready-made adapter (e.g. `@wix/splittext/plugin`), typed _structurally_ against the contract below so it stays assignable to `InteractPlugin` without importing Interact. Prefer the shipped adapter over hand-rolling one. +- Only the **typing** glue lives in your app — the declaration merge on `InteractPluginConfigMap` (see [Config placement](#config-placement)). + +## Registration + +Register before `Interact.create()`: + +```js +Interact.use('', (value, context) => { + // value: the value of the `$` field + // context: { root, key, scope: 'interaction' | 'effect', config } + // return: optional cleanup () => void, run on disconnect/teardown +}); +``` + +- `Interact.getPlugin(name) / Interact.getPluginsNames()` inspect the registry. + +## Config placement + +Add a `$` field on an **interaction** or an **effect**: + +```js +{ + key: 'hero', + trigger: 'viewEnter', + $splitText: { /* any value */ }, + effects: [ /* ... */ ], +} +``` + +## Rules + +- **MUST** register the plugin (`Interact.use`) before `Interact.create()`. A `$` field with no registered plugin is ignored. +- **MUST** prefix plugin fields with `$` (e.g. `$splitText`). A non-prefixed unknown key on an interaction/effect is rejected by `@wix/interact-validate` (via `catchall` + key check). Only `$`-prefixed fields are treated as opaque, un-inspected plugin config. +- Use a bare, unquoted `$` key — no quotes needed since `$` is a valid identifier start (e.g. `$splitText:`, not `'plugin:splitText':`). +- Plugins run at **connect time, before target resolution** — DOM a plugin creates is visible to the `selector` / `listContainer` queries that follow. +- A returned cleanup runs on disconnect, on `Interact.destroy()`, and on media-query reconnect. Use it to fully undo the plugin's work. +- Each distinct plugin value is applied **once per connect**, even across multiple triggers on the same element. +- Interaction-level fields use the interaction's element as `root`; effect-level fields use the effect's target element as `root`. + +## SSR styling (FOUC prevention) + +Runtime plugins mutate the DOM only after JS loads. To style the element _before_ that (e.g. hide un-split text so an entrance animation doesn't flash), pass a **separate** per-plugin callback in the `plugins` option of `generate()`'s options bag: + +```js +const css = generate(config, { + useFirstChild: true, + plugins: { + myPlugin: (value, _context) => { + // value: the opaque `$myPlugin` value; + // context: { key, scope: 'interaction' | 'effect', config } + // return: { declarations: { name: string; value: number | string }[]; selectorSuffix?: string }[] + return [ + { + declarations: [{ name: 'visibility', value: 'hidden' }], + selectorSuffix: ` ${value.container ?? ''}:not([data-myplugin-ready])`, + }, + ]; + }, + }, +}); +``` + +- If the plugin package ships its own generator, pass that instead of writing one — e.g. `splitTextStyle` from `@wix/splittext/plugin` (see the example below). +- This is **NOT** the callback registered via `Interact.use()` — it's a build-time styling generator. +- `generate()` does **not** inspect the `$` value (same as `create()`); it just routes it to the generator, which returns partial CSS rule(s) data. +- `declarations` is an array of names and values of CSS properties to set; `selectorSuffix` is used to refine the target of the CSS rule - the resulting selector for the rule is `[data-interact-key=${key}]${selectorSuffix}` +- Context: `{ key, scope: 'interaction' | 'effect', config }`. +- Typical pattern: the runtime plugin sets a marker attribute after applying (e.g. `data-splittext-ready`), and the SSR rule hides until that marker is present. + +## Example: `@wix/splittext` + +Split text into ``s, then target the generated spans with a normal `selector`. Default classes: `.split-c` (chars), `.split-w` (words), `.split-l` (lines), `.split-s` (sentences). + +**Do NOT hand-roll this adapter.** `@wix/splittext/plugin` ships both callbacks — `splitTextPlugin` (runtime) and `splitTextStyle` (SSR) — already paired on the `data-splittext-ready` marker: + +```js +import { Interact, generate } from '@wix/interact'; +import { splitTextPlugin, splitTextStyle } from '@wix/splittext/plugin'; + +const config = { + effects: { 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + // `hideUntilReady` opts into the SSR hide rule emitted by splitTextStyle + $splitText: { container: '.title', type: 'chars', hideUntilReady: true }, + sequences: [{ offset: 30, effects: [{ effectId: 'char-fade-up', selector: '.split-c' }] }], + }, + ], +}; + +Interact.use('splitText', splitTextPlugin); + +const css = generate(config, { useFirstChild: true, plugins: { splitText: splitTextStyle } }); +// Embed css in HTML — see CSS Generation & FOUC Prevention + +Interact.create(config); +``` + +To type the `$splitText` field, declaration-merge in your app (the only place importing both packages): + +```ts +import type { SplitTextPluginConfig } from '@wix/splittext/plugin'; + +declare module '@wix/interact' { + interface InteractPluginConfigMap { + splitText: SplitTextPluginConfig; + } +} +``` diff --git a/packages/interact/rules/validate.md b/packages/interact/rules/validate.md index 07d46944..6aae8ea6 100644 --- a/packages/interact/rules/validate.md +++ b/packages/interact/rules/validate.md @@ -278,6 +278,7 @@ const ExperienceSchema = z.object({ - `InteractConfigSchema` is `.strict()` — unrecognized top-level keys produce `SCHEMA_UNRECOGNIZED_KEYS`. - `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 (`z.custom`) — they are not deep-validated, so JS-authored configs with function fields validate correctly. +- Interactions and effects accept `$`-prefixed plugin fields (e.g. `$splitText`) — config routed to plugins registered via `Interact.use()`. Interaction/effect schemas use `.catchall(z.unknown())` + a key check instead of `.strict()`: `$`-prefixed fields are accepted with opaque values (validate has no knowledge of any plugin's shape), while any non-prefixed unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS` (typo detection preserved). The top-level `InteractConfigSchema` stays `.strict()`. --- diff --git a/packages/interact/src/core/Interact.ts b/packages/interact/src/core/Interact.ts index 1fdeddaf..13b9ba06 100644 --- a/packages/interact/src/core/Interact.ts +++ b/packages/interact/src/core/Interact.ts @@ -10,6 +10,7 @@ import { ViewEnterHandlerModule, IInteractionController, IInteractElement, + InteractPlugin, } from '../types'; import { getInterpolatedKey } from './utilities'; import { generateId } from '../utils'; @@ -48,6 +49,7 @@ export class Interact { static controllerCache = new Map(); static sequenceCache = new Map(); static elementSequenceMap = new WeakMap>(); + private static plugins = new Map(); constructor() { this.dataCache = { effects: {}, sequences: {}, conditions: {}, interactions: {} }; @@ -245,6 +247,23 @@ export class Interact { static registerEffects = registerEffects; + /** + * Registers a plugin under a name. When the config carries a plugin field (e.g. `$splitText`) with a matching key + * (on an interaction or effect), Interact invokes the plugin with the field's value and a + * context. Interact is agnostic to what the plugin does — see the `InteractPlugin` type. + */ + static use(name: string, plugin: InteractPlugin): void { + Interact.plugins.set(name, plugin); + } + + static getPlugin(name: string): InteractPlugin | undefined { + return Interact.plugins.get(name); + } + + static getPluginsNames(): Set { + return new Set(Interact.plugins.keys()); + } + static getSequence( cacheKey: string, sequenceOptions: SequenceOptions, diff --git a/packages/interact/src/core/InteractionController.ts b/packages/interact/src/core/InteractionController.ts index f12342f6..0384dbfb 100644 --- a/packages/interact/src/core/InteractionController.ts +++ b/packages/interact/src/core/InteractionController.ts @@ -1,4 +1,4 @@ -import type { IInteractElement, StateAction } from '../types'; +import type { IInteractElement, InteractPluginCleanup, StateAction } from '../types'; import { add, addListItems } from './add'; import { remove, removeListItems } from './remove'; @@ -11,6 +11,8 @@ export class InteractionController { sheet: CSSStyleSheet | null; useFirstChild: boolean; _observers: WeakMap; + _pluginCleanups: InteractPluginCleanup[]; + _appliedPlugins: WeakSet; constructor(element: HTMLElement, key?: string, options?: { useFirstChild?: boolean }) { this.element = element; @@ -18,6 +20,8 @@ export class InteractionController { this.connected = false; this.sheet = null; this._observers = new WeakMap(); + this._pluginCleanups = []; + this._appliedPlugins = new WeakSet(); this.useFirstChild = options?.useFirstChild ?? false; } @@ -57,6 +61,20 @@ export class InteractionController { remove(this, removeFromCache); } + // Run plugin cleanups after `remove()` has torn down animations on the (possibly + // plugin-generated) elements, then revert the plugins' DOM mutations. + if (this._pluginCleanups.length) { + for (const cleanup of this._pluginCleanups) { + try { + cleanup(); + } catch (e) { + console.error(e); + } + } + this._pluginCleanups.length = 0; + } + this._appliedPlugins = new WeakSet(); + if (this.sheet) { const rootNode = this.element?.getRootNode() as ShadowRoot | Document; const adoptTarget: { adoptedStyleSheets: CSSStyleSheet[] } = ((rootNode as ShadowRoot).host diff --git a/packages/interact/src/core/add.ts b/packages/interact/src/core/add.ts index 13516e9c..8d53e3db 100644 --- a/packages/interact/src/core/add.ts +++ b/packages/interact/src/core/add.ts @@ -14,6 +14,7 @@ import type { AnimationEndParams, AnimationOptions, } from '../types'; +import { PLUGIN_FIELD_PREFIX } from '../types'; import { createTransitionCSS, getMediaQuery, getSelectorCondition, generateId } from '../utils'; import { getInterpolatedKey } from './utilities'; import { effectToAnimationOptions } from '../handlers/utilities'; @@ -40,6 +41,51 @@ type ListElements = { elements: HTMLElement[]; }; +/** + * Generic plugin bridge. For every `$`-prefixed field on an interaction or effect (e.g. `$splitText` + * → the `splitText` plugin), look up the plugin registered via `Interact.use()` and invoke it with + * the raw field value and a context. Any returned cleanup is stored on the owning controller and + * run on disconnect. Interact never inspects what a plugin does — it only routes config → plugin. + * + * Plugins run BEFORE target resolution (`_getElementsFromData`), so any DOM they produce (e.g. + * split ``s) is visible to the selectors that follow. Each distinct plugin value object is + * applied at most once per connect — the field values are shared by reference across resolution + * passes, so identity is a stable dedup key. + */ +function _applyPlugins( + owner: IInteractionController, + root: HTMLElement, + config: Record, + key: string, + scope: 'interaction' | 'effect', +): void { + const pluginNames = Interact.getPluginsNames(); + + for (const name of pluginNames) { + const field = `${PLUGIN_FIELD_PREFIX}${name}`; + if (!(field in config)) { + continue; + } + + const value = config[field]; + + if (value !== null && typeof value === 'object') { + if (owner._appliedPlugins.has(value)) { + continue; + } + owner._appliedPlugins.add(value); + } + + const plugin = Interact.getPlugin(name)!; + + const cleanup = plugin(value, { root, key, scope, config }); + + if (typeof cleanup === 'function') { + owner._pluginCleanups.push(cleanup); + } + } +} + function _getElementsFromData( data: Interaction | Effect, root: HTMLElement, @@ -216,6 +262,15 @@ function _addInteraction( targetController = sourceController; } + // Effect-level plugins act on the effect's target element before it is resolved. + _applyPlugins( + targetController, + targetController.element, + effectOptions, + target || interaction.key, + 'effect', + ); + const [sourceElements, targetElements] = _getInteractionElements( interaction, effectOptions, @@ -329,6 +384,16 @@ function _buildAnimationGroupArgsFromSequence( } const resolvedTargetKey = target || sourceKey; + + // Effect-level plugins act on the effect's target element before it is resolved. + _applyPlugins( + targetController, + targetController.element, + effectOptions, + resolvedTargetKey, + 'effect', + ); + let targetElement: HTMLElement | HTMLElement[] | null; if ( @@ -685,6 +750,15 @@ function addEffectsForTarget( return true; } + // Effect-level plugins act on the effect's target element before it is resolved. + _applyPlugins( + targetController, + targetController.element, + effectOptions, + targetKey, + 'effect', + ); + if (effectOptions.listContainer) { targetController.watchChildList(effectOptions.listContainer); } @@ -832,6 +906,10 @@ export function add(controller: IInteractionController): boolean { } if (!mql || mql.matches) { + // Run interaction-level plugins before target resolution / list observation so any DOM they + // create is visible to the selectors and MutationObservers that follow. + _applyPlugins(controller, controller.element, interaction, key, 'interaction'); + if (interaction.listContainer) { controller.watchChildList(interaction.listContainer); } diff --git a/packages/interact/src/core/css.ts b/packages/interact/src/core/css.ts index d5b1e10d..895d24fa 100644 --- a/packages/interact/src/core/css.ts +++ b/packages/interact/src/core/css.ts @@ -8,7 +8,10 @@ import type { ListCustomProps, CSSCoordinatedLists, CSSRuleData, + InteractPluginStyles, + GenerateOptions, } from '../types'; +import { PLUGIN_FIELD_PREFIX } from '../types'; import { kebabCustomProp, camelToKebabCase, @@ -169,12 +172,44 @@ function triggerToCSS( }; } +/** + * Collects build-time plugin styles for one effect config object. For every + * `$`-prefixed field with a matching generator in `plugins`, calls the generator with the raw + * value and a context scoped to the element, and return CSS rule(s) data. Interact + * never inspects the field value — it only routes it to the plugin (same contract as `create()`). + */ +function collectFieldPluginStyles( + scope: 'interaction' | 'effect', + source: Record, + key: string, + media: string, + plugins: InteractPluginStyles, +): CSSRuleData[] { + const rules = []; + for (const pluginName of Object.keys(plugins)) { + const pluginField = `${PLUGIN_FIELD_PREFIX}${pluginName}`; + if (!(pluginField in source)) { + continue; + } + + rules.push( + ...plugins[pluginName](source[pluginField], { + key, + scope, + config: source, + }).map((data) => ({ ...data, key, media })), + ); + } + return rules; +} + function effectToCSS( effect: ResolvedEffect, configConditions: Record, customProps: ListCustomProps, trigger: TriggerVariant, childSelector?: string, + plugins?: InteractPluginStyles, ): { rules: CSSRuleData[]; keyframes: MotionKeyframeEffect[]; @@ -209,6 +244,10 @@ function effectToCSS( let usedProperties: ListPropertyName[] = []; + if (plugins) { + rules.push(...collectFieldPluginStyles('effect', effect, key, media, plugins)); + } + if (namedEffect || keyframeEffect) { usedProperties = [...LIST_ANIMATION_PROPERTY_NAMES]; @@ -244,14 +283,14 @@ function effectToCSS( })); if (initial) { - // declare animation and composition custom properties with initial dependent on data-motion-enter + // declare animation custom properties with initial dependent on data-motion-enter rules.push({ key, media, selectorCondition, childSelector, declarations: DEFAULT_INITIAL, - dataInteractEnterSelector: ':not([data-interact-enter])', + selectorSuffix: ':not([data-interact-enter])', }); rules.push({ key, @@ -259,10 +298,10 @@ function effectToCSS( selectorCondition, childSelector, declarations: animationDeclarations, - dataInteractEnterSelector: ':not([data-interact-enter="done"])', + selectorSuffix: ':not([data-interact-enter="done"])', }); } else { - // declare animation and composition custom properties + // declare animation custom properties declarations.push(...animationDeclarations); } } else if (transition || transitionProperties) { @@ -277,8 +316,7 @@ function effectToCSS( value: transitions.join(', ') || LIST_PROPERTY_FALLBACKS.transition, }); - // adding state rule using custom properties that could be overriden to implement - // same-interaction-cascade + // adding state rule rules.push({ key, media, @@ -288,7 +326,7 @@ function effectToCSS( declarations: properties, }); } else { - // setting off animation, composition and transition custom properties + // setting off animation custom properties declarations.push( ...LIST_ANIMATION_PROPERTY_NAMES.map((propertyName) => ({ name: customProps[propertyName], @@ -308,6 +346,7 @@ function parseEffect( keyframesMap: Map, trigger: TriggerVariant, useFirstChild: boolean = true, + plugins?: InteractPluginStyles, sequenceCustomProps?: Record, precomputedTargetHash?: string, ): { rules: CSSRuleData[]; usedProperties: ListPropertyName[] } { @@ -343,6 +382,7 @@ function parseEffect( localCustomProps, trigger, childSelector, + plugins, ); // update keyframes map @@ -360,6 +400,7 @@ function parseSequence( trigger: TriggerVariant, useFirstChild: boolean = true, targetUsedProperties?: Map>, + plugins?: InteractPluginStyles, ): CSSRuleData[] { // in a similar manner to how we treat different interactions and use lists to concatenate them // instead of overriding, we use the same mechanism to allow all of the effects of a sequence to @@ -393,6 +434,7 @@ function parseSequence( keyframesMap, trigger, useFirstChild, + plugins, seqCustomProps, targetHash, ); @@ -433,8 +475,9 @@ function parseInteraction( targetToLists: Map, keyframesMap: Map, useFirstChild: boolean = true, + plugins?: InteractPluginStyles, ): CSSRuleData[] { - const { effects = [], sequences = [] } = interaction; + const { key, conditions, effects = [], sequences = [] } = interaction; const configConditions = config.conditions || {}; // targetHash to custom-property per each coordinated-list type property for current interaction @@ -451,7 +494,15 @@ function parseInteraction( .map((effect) => resolveEffectForCSS(effect, interaction, config)) .filter((effect) => effect !== null); - const cssRules = []; + const cssRules = plugins + ? collectFieldPluginStyles( + 'interaction', + interaction, + key, + getFullPredicateByType(conditions, configConditions, 'media'), + plugins, + ) + : []; const { trigger } = interaction; const motionTrigger = { @@ -473,6 +524,7 @@ function parseInteraction( keyframesMap, motionTrigger, useFirstChild, + plugins, ); cssRules.push(...rules); @@ -494,6 +546,7 @@ function parseInteraction( motionTrigger, useFirstChild, targetUsedProperties, + plugins, ), ), ); @@ -514,20 +567,44 @@ function parseInteraction( // ----- EndPoints ----- +/** + * Normalizes `generate()`'s single optional argument, which is either the legacy `useFirstChild` + * boolean or an options bag. + */ +function normalizeGenerateOptions(options: boolean | GenerateOptions = {}): { + useFirstChild: boolean; + plugins?: InteractPluginStyles; +} { + const { useFirstChild = true, plugins } = + typeof options === 'boolean' ? { useFirstChild: options, plugins: undefined } : options; + + return { useFirstChild, plugins }; +} + export function _generate( config: InteractConfig, - useFirstChild: boolean = true, + options?: boolean | GenerateOptions, ): { cssRules: CSSRuleData[]; keyframes: Map; } { + const { useFirstChild, plugins } = normalizeGenerateOptions(options); + // targetHash to lists of custom-properties for each coordinated-list type property // to be populated when parsing interactions const targetToLists = new Map(); const keyframes = new Map(); const cssRules = config.interactions.flatMap((interaction, interactionIdx) => - parseInteraction(config, interaction, interactionIdx, targetToLists, keyframes, useFirstChild), + parseInteraction( + config, + interaction, + interactionIdx, + targetToLists, + keyframes, + useFirstChild, + plugins, + ), ); // for each target add unconditional rule for the coordinated lists from interactions targeting it @@ -541,11 +618,19 @@ export function _generate( * Generates CSS for animations from an InteractConfig. * * @param config - The interact configuration containing effects and interactions - * @param useFirstChild - Whether to use the first child selector (default: true) + * @param options - Either a {@link GenerateOptions} bag or — for backwards compatibility — a bare + * boolean used as `useFirstChild`: + * + * - `useFirstChild` - Whether to use the first child selector (default: true) + * - `plugins` - 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; + * its returned CSS is appended. Used e.g. to hide pre-split text for FOUC prevention. + * Interact never inspects the field value — mirroring `create()`/`use()`. + * * @returns string containing all of the CSS rules needed for time-based animations */ -export function generate(config: InteractConfig, useFirstChild: boolean = true): string { - const { cssRules, keyframes } = _generate(config, useFirstChild); +export function generate(config: InteractConfig, options?: boolean | GenerateOptions): string { + const { cssRules, keyframes } = _generate(config, options); const css = [ ...[...keyframes.entries()].map(([name, keyframes]) => keyframesToCSS(name, keyframes)), diff --git a/packages/interact/src/core/cssUtils.ts b/packages/interact/src/core/cssUtils.ts index 6a9c28eb..394d6482 100644 --- a/packages/interact/src/core/cssUtils.ts +++ b/packages/interact/src/core/cssUtils.ts @@ -107,15 +107,8 @@ export function keyframesToCSS(name: string, keyframes: Keyframe[]): string { } export function CSSRuleToString(rule: CSSRuleData): string { - const { - key, - childSelector, - declarations, - media, - states, - selectorCondition, - dataInteractEnterSelector, - } = rule; + const { key, childSelector, declarations, media, states, selectorCondition, selectorSuffix } = + rule; if (!declarations.length) { return ''; } @@ -135,8 +128,8 @@ export function CSSRuleToString(rule: CSSRuleData): string { selector = `${selector} ${childSelector}`; } - if (dataInteractEnterSelector) { - selector = `${selector}${dataInteractEnterSelector}`; + if (selectorSuffix) { + selector = `${selector}${selectorSuffix}`; } // maybe nesting is simpler? - diff --git a/packages/interact/src/types/config.ts b/packages/interact/src/types/config.ts index c505dcef..ddfddea1 100644 --- a/packages/interact/src/types/config.ts +++ b/packages/interact/src/types/config.ts @@ -1,5 +1,6 @@ import type { TriggerType, TriggerParams } from './triggers'; import type { Effect, EffectRef, EffectProperty, TimeAnimationTriggerType } from './effects'; +import type { PluginFields } from './plugins'; export type Condition = { type: 'media' | 'container' | 'selector'; @@ -36,7 +37,7 @@ export type InteractionTrigger = { params?: TriggerParams; conditions?: string[]; selector?: string; -}; +} & PluginFields; // `$` fields route to plugins registered via `Interact.use()` export type Interaction = InteractionTrigger & { effects?: ((Effect | EffectRef) & { interactionId?: string })[]; diff --git a/packages/interact/src/types/controller.ts b/packages/interact/src/types/controller.ts index 49ae8bcb..9499c7e6 100644 --- a/packages/interact/src/types/controller.ts +++ b/packages/interact/src/types/controller.ts @@ -1,4 +1,5 @@ import type { StateAction } from './effects'; +import type { InteractPluginCleanup } from './plugins'; export interface IInteractionController { element: HTMLElement; @@ -7,6 +8,10 @@ export interface IInteractionController { sheet: CSSStyleSheet | null; useFirstChild: boolean; _observers: WeakMap; + /** Cleanup callbacks returned by plugins applied to this controller's element. */ + _pluginCleanups: InteractPluginCleanup[]; + /** Plugin config values already applied during the current connect (dedup guard). */ + _appliedPlugins: WeakSet; connect(key?: string): void; disconnect(options?: { removeFromCache?: boolean }): void; update(): void; diff --git a/packages/interact/src/types/css.ts b/packages/interact/src/types/css.ts index 7e7f05a9..5b479619 100644 --- a/packages/interact/src/types/css.ts +++ b/packages/interact/src/types/css.ts @@ -1,3 +1,19 @@ +import type { InteractPluginStyles } from './plugins'; + +/** + * Options bag for `generate()`. Passed as its single optional 2nd argument, which also accepts a + * bare boolean for the legacy `useFirstChild` signature. + */ +export type GenerateOptions = { + /** Whether to use the first child selector (default: `true`). */ + useFirstChild?: boolean; + /** + * 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; its returned CSS is appended. + */ + plugins?: InteractPluginStyles; +}; + export type ListPropertyName = | 'animation' | 'transition' @@ -23,5 +39,5 @@ export type CSSRuleData = { media?: string; states?: string[]; selectorCondition?: string; - dataInteractEnterSelector?: string; + selectorSuffix?: string; }; diff --git a/packages/interact/src/types/effects.ts b/packages/interact/src/types/effects.ts index 43dd3d21..4cf8a35e 100644 --- a/packages/interact/src/types/effects.ts +++ b/packages/interact/src/types/effects.ts @@ -4,6 +4,7 @@ import type { ScrubTransitionEasing, MotionAnimationOptions, } from '@wix/motion'; +import type { PluginFields } from './plugins'; type Fill = 'none' | 'forwards' | 'backwards' | 'both'; @@ -85,7 +86,7 @@ export type EffectBase = { conditions?: string[]; selector?: string; effectId?: string; -}; +} & PluginFields; // `$` fields route to plugins registered via `Interact.use()` export type EffectRef = EffectBase & { effectId: string }; diff --git a/packages/interact/src/types/external.ts b/packages/interact/src/types/external.ts index 3915e2ed..bdb884ec 100644 --- a/packages/interact/src/types/external.ts +++ b/packages/interact/src/types/external.ts @@ -38,5 +38,20 @@ export type { // Controller export type { IInteractionController, IInteractElement } from './controller'; +// CSS generation +export type { GenerateOptions } from './css'; + // Options export type { InteractOptions } from './handlers'; + +// Plugins +export type { + InteractPlugin, + InteractPluginContext, + InteractPluginCleanup, + InteractPluginConfigMap, + InteractPluginStyleContext, + InteractPluginStyleGenerator, + InteractPluginStyles, + PluginFields, +} from './plugins'; diff --git a/packages/interact/src/types/index.ts b/packages/interact/src/types/index.ts index ddff07fc..b7341d50 100644 --- a/packages/interact/src/types/index.ts +++ b/packages/interact/src/types/index.ts @@ -1,6 +1,7 @@ export * from './triggers'; export * from './effects'; export * from './config'; +export * from './plugins'; export * from './controller'; export * from './handlers'; export * from './css'; diff --git a/packages/interact/src/types/plugins.ts b/packages/interact/src/types/plugins.ts new file mode 100644 index 00000000..02bfcf22 --- /dev/null +++ b/packages/interact/src/types/plugins.ts @@ -0,0 +1,96 @@ +/** + * Generic plugin bridge types. + * + * Interact knows nothing about any specific plugin. A plugin is registered by name via + * `Interact.use(name, plugin)` and invoked whenever an interaction/effect carries a matching + * `$`-prefixed field (e.g. `$splitText` → the `splitText` plugin). The plugin receives the raw + * field value plus a context describing where it was found, and may return a cleanup function that + * Interact runs on disconnect/teardown. + */ + +import { CSSRuleData } from './css'; + +/** + * Prefix that marks a config field as plugin config. A field named `$` routes its value to + * the plugin registered under ``. NOTE: the value here and the `` `$${string}` `` literal in + * {@link PluginFields} must stay in sync. + */ +export const PLUGIN_FIELD_PREFIX = '$'; + +export type InteractPluginContext = { + /** The interaction's (or effect target's) resolved root element. */ + root: HTMLElement; + /** The interaction (or effect) key the plugin was found under. */ + key: string; + /** Whether the plugin was declared on an interaction or on an effect. */ + scope: 'interaction' | 'effect'; + /** The full interaction or effect config object the plugin field was found on. */ + config: Record; +}; + +/** Called on disconnect/teardown to undo whatever the plugin did (e.g. revert a DOM mutation). */ +export type InteractPluginCleanup = () => void; + +/** + * A plugin is a plain callback. It receives the value of its `$`-prefixed config field and a + * context, and may return a cleanup function. Interact is agnostic to the shape of `value`. + */ +export type InteractPlugin = ( + value: unknown, + context: InteractPluginContext, +) => void | InteractPluginCleanup; + +/** + * Context passed to a plugin's SSR style generator (see {@link InteractPluginStyleGenerator}). + * Unlike {@link InteractPluginContext}, there is no live DOM — only the selector that scopes to + * the element the plugin field is on. + */ +export type InteractPluginStyleContext = { + /** The interaction (or effect target) key the plugin field was found on. */ + key: string; + /** Whether the plugin field was declared on an interaction or on an effect. */ + scope: 'interaction' | 'effect'; + /** The full interaction or effect config object the plugin field was found on. */ + config: Record; +}; + +/** + * A plugin's **build-time** styling callback, passed to `generate()` (NOT the same callback given + * to `Interact.use()`). Given the raw `$`-prefixed field value and a context, it returns initial + * CSS rule(s) for the element — e.g. hiding the pre-plugin content to prevent FOUC before an + * entrance animation. Interact emits the returned rules verbatim and never inspects `value`. + */ +export type InteractPluginStyleGenerator = ( + value: unknown, + context: InteractPluginStyleContext, +) => Pick[]; + +/** Map of plugin name → SSR style generator, passed as `generate()`'s `plugins` option. */ +export type InteractPluginStyles = Record; + +/** + * Consumers augment this interface (via declaration merging) to type the config values of the + * plugins they register. Keys are the **unprefixed** plugin names, e.g.: + * + * ```ts + * declare module '@wix/interact' { + * interface InteractPluginConfigMap { splitText: { container: string } } + * } + * ``` + * + * That types the `$splitText` field. It is intentionally empty by default — Interact ships no + * built-in plugins. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface InteractPluginConfigMap {} + +/** + * The `$`-prefixed plugin fields allowed on interactions and effects. Plugins registered through + * augmentation keep their value types (as `$`); any other `$`-prefixed field is still + * allowed at runtime with an `unknown` value. + */ +export type PluginFields = { + [K in keyof InteractPluginConfigMap as `$${K & string}`]?: InteractPluginConfigMap[K]; +} & { + [pluginField: `$${string}`]: unknown; +}; diff --git a/packages/interact/test/css.spec.ts b/packages/interact/test/css.spec.ts index dc57ede2..934e3efa 100644 --- a/packages/interact/test/css.spec.ts +++ b/packages/interact/test/css.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { generate, _generate, DEFAULT_INITIAL } from '../src/core/css'; import type { InteractConfig, CSSRuleData } from '../src/types'; @@ -435,9 +435,7 @@ describe('css._generate', () => { const { cssRules } = _generate(config); - const initialRule = cssRules.find( - (r) => r.dataInteractEnterSelector === ':not([data-interact-enter])', - )!; + const initialRule = cssRules.find((r) => r.selectorSuffix === ':not([data-interact-enter])')!; expect(initialRule).toBeDefined(); DEFAULT_INITIAL.forEach(({ name, value, important }) => { @@ -448,7 +446,7 @@ describe('css._generate', () => { }); const animationRule = cssRules.find( - (r) => r.dataInteractEnterSelector === ':not([data-interact-enter="done"])', + (r) => r.selectorSuffix === ':not([data-interact-enter="done"])', )!; const animDeclOnInitial = findDecl(animationRule.declarations, (d) => isAnimationProp(d.name), @@ -480,7 +478,7 @@ describe('css._generate', () => { const { cssRules } = _generate(config); - expect(cssRules.every((r) => !r.dataInteractEnterSelector)).toBe(true); + expect(cssRules.every((r) => !r.selectorSuffix)).toBe(true); const effectRule = cssRules.find((r) => r.declarations.some((d) => isAnimationProp(d.name)))!; expect(effectRule).toBeDefined(); @@ -500,7 +498,7 @@ describe('css._generate', () => { const { cssRules } = _generate(config); - expect(cssRules.every((r) => !r.dataInteractEnterSelector)).toBe(true); + expect(cssRules.every((r) => !r.selectorSuffix)).toBe(true); }); }); @@ -662,7 +660,7 @@ describe('css._generate', () => { const { cssRules } = _generate(config); - expect(cssRules.every((r) => !r.dataInteractEnterSelector)).toBe(true); + expect(cssRules.every((r) => !r.selectorSuffix)).toBe(true); }); it('should emit auto duration in animation shorthand for viewProgress (SSR-safe)', () => { @@ -805,7 +803,7 @@ describe('css._generate', () => { const { cssRules } = _generate(config); const initialRule = cssRules.find( - (r) => r.dataInteractEnterSelector === ':not([data-interact-enter="done"])', + (r) => r.selectorSuffix === ':not([data-interact-enter="done"])', )!; expect(initialRule).toBeDefined(); @@ -1160,7 +1158,7 @@ describe('css._generate', () => { }; const { cssRules } = _generate(config); - const initialRule = cssRules.find((r) => r.dataInteractEnterSelector)!; + const initialRule = cssRules.find((r) => r.selectorSuffix)!; expect(initialRule).toBeDefined(); expect(initialRule.media).toContain('min-width: 1024px'); @@ -1583,4 +1581,165 @@ describe('css._generate', () => { expect(coordListRules).toHaveLength(1); }); }); + + describe('options argument', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'el', + trigger: 'click', + effects: [{ effectId: 'e1' }], + }, + ], + }; + + it('should default useFirstChild to true when no options are passed', () => { + const { cssRules } = _generate(config); + + expect(cssRules.find((r) => r.childSelector === '> :first-child')).toBeDefined(); + }); + + it('should treat a boolean options argument as the legacy useFirstChild', () => { + expect( + _generate(config, false).cssRules.find((r) => r.childSelector === '> :first-child'), + ).toBeUndefined(); + expect( + _generate(config, true).cssRules.find((r) => r.childSelector === '> :first-child'), + ).toBeDefined(); + }); + + it('should read useFirstChild from an options object', () => { + expect( + _generate(config, { useFirstChild: false }).cssRules.find( + (r) => r.childSelector === '> :first-child', + ), + ).toBeUndefined(); + expect( + _generate(config, { useFirstChild: true }).cssRules.find( + (r) => r.childSelector === '> :first-child', + ), + ).toBeDefined(); + }); + + it('should default useFirstChild to true when the options object omits it', () => { + const { cssRules } = _generate(config, { plugins: {} }); + + expect(cssRules.find((r) => r.childSelector === '> :first-child')).toBeDefined(); + }); + }); + + describe('plugin styles (generate `plugins` option)', () => { + it('appends CSS from an interaction-level $-field generator, without inspecting the value', () => { + const calls: Array<{ value: unknown; ctx: any }> = []; + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + $splitText: { container: '.title', type: 'chars' }, + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], + }, + ], + }; + + const result = generate(config, { + plugins: { + splitText: (value, ctx) => { + calls.push({ value, ctx }); + const { container } = value as { container: string }; + return [ + { + declarations: [{ name: 'visibility', value: 'hidden' }], + selectorSuffix: ` ${container}`, + }, + ]; + }, + }, + }); + + expect(calls).toHaveLength(1); + expect(calls[0].value).toEqual({ container: '.title', type: 'chars' }); + expect(calls[0].ctx.key).toBe('hero'); + expect(calls[0].ctx.scope).toBe('interaction'); + expect(result).toContain('[data-interact-key="hero"] .title {\nvisibility: hidden;\n}'); + }); + + it('does nothing when no plugins option is passed', () => { + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + $splitText: { container: '.title' }, + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], + }, + ], + }; + + expect(() => generate(config)).not.toThrow(); + expect(generate(config)).not.toContain('.title'); + }); + + it('skips $-fields with no matching generator', () => { + const splitText = vi.fn(() => [ + { + declarations: [{ name: 'name', value: 'value' }], + selectorSuffix: '.x', + }, + ]); + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + $unknownPlugin: { foo: 1 }, + effects: [{ effectId: 'fadeIn', namedEffect: { type: 'fadeIn' } }], + }, + ], + }; + + const result = generate(config, { plugins: { splitText } }); + expect(splitText).not.toHaveBeenCalled(); + expect(result).not.toContain('.x {'); + }); + + it('routes effect-level $-fields with the resolved target key and effect scope', () => { + const seen: Array<{ key: string; scope: string }> = []; + const config: InteractConfig = { + effects: {}, + interactions: [ + { + key: 'source', + trigger: 'viewEnter', + effects: [ + { + key: 'target', + $splitText: { container: '.h' }, + effectId: 'fadeIn', + namedEffect: { type: 'fadeIn' }, + }, + ], + }, + ], + }; + + generate(config, { + plugins: { + splitText: (_value, ctx) => { + seen.push({ key: ctx.key, scope: ctx.scope }); + return []; + }, + }, + }); + + expect(seen).toContainEqual({ + key: 'target', + scope: 'effect', + }); + }); + }); }); diff --git a/packages/interact/test/cssUtils.spec.ts b/packages/interact/test/cssUtils.spec.ts index 6c13107d..95b4bb3c 100644 --- a/packages/interact/test/cssUtils.spec.ts +++ b/packages/interact/test/cssUtils.spec.ts @@ -224,7 +224,7 @@ describe('CSSRuleToString', () => { it('should serialize important declarations when flagged', () => { const rule: CSSRuleData = { key: 'my-el', - dataInteractEnterSelector: ':not([data-interact-enter])', + selectorSuffix: ':not([data-interact-enter])', declarations: [ { name: 'visibility', value: 'hidden', important: true }, { name: 'transform', value: 'none', important: true }, @@ -235,10 +235,10 @@ describe('CSSRuleToString', () => { expect(CSSRuleToString(rule)).toEqual(expected); }); - it('should dataInteractEnterSelector when provided', () => { + it('should add selectorSuffix when provided', () => { const rule: CSSRuleData = { key: 'my-el', - dataInteractEnterSelector: ':not([data-interact-enter="done"])', + selectorSuffix: ':not([data-interact-enter="done"])', declarations: [{ name: 'opacity', value: '0' }], }; const expected = @@ -282,7 +282,7 @@ describe('CSSRuleToString', () => { const rule: CSSRuleData = { key: 'my-el', childSelector: '.child', - dataInteractEnterSelector: ':not([data-interact-enter="done"])', + selectorSuffix: ':not([data-interact-enter="done"])', states: ['hover'], media: '(min-width: 1024px)', declarations: [ diff --git a/packages/interact/test/plugins.spec.ts b/packages/interact/test/plugins.spec.ts new file mode 100644 index 00000000..139d2430 --- /dev/null +++ b/packages/interact/test/plugins.spec.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Interact, add, remove } from '../src/index'; +import type { InteractConfig, InteractPluginContext } from '../src/types'; +import TRIGGER_TO_HANDLER_MODULE_MAP from '../src/handlers'; + +// Mock @wix/motion so the trigger handlers can run without a real animation engine. +vi.mock('@wix/motion', () => { + const mock: any = { + getWebAnimation: vi.fn().mockReturnValue({ + play: vi.fn(), + cancel: vi.fn(), + onFinish: vi.fn(), + pause: vi.fn(), + reverse: vi.fn(), + progress: vi.fn(), + persist: vi.fn(), + isCSS: false, + playState: 'idle', + ready: Promise.resolve(), + }), + getElementCSSAnimation: vi.fn().mockReturnValue(null), + getCSSAnimation: vi.fn().mockReturnValue(null), + prepareAnimation: vi.fn(), + getScrubScene: vi.fn().mockReturnValue({}), + getEasing: vi.fn().mockImplementation((v) => v), + getJsEasing: vi.fn().mockImplementation((v) => v), + getAnimation: vi.fn().mockImplementation((target, options, trigger, reducedMotion) => { + return mock.getWebAnimation(target, options, trigger, { reducedMotion }); + }), + createAnimationGroups: vi.fn().mockReturnValue([]), + getSequence: vi.fn().mockReturnValue({ animationGroups: [], addGroups: vi.fn() }), + registerEffects: vi.fn(), + MotionKeyframeEffect: class {}, + TriggerVariant: {}, + }; + + return mock; +}); + +vi.mock('kuliso', () => ({ + Pointer: vi.fn().mockImplementation(() => ({ start: vi.fn(), destroy: vi.fn() })), +})); + +vi.mock('fizban', () => ({ + Scroll: vi.fn().mockImplementation(() => ({ start: vi.fn(), end: vi.fn() })), +})); + +describe('interact plugin bridge', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + Interact.destroy(); + Interact.forceReducedMotion = false; + Interact.allowA11yTriggers = false; + }); + + describe('registry (use / getPlugin)', () => { + it('registers and retrieves a plugin by name', () => { + const plugin = vi.fn(); + Interact.use('demo-registry', plugin); + + expect(Interact.getPlugin('demo-registry')).toBe(plugin); + }); + + it('returns undefined for an unregistered plugin name', () => { + expect(Interact.getPlugin('never-registered')).toBeUndefined(); + }); + }); + + describe('interaction-level plugins', () => { + it('invokes the plugin once with the config value and context on connect', () => { + const seen: Array<[unknown, InteractPluginContext]> = []; + Interact.use('demo', (value, ctx) => { + seen.push([value, ctx]); + }); + + const config: InteractConfig = { + interactions: [ + { + key: 'el', + trigger: 'hover', + $demo: { foo: 'bar' }, + effects: [{ key: 'el', namedEffect: { type: 'FadeIn' } as any, duration: 100 }], + }, + ], + }; + + const element = document.createElement('div'); + Interact.create(config); + add(element, 'el'); + + expect(seen).toHaveLength(1); + const [value, ctx] = seen[0]; + expect(value).toEqual({ foo: 'bar' }); + expect(ctx.root).toBe(element); + expect(ctx.key).toBe('el'); + expect(ctx.scope).toBe('interaction'); + expect((ctx.config as { trigger: string }).trigger).toBe('hover'); + }); + + it('runs before target resolution so plugin-created elements are targeted', () => { + // Plugin injects a `.pt` span; the effect selector targets it. If the effect resolves to + // the injected element, the plugin must have run first. + Interact.use('demo', (_value, ctx) => { + const span = document.createElement('span'); + span.className = 'pt'; + ctx.root.appendChild(span); + }); + + const config: InteractConfig = { + interactions: [ + { + key: 'el', + trigger: 'hover', + $demo: {}, + effects: [ + { key: 'el', selector: '.pt', namedEffect: { type: 'FadeIn' } as any, duration: 100 }, + ], + }, + ], + }; + + const element = document.createElement('div'); + const addSpy = vi.spyOn(TRIGGER_TO_HANDLER_MODULE_MAP.hover, 'add'); + + Interact.create(config); + add(element, 'el'); + + // handler.add(source, target, ...) — the resolved target must be the plugin-created span. + const targetedInjected = addSpy.mock.calls.some((call) => + (call[1] as HTMLElement)?.classList?.contains('pt'), + ); + expect(element.querySelector('.pt')).not.toBeNull(); + expect(targetedInjected).toBe(true); + }); + + it('runs the returned cleanup on disconnect', () => { + const cleanup = vi.fn(); + Interact.use('demo', (_value, ctx) => { + const span = document.createElement('span'); + span.className = 'pt'; + ctx.root.appendChild(span); + return () => { + span.remove(); + cleanup(); + }; + }); + + const config: InteractConfig = { + interactions: [ + { + key: 'el', + trigger: 'hover', + $demo: {}, + effects: [{ key: 'el', namedEffect: { type: 'FadeIn' } as any, duration: 100 }], + }, + ], + }; + + const element = document.createElement('div'); + Interact.create(config); + add(element, 'el'); + + expect(element.querySelector('.pt')).not.toBeNull(); + expect(cleanup).not.toHaveBeenCalled(); + + remove('el'); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(element.querySelector('.pt')).toBeNull(); + }); + + it('re-applies after update (media-query reconnect) — disconnect resets the dedup guard', () => { + const apply = vi.fn(); + Interact.use('demo', () => { + apply(); + return vi.fn(); + }); + + const config: InteractConfig = { + interactions: [ + { + key: 'el', + trigger: 'hover', + $demo: {}, + effects: [{ key: 'el', namedEffect: { type: 'FadeIn' } as any, duration: 100 }], + }, + ], + }; + + const element = document.createElement('div'); + Interact.create(config); + add(element, 'el'); + expect(apply).toHaveBeenCalledTimes(1); + + const controller = Interact.getController('el'); + controller?.update(); // disconnect + connect + + expect(apply).toHaveBeenCalledTimes(2); + }); + }); + + describe('effect-level plugins (cross-element)', () => { + it('applies a plugin to the effect target element before resolving it', () => { + const roots: HTMLElement[] = []; + Interact.use('demo', (_value, ctx) => { + roots.push(ctx.root); + expect(ctx.scope).toBe('effect'); + }); + + const config: InteractConfig = { + interactions: [ + { + key: 'source', + trigger: 'hover', + effects: [ + { + key: 'target', + $demo: {}, + namedEffect: { type: 'FadeIn' } as any, + duration: 100, + }, + ], + }, + ], + }; + + const source = document.createElement('div'); + const target = document.createElement('div'); + Interact.create(config); + add(target, 'target'); + add(source, 'source'); + + // The plugin ran against the effect's target element, not the source. + expect(roots).toContain(target); + expect(roots).not.toContain(source); + }); + }); +}); diff --git a/packages/motion/rules/css-generation.md b/packages/motion/rules/css-generation.md index eb80db77..2f39eb61 100644 --- a/packages/motion/rules/css-generation.md +++ b/packages/motion/rules/css-generation.md @@ -180,7 +180,7 @@ effect: **`iterations: 0` on the options ⇒ `infinite` in the generated CSS; `u ## Relationship to `@wix/interact`'s `generate()` -`@wix/interact` builds its own `generate(config, useFirstChild)` on top of this primitive to emit +`@wix/interact` builds its own `generate(config, options?)` on top of this primitive to emit **complete** page CSS: `@keyframes`, animation/transition custom properties, `view-timeline` declarations, state-selector rules, coordinated-list aggregation, and — critically — **FOUC-prevention initial rules** that hide `viewEnter` + `once` entrance targets until the animation diff --git a/packages/splittext/README.md b/packages/splittext/README.md index d69f101d..141d30b7 100644 --- a/packages/splittext/README.md +++ b/packages/splittext/README.md @@ -162,6 +162,55 @@ When `preserveText` is `false`, `aria-label` is set on the container instead of Screen readers and crawlers see the original text; the split spans are hidden from the accessibility tree. `result.revert()` restores `originalHTML` captured at construction time. On re-split (`autoSplit`), plain text is re-read from the element so content changes are picked up. +## Using with `@wix/interact` + +`@wix/splittext` is standalone and has **no** dependency on `@wix/interact`. To drive it declaratively through an `InteractConfig`, a ready-made adapter ships from the `@wix/splittext/plugin` entry point. It's written against Interact's plugin contract _structurally_, so it stays assignable to `InteractPlugin` without importing `@wix/interact` — the two packages remain fully decoupled. + +Register `splitTextPlugin` once, before `Interact.create()`, then declare `$splitText` on any interaction or effect: + +```ts +import { Interact } from '@wix/interact'; +import { splitTextPlugin } from '@wix/splittext/plugin'; + +Interact.use('splitText', splitTextPlugin); + +Interact.create({ + effects: { 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 } }, + interactions: [ + { + key: 'hero', + trigger: 'viewEnter', + // `hideUntilReady` hides the container until the split runs, preventing a FOUC. + $splitText: { container: '.title', type: 'chars', hideUntilReady: true }, + sequences: [{ offset: 30, effects: [{ effectId: 'char-fade-up', selector: '.split-c' }] }], + }, + ], +}); +``` + +For SSR / build-time CSS, pass the companion `splitTextStyle` generator to `generate()` so the `hideUntilReady` container is hidden until the runtime split marks it ready: + +```ts +import { generate } from '@wix/interact'; +import { splitTextStyle } from '@wix/splittext/plugin'; + +const css = generate(config, { plugins: { splitText: splitTextStyle } }); +``` + +To type the `$splitText` config field, augment `InteractPluginConfigMap` from your app (the only place that imports both packages): + +```ts +import type { SplitTextPluginConfig } from '@wix/splittext/plugin'; + +declare module '@wix/interact' { + interface InteractPluginConfigMap { + splitText: SplitTextPluginConfig; + } +} +``` + +See the `@wix/interact` [Plugins guide](../interact/docs/guides/plugins.md) for the full contract. + ## License [MIT](https://github.com/wix/interact/blob/master/LICENSE) diff --git a/packages/splittext/package.json b/packages/splittext/package.json index a0d95dc8..1d17187f 100644 --- a/packages/splittext/package.json +++ b/packages/splittext/package.json @@ -16,6 +16,11 @@ "types": "./dist/types/react/index.d.ts", "import": "./dist/es/react.js", "require": "./dist/cjs/react.js" + }, + "./plugin": { + "types": "./dist/types/plugin/index.d.ts", + "import": "./dist/es/plugin.js", + "require": "./dist/cjs/plugin.js" } }, "files": [ @@ -71,6 +76,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "^4.0.14", + "@wix/interact": "^2.5.5", "jsdom": "^24.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/splittext/src/plugin/index.ts b/packages/splittext/src/plugin/index.ts new file mode 100644 index 00000000..f9889671 --- /dev/null +++ b/packages/splittext/src/plugin/index.ts @@ -0,0 +1,109 @@ +/** + * Interact plugin bridge for `@wix/splittext` — ships from `@wix/splittext/plugin`. + * + * Lets `@wix/splittext` be driven declaratively through an `@wix/interact` config's `$splitText` + * field, so apps can reuse the adapter instead of copy-pasting it. + * + * IMPORTANT: this module does NOT import `@wix/interact` — the two packages stay fully decoupled. + * The callbacks below are typed against the minimal *structural* shapes Interact relies on, so + * they are assignable to Interact's `InteractPlugin` / `InteractPluginStyleGenerator` without a + * dependency on it. A consumer that also has `@wix/interact` "resolves the typing" on its side: + * it ties the `$splitText` config value to {@link SplitTextPluginConfig} via declaration merging + * on `InteractPluginConfigMap` (see the demo's `plugins/splitText.ts`). + */ + +import { splitText } from '../splitText'; +import type { SplitTextOptions, SplitTextResult } from '../types'; + +/** 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; + +/** + * Minimal structural mirror of the runtime context Interact passes to a plugin (a subset of + * Interact's `InteractPluginContext`). Only `root` is read here; typing it as a subset keeps + * {@link splitTextPlugin} assignable to `InteractPlugin` without importing `@wix/interact`. + */ +type PluginContext = { root: HTMLElement }; + +/** + * Minimal structural mirror of the CSS rule Interact's `generate()` expects back from a style + * generator — matches `Pick`. Kept local so this + * module stays free of `@wix/interact`. + */ +type PluginStyleRule = { + declarations: { name: string; value: string | number }[]; + selectorSuffix?: string; +}; + +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 '@wix/splittext/plugin'; + * Interact.use('splitText', splitTextPlugin); + * ``` + */ +export const splitTextPlugin = (value: unknown, { root }: PluginContext): void | (() => void) => { + const { container, hideUntilReady, ...options } = value as SplitTextPluginConfig; + + const elements = root.querySelectorAll(container); + + if (!elements.length) { + return; + } + + const results: SplitTextResult[] = []; + elements.forEach((element) => { + results.push(splitText(element, options)); + }); + + // Reveal the container (see splitTextStyle) now that it holds the individually-animated spans. + if (hideUntilReady) { + elements.forEach((element) => element.setAttribute(READY_ATTR, '')); + } + + // Interact runs this on disconnect/teardown, restoring the original text. + return () => { + results.forEach((result) => result.revert()); + elements.forEach((element) => 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 '@wix/splittext/plugin'; + * const css = generate(config, { plugins: { splitText: splitTextStyle } }); + * ``` + */ +export const splitTextStyle = (value: unknown): PluginStyleRule[] => { + const { container, hideUntilReady } = value as SplitTextPluginConfig; + + if (!hideUntilReady) { + return []; + } + + return [ + { + declarations: [{ name: 'visibility', value: 'hidden' }], + selectorSuffix: ` ${container}:not([${READY_ATTR}])`, + }, + ]; +}; diff --git a/packages/splittext/test/splitText.integration.spec.ts b/packages/splittext/test/splitText.integration.spec.ts new file mode 100644 index 00000000..451090e9 --- /dev/null +++ b/packages/splittext/test/splitText.integration.spec.ts @@ -0,0 +1,120 @@ +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/plugin'; + +// 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, { plugins: { splitText: splitTextStyle } }); + expect(css).toContain( + '[data-interact-key="hero"] .title:not([data-splittext-ready]) {\nvisibility: hidden;\n}', + ); + + // 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, { plugins: { splitText: splitTextStyle } }); + expect(css).not.toContain('data-splittext-ready'); + }); +}); diff --git a/packages/splittext/vite.config.ts b/packages/splittext/vite.config.ts index a3729064..4b9831ac 100644 --- a/packages/splittext/vite.config.ts +++ b/packages/splittext/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ entry: { index: path.resolve(__dirname, 'src/index.ts'), react: path.resolve(__dirname, 'src/react/index.ts'), + plugin: path.resolve(__dirname, 'src/plugin/index.ts'), }, formats: ['es', 'cjs'], }, diff --git a/skills/interactor/SKILL.md b/skills/interactor/SKILL.md index 4251a271..8816ad8e 100644 --- a/skills/interactor/SKILL.md +++ b/skills/interactor/SKILL.md @@ -237,7 +237,7 @@ animation no-ops. Apply them every time, even if you don't open a reference file selective `import { FadeIn, … }` (tree-shakeable) over `import * as presets` in bundled apps. -2. **`generate(config, useFirstChild)` parity** — pass `true` for the **web** +2. **`generate(config, useFirstChild)` parity** (or `generate(config, { useFirstChild })`) — pass `true` for the **web** (``) entry point, `false` for **vanilla** and **React**. Backwards = the FOUC-prevention selectors target the wrong node and break. diff --git a/skills/interactor/references/config-schema.md b/skills/interactor/references/config-schema.md index 8db1c478..2b7bbc99 100644 --- a/skills/interactor/references/config-schema.md +++ b/skills/interactor/references/config-schema.md @@ -343,7 +343,7 @@ container. ## CSS generation & FOUC -`generate(config, useFirstChild = true)` returns a complete CSS string for **all** +`generate(config, options?)` returns a complete CSS string for **all** interactions: `@keyframes`, animation/transition custom properties, native `view-timeline` declarations for `viewProgress`, state-selector rules, coordinated list aggregation, and FOUC initial rules. @@ -359,7 +359,10 @@ const css = generate(config, true); // true for web; false for vanilla/React **`useFirstChild`:** `true` for the **web** (``) entry point — selectors target `:first-child`; `false` for **vanilla** and **React**. The default -is `true`, so vanilla/React callers must pass `false` explicitly. +is `true`, so vanilla/React callers must pass `false` explicitly. Pass it as a bare +boolean (`generate(config, false)`) or in the options bag +(`generate(config, { useFirstChild: false })`); the bag also carries `plugins`, +a map of plugin name → SSR style generator for `$` config fields. **FOUC prevention (viewEnter + once):** For entrance animations where source and target are the **same** element, `generate()` emits author-important initial rules @@ -404,7 +407,7 @@ import { add, remove, generate } from '@wix/interact'; add(element: HTMLElement, key?: string): void; // key defaults to element.dataset.interactKey remove(key: string): void; // unbind everything for a key -generate(config: InteractConfig, useFirstChild?: boolean): string; +generate(config: InteractConfig, options?: boolean | { useFirstChild?: boolean; plugins?: InteractPluginStyles }): string; ``` **`Interact.setup(options)`:** diff --git a/yarn.lock b/yarn.lock index c75f8695..c343ebaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1359,6 +1359,7 @@ __metadata: "@types/react-dom": "npm:^18.3.0" "@vitejs/plugin-react": "npm:^4.3.4" "@wix/interact": "npm:^2.5.5" + "@wix/splittext": "npm:^0.1.0" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" typescript: "npm:^5.9.3" @@ -1481,7 +1482,7 @@ __metadata: languageName: unknown linkType: soft -"@wix/splittext@workspace:packages/splittext": +"@wix/splittext@npm:^0.1.0, @wix/splittext@workspace:packages/splittext": version: 0.0.0-use.local resolution: "@wix/splittext@workspace:packages/splittext" dependencies: @@ -1491,6 +1492,7 @@ __metadata: "@types/react-dom": "npm:^18.3.0" "@vitejs/plugin-react": "npm:^4.3.4" "@vitest/coverage-v8": "npm:^4.0.14" + "@wix/interact": "npm:^2.5.5" jsdom: "npm:^24.0.0" react: "npm:^18.3.1" react-dom: "npm:^18.3.1"