Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 0 additions & 3 deletions packages/interact-validate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'`
| `VALID_MEDIA_QUERIES` | `INVALID_MEDIA_QUERY` | error |
| `SAME_ELEMENT_RETRIGGER` | `SAME_ELEMENT_RETRIGGER` | warning |
| `HIT_AREA_SHIFT` | `HIT_AREA_SHIFT` | warning |
| `SCROLL_RANGE` | `SCROLL_PRESET_MISSING_RANGE`, `SCROLL_PRESET_BAD_RANGE` | warning |
| `ANIMATION_END_GRAPH` | `ANIMATION_END_SELF_REFERENCE`, `ANIMATION_END_CYCLE` | warning |
| `ELEMENT_SELECTION` | `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER`, `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | warning |
| `STATE_EFFECT` | `EMPTY_STYLE_PROPERTIES`, `STATE_REMOVE_WITHOUT_EFFECT_ID` | warning |
Expand Down Expand Up @@ -214,8 +213,6 @@ These encode statically-detectable authoring pitfalls from the trigger rule file
| `DUPLICATE_KEYFRAME_NAME` | A `keyframeEffect.name` is reused across effects. | `UNIQUE_DEFINITION_IDS` |
| `SAME_ELEMENT_RETRIGGER` | `viewEnter` with a non-`once` `triggerType` on the same source+target element. | `SAME_ELEMENT_RETRIGGER` |
| `HIT_AREA_SHIFT` | `hover`/`pointerMove` `keyframeEffect` with a `translate`/`scale`/`matrix` transform on the same source+target element. | `HIT_AREA_SHIFT` |
| `SCROLL_PRESET_MISSING_RANGE` | A `*Scroll` `namedEffect` on `viewProgress` omits `range`. | `SCROLL_RANGE` |
| `SCROLL_PRESET_BAD_RANGE` | A scroll preset `range` is not `'in'`/`'out'`/`'continuous'`. | `SCROLL_RANGE` |
| `ANIMATION_END_SELF_REFERENCE` | An `animationEnd` interaction waits on an effect it also produces (never starts). | `ANIMATION_END_GRAPH` |
| `LIST_ITEM_SELECTOR_WITHOUT_CONTAINER` | `listItemSelector` present without `listContainer` (inert). | `ELEMENT_SELECTION` |
| `REDUNDANT_SELECTOR_WITH_LIST_ITEM` | `selector` ignored when `listContainer` + `listItemSelector` are both present. | `ELEMENT_SELECTION` |
Expand Down
4 changes: 2 additions & 2 deletions packages/interact-validate/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ const RULE_CODE_MAP: Record<string, string> = {
// Rule-derived semantic checks (see packages/interact/rules/*.md).
SAME_ELEMENT_RETRIGGER: 'SAME_ELEMENT_RETRIGGER',
HIT_AREA_SHIFT: 'HIT_AREA_SHIFT',
SCROLL_PRESET_MISSING_RANGE: 'SCROLL_RANGE',
SCROLL_PRESET_BAD_RANGE: 'SCROLL_RANGE',
ANIMATION_END_SELF_REFERENCE: 'ANIMATION_END_GRAPH',
ANIMATION_END_CYCLE: 'ANIMATION_END_GRAPH',
LIST_ITEM_SELECTOR_WITHOUT_CONTAINER: 'ELEMENT_SELECTION',
Expand All @@ -34,6 +32,8 @@ const RULE_CODE_MAP: Record<string, string> = {
INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME',
INVALID_INSET: 'VIEW_INSET',
FUNCTION_OFFSET_EASING: 'OFFSET_EASING',
NAMED_EFFECT_UNKNOWN_PARAM: 'NAMED_EFFECT_PARAMS',
NAMED_EFFECT_INVALID_PARAM: 'NAMED_EFFECT_PARAMS',
};

export function finalize(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,10 @@ import {
checkRedundantSelector,
checkPointerAxisIgnored,
} from './ignored';
import {
checkScrollPresetRange,
checkEmptyStyleProperties,
checkStateRemoveWithoutEffectId,
} from './partialData';
import { checkEmptyStyleProperties, checkStateRemoveWithoutEffectId } from './partialData';
import { checkRecommendedFill } from './recommendedPatterns';
import { findAnimationEndWarnings } from './animationEndGraph';
import { checkNamedEffectParams } from './namedEffectParams';

// Single traversal of top-level registry effects/sequences and per-interaction
// effects/sequences, supplying each node's `path`, whether it is a top-level
Expand Down Expand Up @@ -71,14 +68,14 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
: effect;
warnings.push(...checkSameElementRetrigger(path, resolvedEffect, owner));
warnings.push(...checkHitAreaShift(path, resolvedEffect, owner));
warnings.push(...checkScrollPresetRange(path, resolvedEffect, owner));
warnings.push(...checkListItemSelectorWithoutContainer(path, resolvedEffect));
warnings.push(...checkRedundantSelector(path, resolvedEffect));
warnings.push(...checkEmptyStyleProperties(path, effect));
warnings.push(...checkStateRemoveWithoutEffectId(path, effect));
warnings.push(...checkRecommendedFill(path, resolvedEffect, owner));
warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner));
warnings.push(...checkCSSPropertyNames(path, effect));
warnings.push(...checkNamedEffectParams(path, effect));
},
onSequence: (path, sequence, isTopLevel, owner) => {
const { sequenceId } = sequence;
Expand Down
99 changes: 99 additions & 0 deletions packages/interact-validate/src/semantic/namedEffectParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Interact } from '@wix/interact';
import type { AnyEffect, EffectParamSchema, EffectSchema, Path, SemanticIssue } from '../types';

const LENGTH_REGEX = /^-?\d*\.?\d+(px|%|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc)?$/i;
const ANGLE_REGEX = /^-?\d*\.?\d+(deg|rad|grad|turn)?$/i;
const CALC_REGEX = /^calc\(.+\)$/i;
const SIDES = ['top', 'right', 'bottom', 'left'];

const isNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value);
const matches = (value: unknown, regex: RegExp) =>
typeof value === 'string' && (regex.test(value.trim()) || CALC_REGEX.test(value.trim()));
const isLengthObject = (value: unknown): value is { value: unknown; unit: string } =>
typeof value === 'object' &&
value !== null &&
typeof (value as { unit?: unknown }).unit === 'string';

// [is valid, expected value description] per param type - unknown types are not validated
const PARAM_TYPES: Record<
string,
{
isValid: (value: unknown, param: EffectParamSchema) => boolean;
expected: (param: EffectParamSchema) => string;
}
> = {
number: {
isValid: (value, { min = -Infinity, max = Infinity, int }) =>
isNumber(value) && (!int || Number.isInteger(value)) && value >= min && value <= max,
expected: ({ min, max, int }) =>
`${int ? 'an integer' : 'a number'}${min !== undefined ? ` >= ${min}` : ''}${max !== undefined ? ` <= ${max}` : ''}`,
},
bool: {
isValid: (value) => typeof value === 'boolean',
expected: () => 'a boolean',
},
enum: {
isValid: (value, { values = [] }) => values.includes(value),
expected: ({ values = [] }) => `one of ${values.map((v) => JSON.stringify(v)).join(' | ')}`,
},
length: {
isValid: (value) =>
isNumber(value) ||
matches(value, LENGTH_REGEX) ||
(isLengthObject(value) && (isNumber(value.value) || matches(value.value, LENGTH_REGEX))),
expected: () => "a length (number, '<number><unit>', calc() or { value, unit })",
},
lengthObject: {
isValid: (value) => isLengthObject(value) && isNumber(value.value),
expected: () => 'a length object ({ value: number, unit })',
},
angle: {
isValid: (value) =>
isNumber(value) ||
matches(value, ANGLE_REGEX) ||
(typeof value === 'string' && SIDES.includes(value.trim())),
expected: () =>
`an angle (number, '<number>deg', calc() or ${SIDES.map((s) => `'${s}'`).join(' | ')})`,
},
};

const issue = (
path: Path,
domainCode: string,
message: string,
severity: SemanticIssue['severity'],
) => ({ code: 'custom', path, message, params: { domainCode }, severity }) as SemanticIssue;

// the namedEffect's params against the schema of the effect registered on Interact under its type
export function checkNamedEffectParams(path: Path, effect: AnyEffect): SemanticIssue[] {
const { type, ...params } = effect.namedEffect ?? {};
const module = typeof type === 'string' ? Interact.getRegisteredEffect?.(type, false) : null;
const schema = (module as { schema?: EffectSchema } | null | undefined)?.schema;
if (!schema) return [];

return Object.entries(params).flatMap(([key, value]) => {
const paramPath = [...path, 'namedEffect', key];
const param = schema[key];
if (!param) {
return [
issue(
paramPath,
'NAMED_EFFECT_UNKNOWN_PARAM',
`'${key}' is not a parameter of ${type} (expected one of ${Object.keys(schema).join(', ')}).`,
'warning',
),
];
}
const paramType = PARAM_TYPES[param.type];
if (value === undefined || !paramType || paramType.isValid(value, param)) return [];
return [
issue(
paramPath,
'NAMED_EFFECT_INVALID_PARAM',
`${type} \`${key}\` must be ${paramType.expected(param)}; got ${JSON.stringify(value)}.`,
'error',
),
];
});
}
41 changes: 1 addition & 40 deletions packages/interact-validate/src/semantic/partialData.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,4 @@
import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types';

