Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@wix/interact": "^2.5.4",
"@wix/splittext": "^0.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand Down
85 changes: 85 additions & 0 deletions apps/demo/src/plugins/splitTextPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { splitText, type SplitTextOptions, type SplitTextResult } from '@wix/splittext';
import type { InteractPlugin, InteractPluginStyleGenerator } from '@wix/interact';

/** Config accepted under `$splitText` in an InteractConfig on an interaction or effect. */
export type SplitTextPluginConfig = {
container: string;
/**
* Hide the container until the split has been applied, to prevent a flash of the un-split text
* before an entrance/scroll animation runs. Emits SSR CSS via {@link splitTextStyle} and is
* revealed once the runtime plugin marks the container ready.
*/
hideUntilReady?: boolean;
} & SplitTextOptions;

const READY_ATTR = 'data-splittext-ready';

/**
* Runtime adapter that lets `@wix/splittext` be driven through an InteractConfig `$splitText` field.
*
* Register once, before `Interact.create()`:
*
* ```ts
* import { Interact } from '@wix/interact';
* import { splitTextPlugin } from './plugins/splitTextPlugin';
* Interact.use('splitText', splitTextPlugin);
* ```
*
* This module is the ONLY place that imports both packages. `@wix/interact` never imports
* `@wix/splittext` and vice-versa — the plugin bridge keeps them fully decoupled.
*/
export const splitTextPlugin: InteractPlugin = (value, { root }) => {
const { container, hideUntilReady, ...options } = value as SplitTextPluginConfig;

const element = root.querySelector<HTMLElement>(container);

if (!element) {
return;
}

const result: SplitTextResult = splitText(element, options);

// Reveal the container (see splitTextStyle) now that it holds the individually-animated spans.
if (hideUntilReady) {
element.setAttribute(READY_ATTR, '');
}

// Interact runs this on disconnect/teardown, restoring the original text.
return () => {
result.revert();
element.removeAttribute(READY_ATTR);
};
};

/**
* Build-time (SSR) styling for `$splitText`, passed to `generate()` — NOT the same callback as the
* runtime `splitTextPlugin` above. When `hideUntilReady` is set, hides the container until the
* runtime plugin has split it, preventing a flash of un-split text before the animation.
*
* ```ts
* import { generate } from '@wix/interact';
* import { splitTextStyle } from './plugins/splitTextPlugin';
* const css = generate(config, true, { splitText: splitTextStyle });
* ```
*/
export const splitTextStyle: InteractPluginStyleGenerator = (value, _) => {
const { container, hideUntilReady } = value as SplitTextPluginConfig;

if (!hideUntilReady) {
return [];
}

return [
{
declarations: [{ name: 'visibility', value: 'hidden' }],
selectorSuffix: ` ${container}:not([${READY_ATTR}])`,
},
];
};

// Type the `$splitText` value so configs get autocomplete + checking.
declare module '@wix/interact' {
interface InteractPluginConfigMap {
splitText: SplitTextPluginConfig;
}
}
120 changes: 120 additions & 0 deletions apps/demo/test/splitText.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -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/plugins/splitTextPlugin';

// End-to-end proof of the plugin bridge with the REAL @wix/splittext: splitText mutates the DOM,
// Interact resolves the generated spans, and disconnect reverts the split. The animation engine
// runs for real but no presets are registered here, so `getAnimation` logs a benign
// "FadeIn not found in registry" — irrelevant to the split/resolve/revert behavior under test.
describe('splitText through the Interact plugin bridge (real @wix/splittext)', () => {
beforeEach(() => {
Interact.use('splitText', splitTextPlugin);
});

afterEach(() => {
Interact.destroy();
});

it('splits the container into char spans that the effect selector targets, then reverts', () => {
const element = document.createElement('div');
element.innerHTML = '<h1 class="title">Hi</h1>';
document.body.appendChild(element);

const config: InteractConfig = {
interactions: [
{
key: 'hero',
trigger: 'hover',
$splitText: { container: '.title', type: 'chars' },
effects: [
{
key: 'hero',
selector: '.split-c',
namedEffect: { type: 'FadeIn' } as never,
duration: 300,
},
],
},
],
};

Interact.create(config);
add(element, 'hero');

// Real splitText produced char spans inside the container.
const chars = element.querySelectorAll('.split-c');
expect(chars.length).toBeGreaterThanOrEqual(2); // "H", "i"

// Teardown reverts: the split spans are gone and the original text is restored.
remove('hero');
expect(element.querySelectorAll('.split-c').length).toBe(0);
expect(element.querySelector('.title')?.textContent).toContain('Hi');

document.body.removeChild(element);
});

it('generate() emits SSR FOUC-prevention CSS via splitTextStyle, matched by the runtime marker', () => {
// A `hover` trigger keeps the runtime path off the (jsdom-unsupported) sequence engine;
// the `hideUntilReady` marker is trigger-independent. The SSR rule is emitted for any trigger.
const config: InteractConfig = {
interactions: [
{
key: 'hero',
trigger: 'hover',
$splitText: { container: '.title', type: 'chars', hideUntilReady: true },
effects: [
{
key: 'hero',
selector: '.split-c',
namedEffect: { type: 'FadeIn' } as never,
duration: 300,
},
],
},
],
};

// SSR: the container is hidden until the split marks it ready.
const css = generate(config, true, { splitText: splitTextStyle });
expect(css).toContain(
'[data-interact-key="hero"] .title:not([data-splittext-ready]) { visibility: hidden; }',
);

// Runtime: after the plugin splits, the container carries the marker, so the hide rule
// stops matching (the generated spans handle their own entrance visibility).
const element = document.createElement('div');
element.innerHTML = '<h1 class="title">Hi</h1>';
document.body.appendChild(element);

Interact.create(config);
add(element, 'hero');

expect(element.querySelector('.title')?.hasAttribute('data-splittext-ready')).toBe(true);
expect(element.querySelectorAll('.split-c').length).toBeGreaterThanOrEqual(2);

remove('hero');
expect(element.querySelector('.title')?.hasAttribute('data-splittext-ready')).toBe(false);

document.body.removeChild(element);
});

it('generate() omits the hide rule when hideUntilReady is not set', () => {
const config: InteractConfig = {
effects: { 'char-fade-up': { namedEffect: { type: 'FadeIn' }, duration: 400 } },
interactions: [
{
key: 'hero',
trigger: 'viewEnter',
$splitText: { container: '.title', type: 'chars' },
sequences: [
{ offset: 30, effects: [{ effectId: 'char-fade-up', selector: '.split-c' }] },
],
},
],
};

const css = generate(config, true, { splitText: splitTextStyle });
expect(css).not.toContain('data-splittext-ready');
});
});
8 changes: 8 additions & 0 deletions apps/demo/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'jsdom',
include: ['test/**/*.spec.ts'],
},
});
2 changes: 1 addition & 1 deletion packages/interact-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
129 changes: 65 additions & 64 deletions packages/interact-validate/src/schema/effects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { Keyframe, RangeOffset } from './primitives';
import { withPluginFields } from './plugins';
Comment thread
ameerf-wix marked this conversation as resolved.

export const StateActionType = z.enum(['add', 'remove', 'toggle', 'clear']);
export const TimeTriggerType = z.enum(['once', 'repeat', 'alternate', 'state']);
Expand Down Expand Up @@ -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,
Expand All @@ -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]);
Expand Down
Loading
Loading