Skip to content

Commit 84626c8

Browse files
ameerf-wixydaniv
andauthored
Support both camel kebab casing (#281)
* docs + audit * normalizing all casing * removing unnecessary docs * Review fixes * Added rest of fixes * resolving conflicts --------- Co-authored-by: Yehonatan Daniv <maggotfish@gmail.com>
1 parent 5d2b8d9 commit 84626c8

30 files changed

Lines changed: 743 additions & 134 deletions

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
3939

4040
- Plugin fields: `$`-prefixed keys on interactions and effects are accepted; every other unknown key is still reported as `SCHEMA_UNRECOGNIZED_KEYS` (#275)
4141

42+
#### Changed
43+
44+
- `KEYFRAME_PROP_NOT_CAMEL_CASE` (rule category `KEYFRAME_STYLE`) is replaced by `INVALID_CSS_PROPERTY_NAME` (rule category `CSS_PROPERTY_NAME`): both camelCase and kebab-case CSS property names are valid input, so only names that are neither are reported, and the check now covers `transition.styleProperties` / `transitionProperties` names in addition to `keyframeEffect` keyframes
45+
- `severityOverrides` keyed on `KEYFRAME_STYLE` are now silently ignored. Rename the key to `CSS_PROPERTY_NAME` to keep an override in effect
46+
4247
### [0.1.2] - 2026-07-29
4348

4449
#### Added
@@ -86,6 +91,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
8691
#### Changed
8792

8893
- `generate(config, options?)`: the second argument now accepts an options bag — `{ useFirstChild?, plugins? }` — exported as the `GenerateOptions` (#275)
94+
- CSS property names may be authored in either camelCase or kebab-case in `transition.styleProperties`, `transitionProperties` and `keyframeEffect.keyframes`; state-effect properties are normalized to kebab-case for the generated CSS (state rules and the `transition:` shorthand) and keyframes to camelCase for WAAPI
95+
96+
#### Fixed
97+
98+
- camelCase property names in `transition.styleProperties` / `transitionProperties` are now normalized to kebab-case
99+
- CSS custom properties in keyframes (e.g. `--fooBar`) are no longer lower-cased when emitted into `@keyframes`
100+
- Vendor-prefixed keyframe properties (e.g. `webkitTextStroke`) now emit a valid CSS property name (`-webkit-text-stroke`)
89101

90102
### [2.5.5] - 2026-07-29
91103

@@ -299,6 +311,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
299311

300312
## @wix/motion
301313

314+
### [Unreleased]
315+
316+
#### Added
317+
318+
- `toCSSPropertyName()`, `toWAAPIPropertyName()` and `normalizeKeyframes()` utilities for converting CSS property names between their CSS and WAAPI forms
319+
320+
#### Changed
321+
322+
- Keyframe property names may be authored in either camelCase or kebab-case and are normalized to WAAPI's camelCase on every animation path — `keyframeEffect`, presets, and effects registered via `registerEffects()`
323+
302324
### [2.1.8] - 2026-07-29
303325

304326
#### Added

apps/playground/SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ For authoring custom `keyframeEffect` animations (`{ name: string; keyframes: Ke
339339
- Optional `offset` (0–1, step 0.01) — shown for 3+ keyframes
340340
- CSS property/value rows with text inputs
341341
- Property name autocomplete via `<datalist>` (opacity, transform, background-color, clip-path, filter, border-radius, etc.)
342-
- Kebab-case input auto-converts to camelCase on blur (Web Animations API requirement)
342+
- Kebab-case input auto-converts to camelCase on blur (display preference — Interact accepts either casing)
343343
- Add/remove property rows (min 1 per keyframe)
344344
- **Add Keyframe** button — appends an empty keyframe
345345
- Remove keyframe button (min 1 keyframe enforced)

packages/interact-validate/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Every issue is `'error'` or `'warning'`. `valid` is `true` **iff** no `'error'`
147147
| `STATE_EFFECT` | `EMPTY_STYLE_PROPERTIES`, `STATE_REMOVE_WITHOUT_EFFECT_ID` | warning |
148148
| `RECOMMENDED_FILL` | `RECOMMENDED_FILL_BOTH` | info |
149149
| `POINTER_AXIS` | `POINTER_AXIS_IGNORED` | warning |
150-
| `KEYFRAME_STYLE` | `KEYFRAME_PROP_NOT_CAMEL_CASE` | warning |
150+
| `CSS_PROPERTY_NAME` | `INVALID_CSS_PROPERTY_NAME` | warning |
151151
| `VIEW_INSET` | `INVALID_INSET` | warning |
152152

153153
Set a category to `'off'` to drop those issues, or `'warning'` / `'error'` to set their severity. **All other codes** (every `SCHEMA_*`, numeric, effect-source, and referential code) are not in a category and **cannot** be silenced or re-leveled via `severityOverrides` — they always emit at their built-in severity. Precedence: `'off'` first (drops the issue), then a `'warning'`/`'error'` override, then `strict` (forces the rest to `'error'`).
@@ -212,7 +212,7 @@ These encode statically-detectable authoring pitfalls from the trigger rule file
212212
| `RECOMMENDED_FILL_BOTH` | A scrubbed (`viewProgress`/`pointerMove`) or toggling (`alternate`/`repeat`/`state`) effect omits `fill: 'both'`. | `RECOMMENDED_FILL` |
213213
| `RECOMMENDED_FILL_BACKWARDS` | A `viewEnter` + `once` named/keyframe effect targeting another element or using a same-element delay omits `fill: 'backwards'` or `'both'`. | `RECOMMENDED_FILL` |
214214
| `POINTER_AXIS_IGNORED` | `pointerMove` `params.axis` set on a `namedEffect`/`customEffect` (axis only applies to `keyframeEffect`). | `POINTER_AXIS` |
215-
| `KEYFRAME_PROP_NOT_CAMEL_CASE` | A `keyframeEffect` property name is kebab-case (not WAAPI camelCase). | `KEYFRAME_STYLE` |
215+
| `INVALID_CSS_PROPERTY_NAME` | A keyframe or state-effect property name is neither camelCase nor kebab-case (both are accepted). | `CSS_PROPERTY_NAME` |
216216
| `INVALID_INSET` | `viewEnter` `params.inset` is not 1–4 CSS lengths/percentages. | `VIEW_INSET` |
217217

218218
## Usage recipes

packages/interact-validate/src/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const RULE_CODE_MAP: Record<string, string> = {
3131
RECOMMENDED_FILL_BOTH: 'RECOMMENDED_FILL',
3232
RECOMMENDED_FILL_BACKWARDS: 'RECOMMENDED_FILL',
3333
POINTER_AXIS_IGNORED: 'POINTER_AXIS',
34-
KEYFRAME_PROP_NOT_CAMEL_CASE: 'KEYFRAME_STYLE',
34+
INVALID_CSS_PROPERTY_NAME: 'CSS_PROPERTY_NAME',
3535
INVALID_INSET: 'VIEW_INSET',
3636
};
3737

packages/interact-validate/src/semantic/collectSemanticWarnings.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// `collectSemanticWarnings` (consumed by the schema `transform`).
55

66
import type { Path, SemanticIssue, AnyConfig, Visitors } from '../types';
7-
import { checkKeyframePropCamelCase, checkInvalidInset } from './cssSyntax';
7+
import { checkCSSPropertyNames, checkInvalidInset } from './cssSyntax';
88
import { checkSameElementRetrigger, checkHitAreaShift } from './fouc';
99
import {
1010
checkListItemSelectorWithoutContainer,
@@ -77,7 +77,7 @@ export function collectSemanticWarnings(config: AnyConfig): SemanticIssue[] {
7777
warnings.push(...checkStateRemoveWithoutEffectId(path, effect));
7878
warnings.push(...checkRecommendedFill(path, resolvedEffect, owner));
7979
warnings.push(...checkPointerAxisIgnored(path, resolvedEffect, owner));
80-
warnings.push(...checkKeyframePropCamelCase(path, effect));
80+
warnings.push(...checkCSSPropertyNames(path, effect));
8181
},
8282
onSequence: (path, sequence, isTopLevel, owner) => {
8383
const { sequenceId } = sequence;

packages/interact-validate/src/semantic/cssSyntax.ts

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,59 @@
11
import type { Path, SemanticIssue, AnyEffect, AnyInteraction } from '../types';
22

3-
// camelCase guidance: a keyframe property containing `-` is invalid for WAAPI
4-
// (e.g. `background-color`). CSS custom properties (`--*`) are allowed as-is.
3+
// Property names may be authored in camelCase (`backgroundColor`) or kebab-case
4+
// (`background-color`) - Interact normalizes either form to what the consuming
5+
// API needs. What cannot be normalized is a name that is neither, e.g.
6+
// `background-Color` or `BackgroundColor`. CSS custom properties (`--*`) are
7+
// case-sensitive and used verbatim.
58
const INSET_TOKEN = /^(auto|[+-]?(?:\d+\.?\d*|\.\d+)(?:%|[a-z]{1,5})?)$/i;
9+
const CAMEL_CASE_PROPERTY = /^[a-z][a-zA-Z0-9]*$/;
10+
const KEBAB_CASE_PROPERTY = /^-?[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
611

7-
function kebabToCamel(prop: string): string {
8-
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
12+
// note: the WAAPI keyframe keys that are not CSS property names (`offset`,
13+
// `easing`, `composite`) are plain lowercase words, so they pass the camelCase
14+
// check and need no special casing here.
15+
function isNormalizableProperty(name: string): boolean {
16+
return name.startsWith('--') || CAMEL_CASE_PROPERTY.test(name) || KEBAB_CASE_PROPERTY.test(name);
917
}
1018

11-
// keyframe property names must be camelCase (WAAPI)
12-
export function checkKeyframePropCamelCase(path: Path, effect: AnyEffect): SemanticIssue[] {
13-
const keyframes = effect.keyframeEffect?.keyframes;
14-
if (!Array.isArray(keyframes)) return [];
19+
function invalidPropertyName(path: Path, name: string): SemanticIssue {
20+
return {
21+
code: 'custom',
22+
params: { domainCode: 'INVALID_CSS_PROPERTY_NAME' },
23+
path,
24+
message: `CSS property '${name}' is neither camelCase (\`backgroundColor\`) nor kebab-case (\`background-color\`); both forms are accepted, this one is not.`,
25+
};
26+
}
27+
28+
// keyframe and state-effect property names must be resolvable to a CSS property
29+
export function checkCSSPropertyNames(path: Path, effect: AnyEffect): SemanticIssue[] {
1530
const result: SemanticIssue[] = [];
16-
keyframes.forEach((frame, ki) => {
17-
if (!frame || typeof frame !== 'object') return;
18-
Object.keys(frame).forEach((prop) => {
19-
if (!prop.startsWith('--') && prop.includes('-')) {
20-
result.push({
21-
code: 'custom',
22-
params: { domainCode: 'KEYFRAME_PROP_NOT_CAMEL_CASE' },
23-
path: [...path, 'keyframeEffect', 'keyframes', ki, prop],
24-
message: `Keyframe property '${prop}' must be camelCase for WAAPI; use '${kebabToCamel(prop)}'.`,
25-
});
26-
}
31+
const keyframes = effect.keyframeEffect?.keyframes;
32+
33+
if (Array.isArray(keyframes)) {
34+
keyframes.forEach((frame, ki) => {
35+
if (!frame || typeof frame !== 'object') return;
36+
Object.keys(frame).forEach((prop) => {
37+
if (isNormalizableProperty(prop)) return;
38+
result.push(invalidPropertyName([...path, 'keyframeEffect', 'keyframes', ki, prop], prop));
39+
});
40+
});
41+
}
42+
43+
const stateProperties: [Path, unknown][] = [
44+
[[...path, 'transition', 'styleProperties'], effect.transition?.styleProperties],
45+
[[...path, 'transitionProperties'], effect.transitionProperties],
46+
];
47+
48+
stateProperties.forEach(([propertiesPath, properties]) => {
49+
if (!Array.isArray(properties)) return;
50+
properties.forEach((property, pi) => {
51+
const name = (property as { name?: unknown })?.name;
52+
if (typeof name !== 'string' || isNormalizableProperty(name)) return;
53+
result.push(invalidPropertyName([...propertiesPath, pi, 'name'], name));
2754
});
2855
});
56+
2957
return result;
3058
}
3159

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { validateInteractConfig } from '../../src';
3+
4+
// CSS property names may be camelCase or kebab-case (Interact normalizes either
5+
// form); only names that are neither are reported. Source: every trigger rule.
6+
7+
const CODE = 'INVALID_CSS_PROPERTY_NAME';
8+
9+
function validateKeyframeProperty(property: string, value: string = 'red') {
10+
return validateInteractConfig({
11+
interactions: [
12+
{
13+
key: 'el',
14+
trigger: 'viewEnter',
15+
effects: [
16+
{
17+
duration: 400,
18+
keyframeEffect: { name: 'kf', keyframes: [{ [property]: value }] },
19+
},
20+
],
21+
},
22+
],
23+
});
24+
}
25+
26+
function validateStyleProperty(name: string) {
27+
return validateInteractConfig({
28+
interactions: [
29+
{
30+
key: 'el',
31+
trigger: 'hover',
32+
effects: [
33+
{
34+
transition: { duration: 200, styleProperties: [{ name, value: 'red' }] },
35+
},
36+
],
37+
},
38+
],
39+
});
40+
}
41+
42+
describe('cssSyntax — INVALID_CSS_PROPERTY_NAME', () => {
43+
it('warns for a keyframe property that is neither camelCase nor kebab-case', () => {
44+
const result = validateKeyframeProperty('background-Color');
45+
const err = result.errors.find((e) => e.code === CODE);
46+
47+
expect(err).toBeDefined();
48+
expect(err?.severity).toBe('warning');
49+
expect(err?.path).toEqual([
50+
'interactions',
51+
0,
52+
'effects',
53+
0,
54+
'keyframeEffect',
55+
'keyframes',
56+
0,
57+
'background-Color',
58+
]);
59+
expect(err?.message).toContain('background-Color');
60+
});
61+
62+
it('warns for a state-effect style property that is neither casing', () => {
63+
const result = validateStyleProperty('BackgroundColor');
64+
const err = result.errors.find((e) => e.code === CODE);
65+
66+
expect(err).toBeDefined();
67+
expect(err?.severity).toBe('warning');
68+
expect(err?.path).toEqual([
69+
'interactions',
70+
0,
71+
'effects',
72+
0,
73+
'transition',
74+
'styleProperties',
75+
0,
76+
'name',
77+
]);
78+
});
79+
80+
it('warns for a `transitionProperties` name that is neither casing', () => {
81+
const result = validateInteractConfig({
82+
interactions: [
83+
{
84+
key: 'el',
85+
trigger: 'hover',
86+
effects: [
87+
{
88+
transitionProperties: [{ name: 'Background Color', value: 'red', duration: 200 }],
89+
},
90+
],
91+
},
92+
],
93+
});
94+
const err = result.errors.find((e) => e.code === CODE);
95+
96+
expect(err).toBeDefined();
97+
expect(err?.path).toEqual(['interactions', 0, 'effects', 0, 'transitionProperties', 0, 'name']);
98+
});
99+
100+
describe('no warning for the documented valid patterns', () => {
101+
// `valid` must be asserted alongside the missing warning: a structural (zod) rejection
102+
// skips the semantic layer entirely, which would also produce zero INVALID_CSS_PROPERTY_NAME
103+
// issues - and would mean the casing was never accepted in the first place.
104+
function expectAccepted(result: ReturnType<typeof validateInteractConfig>) {
105+
expect(result.errors.filter((e) => e.code.startsWith('SCHEMA_'))).toEqual([]);
106+
expect(result.valid).toBe(true);
107+
expect(result.errors.filter((e) => e.code === CODE)).toHaveLength(0);
108+
}
109+
110+
it('does not warn for a camelCase keyframe property', () => {
111+
expectAccepted(validateKeyframeProperty('backgroundColor'));
112+
});
113+
114+
it('does not warn for a kebab-case keyframe property', () => {
115+
expectAccepted(validateKeyframeProperty('background-color'));
116+
});
117+
118+
it('does not warn for a vendor-prefixed keyframe property in either casing', () => {
119+
expectAccepted(validateKeyframeProperty('-webkit-text-stroke', '1px red'));
120+
expectAccepted(validateKeyframeProperty('webkitTextStroke', '1px red'));
121+
});
122+
123+
it('does not warn for WAAPI keyframe keywords', () => {
124+
expectAccepted(
125+
validateInteractConfig({
126+
interactions: [
127+
{
128+
key: 'el',
129+
trigger: 'viewEnter',
130+
effects: [
131+
{
132+
duration: 400,
133+
keyframeEffect: {
134+
name: 'kf',
135+
keyframes: [{ opacity: 0, offset: 0, easing: 'ease-in', composite: 'add' }],
136+
},
137+
},
138+
],
139+
},
140+
],
141+
}),
142+
);
143+
});
144+
145+
it('does not warn for a CSS custom property (--*) in either shape', () => {
146+
expectAccepted(validateKeyframeProperty('--my-var', '1'));
147+
expectAccepted(validateKeyframeProperty('--myVar', '1'));
148+
expectAccepted(validateStyleProperty('--myVar'));
149+
});
150+
151+
it('does not warn for camelCase or kebab-case style properties', () => {
152+
expectAccepted(validateStyleProperty('backgroundColor'));
153+
expectAccepted(validateStyleProperty('background-color'));
154+
});
155+
});
156+
});

0 commit comments

Comments
 (0)