const SCROLL_RANGE_VALUES = ['in', 'out', 'continuous'];

// Scroll presets (the `*Scroll` motion-presets) all end with `Scroll`; no other
// preset category does. Used to flag a missing/invalid `range` on `viewProgress`.
function isScrollPresetType(type: unknown): boolean {
return typeof type === 'string' && /Scroll$/.test(type);
}

// `*Scroll` namedEffect on viewProgress without a valid `range`
export function checkScrollPresetRange(
path: Path,
effect: AnyEffect,
owner?: AnyInteraction,
): SemanticIssue[] {
if (!owner || owner.trigger !== 'viewProgress') return [];
const named = effect.namedEffect;
if (!named || !isScrollPresetType(named.type)) return [];
if (named.range === undefined) {
return [
{
code: 'custom',
params: { domainCode: 'SCROLL_PRESET_MISSING_RANGE' },
path: [...path, 'namedEffect', 'range'],
message: `Scroll preset '${named.type}' on viewProgress requires \`range: 'in' | 'out' | 'continuous'\` (prefer 'continuous').`,
},
];
} else if (typeof named.range !== 'string' || !SCROLL_RANGE_VALUES.includes(named.range)) {
return [
{
code: 'custom',
params: { domainCode: 'SCROLL_PRESET_BAD_RANGE' },
path: [...path, 'namedEffect', 'range'],
message: `Scroll preset \`range\` must be 'in', 'out', or 'continuous' (prefer 'continuous'); got ${JSON.stringify(named.range)}.`,
},
];
}
return [];
}
import type { Path, SemanticIssue, AnyEffect } from '../types';

