Skip to content

Commit 27dc51b

Browse files
tenphiCopilot
andauthored
feat: new syntax for custom properties with a fallback (#747)
Co-authored-by: Copilot <[email protected]>
1 parent ad9b443 commit 27dc51b

File tree

6 files changed

+202
-22
lines changed

6 files changed

+202
-22
lines changed

.changeset/flat-bags-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": minor
3+
---
4+
5+
New syntax for custom properties with fallback: `($prop-name, <fallback_value>)`.

src/components/form/FieldWrapper/FieldWrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const FieldElement = tasty({
1919
width: 'auto',
2020
gridColumns: {
2121
'': 'minmax(0, 1fr)',
22-
'has-sider': '$(full-label-width, auto) minmax(0, 1fr)',
22+
'has-sider': '($full-label-width, auto) minmax(0, 1fr)',
2323
},
2424
gap: 0,
2525
placeItems: 'baseline stretch',

src/stories/Tasty.docs.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ const Component = tasty({
606606
// Use custom properties
607607
padding: '$local-spacing',
608608
color: '$theme-color',
609-
margin: '$(custom-margin, 1x)', // With fallback
609+
margin: '($custom-margin, 1x)', // With fallback
610610
},
611611
});
612612
```

src/tasty/parser/classify.ts

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,7 @@ export function classify(
7171

7272
// 2. Custom property
7373
if (token[0] === '@' || token[0] === '$') {
74-
const match = token.match(/^[$@]\(([a-z0-9-_]+)\s*,\s*(.*)\)$/);
75-
if (match) {
76-
const [, name, fallback] = match;
77-
const processedFallback = recurse(fallback).output;
78-
return {
79-
bucket: Bucket.Value,
80-
processed: `var(--${name}, ${processedFallback})`,
81-
};
82-
}
83-
const identMatch = token.match(/^[$@]([a-z0-9-_]+)$/);
74+
const identMatch = token.match(/^[$@]([a-z_][a-z0-9-_]*)$/);
8475
if (identMatch) {
8576
const name = identMatch[1];
8677
const processed = `var(--${name})`;
@@ -147,14 +138,28 @@ export function classify(
147138
return { bucket: Bucket.Value, processed: `${fname}(${argProcessed})` };
148139
}
149140

150-
// 6. Auto-calc group
141+
// 6. Custom property with fallback syntax: (${prop}, fallback)
142+
if (token.startsWith('(') && token.endsWith(')')) {
143+
const inner = token.slice(1, -1);
144+
const match = inner.match(/^[$@]([a-z_][a-z0-9-_]*)\s*,\s*(.*)$/);
145+
if (match) {
146+
const [, name, fallback] = match;
147+
const processedFallback = recurse(fallback).output;
148+
return {
149+
bucket: Bucket.Value,
150+
processed: `var(--${name}, ${processedFallback})`,
151+
};
152+
}
153+
}
154+
155+
// 7. Auto-calc group
151156
if (token[0] === '(' && token[token.length - 1] === ')') {
152157
const inner = token.slice(1, -1);
153158
const innerProcessed = recurse(inner).output;
154159
return { bucket: Bucket.Value, processed: `calc(${innerProcessed})` };
155160
}
156161

157-
// 7. Unit number
162+
// 8. Unit number
158163
const um = token.match(RE_UNIT_NUM);
159164
if (um) {
160165
const unit = um[1];
@@ -182,27 +187,27 @@ export function classify(
182187
}
183188
}
184189

185-
// 7b. Unknown numeric+unit → treat as literal value (e.g., 1fr)
190+
// 8b. Unknown numeric+unit → treat as literal value (e.g., 1fr)
186191
if (/^[+-]?(?:\d*\.\d+|\d+)[a-z%]+$/.test(token)) {
187192
return { bucket: Bucket.Value, processed: token };
188193
}
189194

190-
// 7c. Plain unit-less numbers should be treated as value tokens so that
195+
// 8c. Plain unit-less numbers should be treated as value tokens so that
191196
// code such as `scrollbar={10}` resolves correctly.
192197
if (RE_NUMBER.test(token)) {
193198
return { bucket: Bucket.Value, processed: token };
194199
}
195200

196-
// 8. Literal value keywords
201+
// 9. Literal value keywords
197202
if (VALUE_KEYWORDS.has(token)) {
198203
return { bucket: Bucket.Value, processed: token };
199204
}
200205

201-
// 8b. Special keyword colors
206+
// 9b. Special keyword colors
202207
if (token === 'transparent' || token === 'currentcolor') {
203208
return { bucket: Bucket.Color, processed: token };
204209
}
205210

206-
// 9. Fallback modifier
211+
// 10. Fallback modifier
207212
return { bucket: Bucket.Mod, processed: token };
208213
}

src/tasty/parser/parser.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Each `StyleParser` instance maintains its own LRU cache.
133133
| Order | Rule | Bucket |
134134
|-------|--------------------------------------------------------------------------------------------|----------|
135135
| 1 | URL – `url(` opens `inUrl`; everything to its `)` is a single token. | value |
136-
| 2 | Custom property – `$ident``var(--ident)`; `$(ident,fallback)``var(--ident, <processed fallback>)`. Only first `$` per token counts. | value |
136+
| 2 | Custom property – `$ident``var(--ident)`; `($ident,fallback)``var(--ident, <processed fallback>)`. Must start with letter or underscore, followed by letters, numbers, hyphens, or underscores. | value |
137137
| 3 | Hash token – `#xxxxxx` if valid hex → `var(--xxxxxx-color, #xxxxxx)`; otherwise `var(--name-color)`. | color |
138138
| 4 | Color function – name in list §12.2 followed by `(` (balanced). | color |
139139
| 5 | User / other function – `ident(` not in color list; parse args recursively, hand off to `funcs[name]` if provided; else rebuild with processed args. | value |
@@ -152,7 +152,7 @@ Each processed string is inserted into its bucket and into `all` in source order
152152
|--------------------------|---------------------------------------------------------------------------------------------|
153153
| Custom unit (`2x`, `.75r`, `-3cr`) | `units[unit]`: • string → `calc(n * replacement)` • function → `calc(handler(numeric))`<br> `0u` stays `calc(0 * …)` (unit info preserved). |
154154
| Auto-calc parentheses | Applies anywhere, nesting allowed.<br>Trigger = `(` whose previous non-space char is not `[a-z0-9_-]` and not `l` in `url(`.<br>Algorithm:<br>1. Strip outer parens.<br>2. Recursively parse inner text (so `2r`, `#fff`, nested auto-calc, etc., all expand).<br>3. Wrap in `calc( … )`. |
155-
| Custom property | As in §5-2. |
155+
| Custom property | `$ident``var(--ident)` \| `($ident,fallback)``var(--ident, <processed fallback>)` |
156156
| Hash colors | As in §5-3. |
157157
| Color functions | Arguments are parsed, inner colors re-expanded; function name retained. |
158158
| User functions | If `funcs[name]` exists → call with parsed arg-`StyleDetails[]`, use return string.<br>Else rebuild `ident(<processed args>)`. |
@@ -233,6 +233,8 @@ rgb rgba hsl hsla hwb lab lch oklab oklch color device-cmyk gray color-mix color
233233
| `1bw top #purple, 1ow right #dark-05` | Two groups; colors processed; positions as modifiers. |
234234
| Comments `/*…*/2x` | `calc(2 * var(--gap))`. |
235235
| `#+not-hash` | Modifier (fails hex test). |
236+
| `($custom-gap, 1x)` | `var(--custom-gap, var(--gap))` (new custom property syntax). |
237+
| `($123invalid, fallback)` | `calc($123invalid, fallback)` (invalid name → auto-calc). |
236238
| Excess spaces/newlines | Collapsed in output. |
237239
| `+2r, 1e3x` | Invalid → modifiers. |
238240
| Unicode identifiers | Modifiers (parser supports only kebab-case ASCII idents). |

src/tasty/parser/parser.test.ts

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('StyleProcessor', () => {
4747
});
4848

4949
test('parses custom properties', () => {
50-
const result = parser.process('$my-gap $(my-gap, 2x)');
50+
const result = parser.process('$my-gap ($my-gap, 2x)');
5151
expect(result.groups[0].values).toEqual([
5252
'var(--my-gap)',
5353
'var(--my-gap, calc(2 * var(--gap)))',
@@ -195,6 +195,174 @@ describe('StyleProcessor', () => {
195195
]);
196196
});
197197

198+
test('parses new custom property with fallback syntax', () => {
199+
const result = parser.process(
200+
'($custom-margin, 1x) ($theme-color, #purple)',
201+
);
202+
expect(result.groups[0].values).toEqual([
203+
'var(--custom-margin, var(--gap))',
204+
'var(--theme-color, var(--purple-color))',
205+
]);
206+
});
207+
208+
test('parses new custom property syntax in complex expressions', () => {
209+
const result = parser.process('(100% - (2 * ($custom-gap, 1x)))');
210+
expect(result.groups[0].values).toEqual([
211+
'calc(100% - calc(2 * var(--custom-gap, var(--gap))))',
212+
]);
213+
});
214+
215+
test('distinguishes between functions and custom property fallbacks', () => {
216+
// Test function with custom property as first argument
217+
const result1 = parser.process('sum($my-prop, 2x)');
218+
expect(result1.groups[0].values).toEqual([
219+
'calc(var(--my-prop) + calc(2 * var(--gap)))',
220+
]);
221+
222+
// Test custom property with fallback
223+
const result2 = parser.process('($my-prop, 2x)');
224+
expect(result2.groups[0].values).toEqual([
225+
'var(--my-prop, calc(2 * var(--gap)))',
226+
]);
227+
228+
// Test multiple scenarios in one expression
229+
const result3 = parser.process('sum($a, $b) ($fallback-prop, 1x)');
230+
expect(result3.groups[0].values).toEqual([
231+
'calc(var(--a) + var(--b))',
232+
'var(--fallback-prop, var(--gap))',
233+
]);
234+
235+
// Test edge case: function with custom property fallback as argument
236+
const result4 = parser.process('sum(($prop-a, 1x), ($prop-b, 2x))');
237+
expect(result4.groups[0].values).toEqual([
238+
'calc(var(--prop-a, var(--gap)) + var(--prop-b, calc(2 * var(--gap))))',
239+
]);
240+
241+
// Test color functions with custom properties
242+
const result5 = parser.process('rgb($red, $green, $blue)');
243+
expect(result5.groups[0].colors).toEqual([
244+
'rgb(var(--red),var(--green),var(--blue))',
245+
]);
246+
247+
// Test generic function (not user-defined)
248+
const result6 = parser.process('min($width, 100%)');
249+
expect(result6.groups[0].values).toEqual(['min(var(--width), 100%)']);
250+
251+
// Test critical edge case: ensure no ambiguity in parsing order
252+
// Function name 'sum' vs custom property fallback starting with 'sum'
253+
const result7 = parser.process('sum($a, $b) ($sum, fallback)');
254+
expect(result7.groups[0].values).toEqual([
255+
'calc(var(--a) + var(--b))', // Function call
256+
'var(--sum, fallback)', // Custom property fallback (not a function)
257+
]);
258+
});
259+
260+
test('validates CSS custom property names correctly', () => {
261+
// Valid names
262+
const result1 = parser.process(
263+
'$valid-name $_underscore $hyphen-ok $abc123',
264+
);
265+
expect(result1.groups[0].values).toEqual([
266+
'var(--valid-name)',
267+
'var(--_underscore)',
268+
'var(--hyphen-ok)',
269+
'var(--abc123)',
270+
]);
271+
272+
// Invalid names (should become modifiers)
273+
const result2 = parser.process('$123invalid $-123invalid $0test $-');
274+
expect(result2.groups[0].mods).toEqual([
275+
'$123invalid',
276+
'$-123invalid',
277+
'$0test',
278+
'$-',
279+
]);
280+
281+
// Edge case: single character names
282+
const result3 = parser.process('$a $_ $1');
283+
expect(result3.groups[0].values).toEqual(['var(--a)', 'var(--_)']);
284+
expect(result3.groups[0].mods).toEqual(['$1']);
285+
});
286+
287+
test('comprehensive collision testing for edge cases', () => {
288+
// Test 1: Auto-calc vs custom property fallback - similar patterns
289+
const result1 = parser.process('(100% - 2x) ($gap, 1x)');
290+
expect(result1.groups[0].values).toEqual([
291+
'calc(100% - calc(2 * var(--gap)))', // Auto-calc
292+
'var(--gap, var(--gap))', // Custom property fallback
293+
]);
294+
295+
// Test 2: URL with comma vs custom property fallback
296+
// NOTE: URLs merge with following tokens for background layers
297+
const result2 = parser.process(
298+
'url("img,with,comma.png") ($fallback, auto)',
299+
);
300+
expect(result2.groups[0].values).toEqual([
301+
'url("img,with,comma.png") var(--fallback, auto)', // URL merges with following token
302+
]);
303+
304+
// Test 3: Quoted strings that look like custom properties
305+
const result3 = parser.process(
306+
'"($not-a-prop, value)" ($real-prop, fallback)',
307+
);
308+
expect(result3.groups[0].values).toEqual([
309+
'"($not-a-prop, value)"', // Quoted string (not processed)
310+
'var(--real-prop, fallback)', // Custom property fallback
311+
]);
312+
313+
// Test 4: Color function with similar pattern
314+
const result4 = parser.process(
315+
'rgb($red, $green, $blue) ($color-fallback, #fff)',
316+
);
317+
expect(result4.groups[0].colors).toEqual([
318+
'rgb(var(--red),var(--green),var(--blue))',
319+
]);
320+
expect(result4.groups[0].values).toEqual([
321+
'var(--color-fallback, var(--fff-color, #fff))',
322+
]);
323+
324+
// Test 5: Nested parentheses with custom properties
325+
const result5 = parser.process('(($outer, 10px) + ($inner, 5px))');
326+
expect(result5.groups[0].values).toEqual([
327+
'calc(var(--outer, 10px) + var(--inner, 5px))',
328+
]);
329+
330+
// Test 6: Invalid custom property patterns (should not match)
331+
const result6 = parser.process(
332+
'(not-a-prop, value) (@invalid-syntax, bad)',
333+
);
334+
expect(result6.groups[0].values).toEqual([
335+
'calc(not-a-prop, value)', // Auto-calc (no $ prefix)
336+
'var(--invalid-syntax, bad)', // @ is valid custom property prefix
337+
]);
338+
339+
// Test 7: Edge case with spaces and special characters
340+
// NOTE: Extra spaces cause the pattern to not match, falling back to auto-calc
341+
const result7 = parser.process('( $spaced , fallback ) ($compact,nospace)');
342+
expect(result7.groups[0].values).toEqual([
343+
'calc(var(--spaced), fallback)', // Extra spaces -> auto-calc, not custom property
344+
'var(--compact, nospace)', // No spaces are fine
345+
]);
346+
347+
// Test 8: Edge cases with regex boundaries
348+
// Now properly validates CSS custom property names
349+
const result8 = parser.process(
350+
'($123invalid, fallback) ($valid-name, fallback) ($_underscore, fallback) ($hyphen-ok, fallback)',
351+
);
352+
expect(result8.groups[0].values).toEqual([
353+
'calc($123invalid, fallback)', // Invalid (starts with number) -> auto-calc
354+
'var(--valid-name, fallback)', // Valid
355+
'var(--_underscore, fallback)', // Valid (underscore allowed)
356+
'var(--hyphen-ok, fallback)', // Valid (letter followed by hyphen)
357+
]);
358+
359+
// Test 9: Comma separation in complex scenarios
360+
const result9 = parser.process('($prop1, fallback), ($prop2, fallback)');
361+
expect(result9.groups.length).toBe(2); // Should create two groups
362+
expect(result9.groups[0].values).toEqual(['var(--prop1, fallback)']);
363+
expect(result9.groups[1].values).toEqual(['var(--prop2, fallback)']);
364+
});
365+
198366
test('skips invalid functions while parsing (for example missing closing parenthesis)', () => {
199367
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
200368

0 commit comments

Comments
 (0)