// state effect that toggles nothing (empty style arrays)
export function checkEmptyStyleProperties(path: Path, effect: AnyEffect): SemanticIssue[] {
Expand Down
11 changes: 11 additions & 0 deletions packages/interact-validate/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export type ValidationResult = {
errors: ValidationError[];
};

export type EffectParamSchema = {
type: string;
values?: readonly unknown[];
min?: number;
max?: number;
int?: boolean;
default?: unknown;
};

export type EffectSchema = Record<string, EffectParamSchema>;

export type ValidateOptions = {
strict?: boolean;
max?: number;
Expand Down
120 changes: 120 additions & 0 deletions packages/interact-validate/test/rules/namedEffectParams.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import { Interact } from '@wix/interact';
import { validateInteractConfig } from '../../src';

const Move = {
schema: {
from: { type: 'enum', values: ['left', 'right'], default: 'left' },
angle: { type: 'angle', default: 0 },
travel: { type: 'length', default: { value: 10, unit: 'px' } },
depth: { type: 'lengthObject', default: { value: 10, unit: 'px' } },
count: { type: 'number', int: true, min: 1, default: 1 },
scale: { type: 'number', min: 0, max: 1, default: 0.5 },
fade: { type: 'bool', default: true },
},
};

Interact.registerEffects({ Move, Unschemed: {} } as any);

const configWith = (namedEffect: Record<string, unknown>) => ({
interactions: [
{
key: 'el',
trigger: 'viewEnter',
effects: [
{ namedEffect: { type: 'Move', ...namedEffect }, duration: 400, fill: 'backwards' },
],
},
],
});

const paramErrors = (namedEffect: Record<string, unknown>) =>
validateInteractConfig(configWith(namedEffect)).errors.filter((e) =>
e.code.startsWith('NAMED_EFFECT_'),
);

describe('namedEffectParams', () => {
it('accepts valid values of every param type', () => {
expect(
paramErrors({
from: 'right',
angle: '45deg',
travel: { value: 20, unit: '%' },
depth: { value: -5, unit: 'em' },
count: 3,
scale: 1,
fade: false,
}),
).toEqual([]);
expect(paramErrors({ angle: 'top', travel: 'calc(100% - 10px)' })).toEqual([]);
expect(paramErrors({ angle: 90, travel: 12 })).toEqual([]);
expect(paramErrors({ angle: '-0.5turn', travel: '2rem' })).toEqual([]);
});

it('errors (NAMED_EFFECT_INVALID_PARAM) on values that do not match the schema', () => {
expect(validateInteractConfig(configWith({ scale: 2 })).valid).toBe(false);
const errors = paramErrors({
from: 'top',
angle: 'sideways',
travel: '10 apples',
scale: 2,
fade: 'yes',
depth: '10px',
count: 1.5,
});
expect(errors.map((e) => e.path.at(-1)).sort()).toEqual([
'angle',
'count',
'depth',
'fade',
'from',
'scale',
'travel',
]);
expect(errors.every((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM')).toBe(true);
expect(errors.every((e) => e.severity === 'error')).toBe(true);
expect(errors.find((e) => e.path.at(-1) === 'from')?.path).toEqual([
'interactions',
0,
'effects',
0,
'namedEffect',
'from',
]);
expect(paramErrors({ travel: { value: 'far', unit: 'px' } })).toHaveLength(1);
expect(paramErrors({ depth: { value: 'calc(1px + 2px)', unit: 'px' } })).toHaveLength(1);
expect(paramErrors({ depth: 10 })).toHaveLength(1);
});

it('warns (NAMED_EFFECT_UNKNOWN_PARAM) on params the schema does not declare', () => {
const [error] = paramErrors({ direction: 'left' });
expect(error.code).toBe('NAMED_EFFECT_UNKNOWN_PARAM');
expect(error.severity).toBe('warning');
expect(error.path).toEqual(['interactions', 0, 'effects', 0, 'namedEffect', 'direction']);
});

it('skips effects without a schema, or not registered', () => {
for (const type of ['Unschemed', 'NotRegistered']) {
const config = configWith({ anything: 1 });
config.interactions[0].effects[0].namedEffect.type = type;
const errors = validateInteractConfig(config).errors;
expect(errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]);
}
});

it('validates top-level effect definitions', () => {
const result = validateInteractConfig({
effects: { move: { namedEffect: { type: 'Move', scale: -1 } } },
interactions: [{ key: 'el', trigger: 'viewEnter', effects: [{ effectId: 'move' }] }],
});
const errors = result.errors.filter((e) => e.code === 'NAMED_EFFECT_INVALID_PARAM');
expect(errors.map((e) => e.path)).toEqual([['effects', 'move', 'namedEffect', 'scale']]);
});

it('can be turned off with severityOverrides', () => {
const result = validateInteractConfig(configWith({ scale: 5, direction: 'left' }), {
severityOverrides: { NAMED_EFFECT_PARAMS: 'off' },
});
expect(result.errors.filter((e) => e.code.startsWith('NAMED_EFFECT_'))).toEqual([]);
});
});
Loading
Loading