@astryxdesign/core 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/Avatar/Avatar.d.ts.map +1 -1
  3. package/dist/Avatar/Avatar.js +3 -19
  4. package/dist/Banner/Banner.d.ts +6 -2
  5. package/dist/Banner/Banner.d.ts.map +1 -1
  6. package/dist/Banner/Banner.js +71 -30
  7. package/dist/Banner/index.d.ts +14 -12
  8. package/dist/Banner/index.d.ts.map +1 -1
  9. package/dist/Banner/index.js +10 -8
  10. package/dist/Chat/ChatTokenizedText.js +1 -1
  11. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +96 -35
  14. package/dist/ComplexSelector/index.d.ts +2 -2
  15. package/dist/ComplexSelector/index.d.ts.map +1 -1
  16. package/dist/ComplexSelector/index.js +1 -1
  17. package/dist/Markdown/parser.d.ts.map +1 -1
  18. package/dist/Markdown/parser.js +55 -12
  19. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  20. package/dist/PowerSearch/PowerSearch.js +4 -1
  21. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  22. package/dist/PowerSearch/formatFilterValue.js +2 -4
  23. package/dist/Table/columnUtils.d.ts.map +1 -1
  24. package/dist/Table/columnUtils.js +4 -1
  25. package/dist/TextArea/TextArea.d.ts +6 -3
  26. package/dist/TextArea/TextArea.d.ts.map +1 -1
  27. package/dist/TextArea/TextArea.js +17 -6
  28. package/dist/TreeList/TreeList.js +2 -1
  29. package/dist/astryx.css +6 -4
  30. package/dist/astryx.umd.js +51 -51
  31. package/dist/astryx.umd.js.map +4 -4
  32. package/dist/theme/defineTheme.d.ts +8 -3
  33. package/dist/theme/defineTheme.d.ts.map +1 -1
  34. package/dist/theme/defineTheme.js +36 -47
  35. package/dist/theme/mergeComponents.d.ts +20 -0
  36. package/dist/theme/mergeComponents.d.ts.map +1 -0
  37. package/dist/theme/mergeComponents.js +56 -0
  38. package/dist/theme/onMediaTokens.d.ts +6 -1
  39. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  40. package/dist/theme/onMediaTokens.js +11 -3
  41. package/dist/utils/characters.d.ts +27 -0
  42. package/dist/utils/characters.d.ts.map +1 -0
  43. package/dist/utils/characters.js +83 -0
  44. package/dist/utils/index.d.ts +1 -0
  45. package/dist/utils/index.d.ts.map +1 -1
  46. package/dist/utils/index.js +1 -0
  47. package/package.json +2 -2
  48. package/src/Avatar/Avatar.test.tsx +6 -1
  49. package/src/Avatar/Avatar.tsx +3 -21
  50. package/src/Banner/Banner.doc.mjs +9 -7
  51. package/src/Banner/Banner.test.tsx +68 -0
  52. package/src/Banner/Banner.tsx +97 -36
  53. package/src/Banner/index.ts +15 -13
  54. package/src/Chat/ChatTokenizedText.tsx +1 -1
  55. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  56. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  57. package/src/ComplexSelector/ComplexSelector.tsx +153 -32
  58. package/src/ComplexSelector/index.ts +3 -1
  59. package/src/Markdown/parser.test.ts +53 -0
  60. package/src/Markdown/parser.ts +53 -12
  61. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  62. package/src/PowerSearch/PowerSearch.tsx +4 -1
  63. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  64. package/src/PowerSearch/formatFilterValue.ts +2 -4
  65. package/src/Table/Table.test.tsx +6 -0
  66. package/src/Table/columnUtils.ts +3 -1
  67. package/src/TextArea/TextArea.doc.mjs +1 -1
  68. package/src/TextArea/TextArea.test.tsx +72 -0
  69. package/src/TextArea/TextArea.tsx +26 -8
  70. package/src/TreeList/TreeList.doc.mjs +2 -2
  71. package/src/TreeList/TreeList.tsx +1 -1
  72. package/src/theme/defineTheme.test.ts +127 -0
  73. package/src/theme/defineTheme.ts +48 -51
  74. package/src/theme/extensibleAxes.test.ts +365 -0
  75. package/src/theme/mergeComponents.ts +59 -0
  76. package/src/theme/onMediaTokens.ts +9 -2
  77. package/src/utils/characters.test.ts +141 -0
  78. package/src/utils/characters.ts +88 -0
  79. package/src/utils/index.ts +2 -0
@@ -275,7 +275,7 @@ function matchReferenceLink(
275
275
  // matches nothing.
276
276
  const label = rawLabel === '' ? linkText : rawLabel;
277
277
  const href = linkDefs.get(normalizeLinkLabel(label));
278
- if (href != null) {
278
+ if (href != null && isSafeUrl(href)) {
279
279
  return {
280
280
  node: {type: 'link', href, children: parseInlineImpl(linkText, opts)},
281
281
  end: labelClose + 1,
@@ -290,7 +290,7 @@ function matchReferenceLink(
290
290
  return null;
291
291
  }
292
292
  const href = linkDefs.get(normalizeLinkLabel(linkText));
293
- if (href == null) {
293
+ if (href == null || !isSafeUrl(href)) {
294
294
  return null;
295
295
  }
296
296
  return {
@@ -359,6 +359,31 @@ function isWordChar(ch: string | undefined): boolean {
359
359
  return /\w/.test(ch);
360
360
  }
361
361
 
362
+ // ---------------------------------------------------------------------------
363
+ // URL scheme sanitization
364
+ // ---------------------------------------------------------------------------
365
+
366
+ /**
367
+ * Reject URLs with dangerous schemes (javascript:, vbscript:, data:) that
368
+ * could execute arbitrary code when rendered as link hrefs or image srcs.
369
+ * Returns true if the URL is safe to use, false otherwise.
370
+ */
371
+ function isSafeUrl(url: string): boolean {
372
+ // Trim and collapse whitespace/control chars that browsers tolerate but
373
+ // could bypass a naive prefix check (e.g. "java\nscript:alert(1)").
374
+ // eslint-disable-next-line no-control-regex -- control chars are the bypass
375
+ const normalized = url.replace(/[\x00-\x1f\x7f]/g, '').trim();
376
+ const lower = normalized.toLowerCase();
377
+ if (
378
+ lower.startsWith('javascript:') ||
379
+ lower.startsWith('vbscript:') ||
380
+ lower.startsWith('data:text/html')
381
+ ) {
382
+ return false;
383
+ }
384
+ return true;
385
+ }
386
+
362
387
  // ---------------------------------------------------------------------------
363
388
  // Inline parser
364
389
  // ---------------------------------------------------------------------------
@@ -478,11 +503,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
478
503
  if (altClose !== -1 && text[altClose + 1] === '(') {
479
504
  const srcClose = findClosingParen(text, altClose + 2);
480
505
  if (srcClose !== -1) {
481
- nodes.push({
482
- type: 'image',
483
- src: text.slice(altClose + 2, srcClose),
484
- alt: text.slice(i + 2, altClose),
485
- });
506
+ const src = text.slice(altClose + 2, srcClose);
507
+ if (!isSafeUrl(src)) {
508
+ // Dangerous scheme emit as plain text.
509
+ nodes.push({type: 'text', content: text.slice(i, srcClose + 1)});
510
+ } else {
511
+ nodes.push({
512
+ type: 'image',
513
+ src,
514
+ alt: text.slice(i + 2, altClose),
515
+ });
516
+ }
486
517
  i = srcClose + 1;
487
518
  continue;
488
519
  }
@@ -515,11 +546,17 @@ function parseInlineImpl(text: string, opts: ResolvedOptions): InlineNode[] {
515
546
  if (textClose !== -1 && text[textClose + 1] === '(') {
516
547
  const urlClose = findClosingParen(text, textClose + 2);
517
548
  if (urlClose !== -1) {
518
- nodes.push({
519
- type: 'link',
520
- href: text.slice(textClose + 2, urlClose),
521
- children: parseInlineImpl(text.slice(i + 1, textClose), opts),
522
- });
549
+ const href = text.slice(textClose + 2, urlClose);
550
+ if (!isSafeUrl(href)) {
551
+ // Dangerous scheme — emit as plain text instead of a link.
552
+ nodes.push({type: 'text', content: text.slice(i, urlClose + 1)});
553
+ } else {
554
+ nodes.push({
555
+ type: 'link',
556
+ href,
557
+ children: parseInlineImpl(text.slice(i + 1, textClose), opts),
558
+ });
559
+ }
523
560
  i = urlClose + 1;
524
561
  continue;
525
562
  }
@@ -780,6 +817,10 @@ function scanAutolinksInText(text: string): AutolinkMatch[] {
780
817
  let m: RegExpExecArray | null;
781
818
  while ((m = re.exec(text)) !== null) {
782
819
  const url = m[1];
820
+ // Skip dangerous URL schemes (javascript:, vbscript:, data:text/html)
821
+ if (!isSafeUrl(url)) {
822
+ continue;
823
+ }
783
824
  matches.push({
784
825
  start: m.index,
785
826
  end: m.index + m[0].length,
@@ -158,6 +158,41 @@ describe('PowerSearch', () => {
158
158
  expect(screen.getByRole('combobox')).toHaveFocus();
159
159
  });
160
160
 
161
+ describe('token value truncation (#4759)', () => {
162
+ it('truncates token values by characters, not code units', () => {
163
+ const truncConfig: PowerSearchConfig = {
164
+ name: 'trunc',
165
+ fields: [
166
+ {
167
+ key: 'status',
168
+ label: 'Status',
169
+ operators: [{key: 'is', label: 'is', value: {type: 'string'}}],
170
+ },
171
+ ],
172
+ };
173
+ // 14 emoji = 28 code units but 14 user-perceived characters.
174
+ // adjustedMaxLength = max(15 - 'Status'.length - 'is'.length, 10) = 10,
175
+ // so the value truncates only past 13 characters, cutting at 10.
176
+ render(
177
+ <PowerSearch
178
+ config={truncConfig}
179
+ filters={[
180
+ {
181
+ field: 'status',
182
+ operator: 'is',
183
+ value: {type: 'string', value: '\u{1F600}'.repeat(14)},
184
+ },
185
+ ]}
186
+ onChange={() => {}}
187
+ maxTokenLength={15}
188
+ />,
189
+ );
190
+ expect(
191
+ screen.getByText('\u{1F600}'.repeat(10) + '...'),
192
+ ).toBeInTheDocument();
193
+ });
194
+ });
195
+
161
196
  describe('startIcon', () => {
162
197
  it('does not render a start icon when omitted', () => {
163
198
  render(<PowerSearch config={config} filters={[]} onChange={() => {}} />);
@@ -425,11 +460,15 @@ describe('PowerSearch', () => {
425
460
  });
426
461
  });
427
462
 
428
-
429
463
  describe('PowerSearch statusVariant forwarding', () => {
430
464
  it('defaults to attached (status renders with data-variant="attached")', () => {
431
465
  const {container} = render(
432
- <PowerSearch config={config} filters={[]} onChange={() => {}} status={{type: 'error', message: 'Required'}} />,
466
+ <PowerSearch
467
+ config={config}
468
+ filters={[]}
469
+ onChange={() => {}}
470
+ status={{type: 'error', message: 'Required'}}
471
+ />,
433
472
  );
434
473
  expect(container.querySelector('.astryx-field-status')).toHaveAttribute(
435
474
  'data-variant',
@@ -439,7 +478,13 @@ describe('PowerSearch statusVariant forwarding', () => {
439
478
 
440
479
  it('forwards statusVariant="detached" to the underlying Field status', () => {
441
480
  const {container} = render(
442
- <PowerSearch config={config} filters={[]} onChange={() => {}} status={{type: 'error', message: 'Required'}} statusVariant="detached" />,
481
+ <PowerSearch
482
+ config={config}
483
+ filters={[]}
484
+ onChange={() => {}}
485
+ status={{type: 'error', message: 'Required'}}
486
+ statusVariant="detached"
487
+ />,
443
488
  );
444
489
  expect(container.querySelector('.astryx-field-status')).toHaveAttribute(
445
490
  'data-variant',
@@ -54,6 +54,7 @@ import {formatFilterValue} from './formatFilterValue';
54
54
  import {PowerSearchEditPopover} from './PowerSearchEditPopover';
55
55
  import {resolveOperatorLabel} from './resolveOperatorLabel';
56
56
  import {themeProps} from '../utils/themeProps';
57
+ import {truncateCharacters} from '../utils/characters';
57
58
  import {useTranslator} from '../i18n';
58
59
  import type {
59
60
  PowerSearchConfig,
@@ -117,7 +118,9 @@ const resultCountStyles = stylex.create({
117
118
  });
118
119
 
119
120
  function truncateString(value: string, limit: number): string {
120
- return value.length > limit + 3 ? value.slice(0, limit) + '...' : value;
121
+ // Same semantics as before strings within limit + 3 pass through, longer
122
+ // ones cut to limit + '...' — but counted in characters, not code units.
123
+ return truncateCharacters(value, limit + 3, '...');
121
124
  }
122
125
 
123
126
  function getEnumLabel(values: ReadonlyArray<EnumItem>, value: string): string {
@@ -62,6 +62,28 @@ describe('formatFilterValue', () => {
62
62
  'abcde',
63
63
  );
64
64
  });
65
+
66
+ it('truncates by characters, never splitting an emoji (#4759)', () => {
67
+ // Four surrogate-pair emoji: 8 code units, 4 user-perceived characters.
68
+ // The exact-string assertion also proves no surrogate pair was split.
69
+ const out = fmt(
70
+ {type: 'string'},
71
+ {type: 'string', value: '\u{1F600}'.repeat(4)},
72
+ 3,
73
+ );
74
+ expect(out).toBe('\u{1F600}'.repeat(2) + ELLIPSIS);
75
+ });
76
+
77
+ it('keeps multi-unit characters within maxLength intact (#4759)', () => {
78
+ // Three emoji are 6 code units but only 3 user-perceived characters.
79
+ expect(
80
+ fmt(
81
+ {type: 'string'},
82
+ {type: 'string', value: '\u{1F600}'.repeat(3)},
83
+ 3,
84
+ ),
85
+ ).toBe('\u{1F600}'.repeat(3));
86
+ });
65
87
  });
66
88
 
67
89
  describe('integer / float', () => {
@@ -13,12 +13,10 @@
13
13
  import type {OperatorValue, FilterValue, EnumItem} from './types';
14
14
  import type {InternalConfig} from './useInternalConfig';
15
15
  import type {TranslatorFn} from '../i18n';
16
+ import {truncateCharacters} from '../utils/characters';
16
17
 
17
18
  function truncate(str: string, maxLength: number): string {
18
- if (str.length <= maxLength) {
19
- return str;
20
- }
21
- return str.slice(0, maxLength - 1) + '\u2026';
19
+ return truncateCharacters(str, maxLength);
22
20
  }
23
21
 
24
22
  function formatEnumLabel(
@@ -95,6 +95,12 @@ describe('columnUtils', () => {
95
95
  it('handles single character', () => {
96
96
  expect(capitalize('a')).toBe('A');
97
97
  });
98
+
99
+ it('uppercases an astral-plane letter without splitting it (#4759)', () => {
100
+ // Deseret 𐐨 (U+10428) uppercases to 𐐀 (U+10400); charAt(0) would grab
101
+ // half the surrogate pair and leave the string unchanged.
102
+ expect(capitalize('\u{10428}pple')).toBe('\u{10400}pple');
103
+ });
98
104
  });
99
105
 
100
106
  describe('generateColumns', () => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type {CSSProperties, ReactNode} from 'react';
15
15
  import type {TableColumn, ProportionalWidth, PixelWidth} from './types';
16
+ import {firstCharacter} from '../utils/characters';
16
17
 
17
18
  /** Default minimum width (in px) for proportional columns. */
18
19
  export const DEFAULT_MIN_COLUMN_WIDTH = 120;
@@ -168,7 +169,8 @@ export function capitalize(str: string): string {
168
169
  if (str.length === 0) {
169
170
  return str;
170
171
  }
171
- return str.charAt(0).toUpperCase() + str.slice(1);
172
+ const first = firstCharacter(str);
173
+ return first.toUpperCase() + str.slice(first.length);
172
174
  }
173
175
 
174
176
  /**
@@ -105,7 +105,7 @@ export const docs = {
105
105
  name: 'maxLength',
106
106
  type: 'number',
107
107
  description:
108
- 'Maximum number of characters allowed. When set, a character counter (current/max) is displayed inside the input container, anchored to the bottom-right beneath the text. Does not enforce the limit natively; when exceeded the counter turns red and shows a warning icon (a non-color cue), and screen-reader users hear the remaining/over-limit count announced.',
108
+ 'Maximum number of characters allowed, counted as user-perceived characters — an emoji or flag sequence counts as one. When set, a character counter (current/max) is displayed inside the input container, anchored to the bottom-right beneath the text. Does not enforce the limit natively; when exceeded the counter turns red and shows a warning icon (a non-color cue), and screen-reader users hear the remaining/over-limit count announced. Consumers validating the limit should count with characterCount (exported from the package) so enforcement matches the displayed count.',
109
109
  },
110
110
  {
111
111
  name: 'status',
@@ -504,6 +504,33 @@ describe('TextArea', () => {
504
504
  expect(screen.getByRole('textbox')).not.toHaveAttribute('maxlength');
505
505
  });
506
506
 
507
+ it('counts user-perceived characters, not code units (#4759)', () => {
508
+ // Two surrogate-pair emoji: 4 code units, but 2 user-perceived characters.
509
+ render(
510
+ <TextArea
511
+ label="Description"
512
+ value={'\u{1F600}\u{1F600}'}
513
+ onChange={() => {}}
514
+ maxLength={5}
515
+ />,
516
+ );
517
+ expect(screen.getByText('2/5')).toBeInTheDocument();
518
+ });
519
+
520
+ it('measures the over-limit state in characters (#4759)', () => {
521
+ // Three ZWJ family emoji: 33 code units, 3 user-perceived characters.
522
+ const family = '\u{1F468}‍\u{1F469}‍\u{1F467}‍\u{1F466}';
523
+ render(
524
+ <TextArea
525
+ label="Description"
526
+ value={family.repeat(3)}
527
+ onChange={() => {}}
528
+ maxLength={2}
529
+ />,
530
+ );
531
+ expect(screen.getByText('3/2')).toBeInTheDocument();
532
+ });
533
+
507
534
  it('counter updates as user types (controlled)', async () => {
508
535
  const user = userEvent.setup();
509
536
  function Wrapper() {
@@ -570,6 +597,51 @@ describe('TextArea', () => {
570
597
  });
571
598
  });
572
599
 
600
+ it('announces zone transitions using character counts (#4759)', async () => {
601
+ function Wrapper() {
602
+ const [val, setVal] = useState('x'.repeat(7));
603
+ return (
604
+ <TextArea
605
+ label="Description"
606
+ value={val}
607
+ onChange={setVal}
608
+ maxLength={10}
609
+ />
610
+ );
611
+ }
612
+ render(<Wrapper />);
613
+ // Appending two emoji makes 9 characters (11 code units): near the limit
614
+ // with 1 remaining. Code-unit counting would call this over the limit
615
+ // and announce assertively instead.
616
+ fireEvent.change(screen.getByRole('textbox'), {
617
+ target: {value: 'x'.repeat(7) + '\u{1F600}\u{1F600}'},
618
+ });
619
+ const politeRegion = () =>
620
+ document.querySelector('[data-astryx-live-region="polite"]');
621
+ const assertiveRegion = () =>
622
+ document.querySelector('[data-astryx-live-region="assertive"]');
623
+ await waitFor(() => {
624
+ expect(politeRegion()).toHaveTextContent('1 character remaining');
625
+ });
626
+ expect(assertiveRegion()).not.toHaveTextContent('over the limit');
627
+ });
628
+
629
+ it('does not flag over-limit while characters fit, even when code units exceed (#4759)', () => {
630
+ // Three emoji: 6 code units but 3 user-perceived characters — within a
631
+ // maxLength of 4, so no error state anywhere.
632
+ render(
633
+ <TextArea
634
+ label="Description"
635
+ value={'\u{1F600}'.repeat(3)}
636
+ onChange={() => {}}
637
+ maxLength={4}
638
+ />,
639
+ );
640
+ const counter = screen.getByText('3/4');
641
+ expect(counter.querySelector('svg')).toBeNull();
642
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-invalid');
643
+ });
644
+
573
645
  it('shows a non-color over-limit indicator icon when exceeded', () => {
574
646
  const {container} = render(
575
647
  <TextArea
@@ -22,6 +22,7 @@ import {
22
22
  useTransition,
23
23
  useRef,
24
24
  useCallback,
25
+ useMemo,
25
26
  type ChangeEvent,
26
27
  type ClipboardEvent,
27
28
  type FocusEvent,
@@ -53,6 +54,7 @@ import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
53
54
  import {useAnnounce} from '../hooks/useAnnounce';
54
55
  import {useSize} from '../SizeContext/SizeContext';
55
56
  import {themeProps} from '../utils/themeProps';
57
+ import {characterCount} from '../utils/characters';
56
58
  import {useTranslator} from '../i18n';
57
59
 
58
60
  const COUNTER_WARNING_THRESHOLD = 0.8;
@@ -312,10 +314,13 @@ export interface TextAreaProps extends Omit<
312
314
  */
313
315
  onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void;
314
316
  /**
315
- * Maximum number of characters allowed.
316
- * When set, displays a character counter below the textarea.
317
+ * Maximum number of characters allowed, counted as user-perceived
318
+ * characters an emoji or flag sequence counts as one. When set,
319
+ * displays a character counter below the textarea.
317
320
  * Does not enforce the limit natively — the counter shows error styling
318
- * when exceeded, and the consumer can validate via onChange.
321
+ * when exceeded, and the consumer can validate via onChange. Validate with
322
+ * `characterCount` (exported from this package) rather than `value.length`
323
+ * so enforcement matches the displayed count.
319
324
  */
320
325
  maxLength?: number;
321
326
  /**
@@ -482,7 +487,11 @@ export function TextArea({
482
487
  return;
483
488
  }
484
489
  const newValue = e.target.value;
485
- announceCounter(newValue.length);
490
+ // Guarded here, not just inside announceCounter, so textareas without a
491
+ // maxLength never pay for segmenting the whole value on each keystroke.
492
+ if (maxLength != null) {
493
+ announceCounter(characterCount(newValue));
494
+ }
486
495
  onChange?.(newValue, e);
487
496
  if (changeAction && !e.defaultPrevented) {
488
497
  startTransition(async () => {
@@ -492,6 +501,15 @@ export function TextArea({
492
501
  }
493
502
  };
494
503
 
504
+ // Counter semantics count user-perceived characters, so an emoji or flag
505
+ // sequence counts as one character, not its code units.
506
+ // Only measured when a counter exists — segmentation is O(value length),
507
+ // and memoized so re-renders that keep the same value skip it entirely.
508
+ const valueLength = useMemo(
509
+ () => (maxLength != null ? characterCount(optimisticValue) : 0),
510
+ [maxLength, optimisticValue],
511
+ );
512
+
495
513
  const effectivelyDisabled = isDisabled || isBusy;
496
514
 
497
515
  // Focus textarea when clicking anywhere on the wrapper (icons, padding, etc.)
@@ -583,7 +601,7 @@ export function TextArea({
583
601
  aria-required={isRequired && !isOptional ? 'true' : undefined}
584
602
  aria-invalid={
585
603
  status?.type === 'error' ||
586
- (maxLength != null && optimisticValue.length > maxLength)
604
+ (maxLength != null && valueLength > maxLength)
587
605
  ? 'true'
588
606
  : undefined
589
607
  }
@@ -614,15 +632,15 @@ export function TextArea({
614
632
  id={counterID}
615
633
  {...stylex.props(
616
634
  styles.counter,
617
- optimisticValue.length > maxLength && styles.counterError,
635
+ valueLength > maxLength && styles.counterError,
618
636
  )}>
619
- {optimisticValue.length > maxLength && (
637
+ {valueLength > maxLength && (
620
638
  // Non-color cue so the over-limit state isn't conveyed by the red
621
639
  // color alone (WCAG 1.4.1). Decorative — the count text and the
622
640
  // live-region announcement carry the meaning.
623
641
  <Icon icon="warning" size="sm" />
624
642
  )}
625
- {optimisticValue.length}/{maxLength}
643
+ {valueLength}/{maxLength}
626
644
  </div>
627
645
  )}
628
646
  </div>
@@ -24,7 +24,7 @@ export const docs = {
24
24
  },
25
25
  theming: {
26
26
  targets: [
27
- {className: 'astryx-tree-list', visualProps: ['density']},
27
+ {className: 'astryx-tree-list', visualProps: ['density', 'variant']},
28
28
  {className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
29
29
  {className: 'astryx-tree-list-chevron', states: ['state']},
30
30
  {className: 'astryx-tree-list-item-label', states: ['selected']},
@@ -104,7 +104,7 @@ export const docsZh = {
104
104
  group: 'TreeList',
105
105
  theming: {
106
106
  targets: [
107
- {className: 'astryx-tree-list', visualProps: ['density']},
107
+ {className: 'astryx-tree-list', visualProps: ['density', 'variant']},
108
108
  {className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
109
109
  {className: 'astryx-tree-list-chevron', states: ['state']},
110
110
  {className: 'astryx-tree-list-item-label', states: ['selected']},
@@ -332,7 +332,7 @@ export function TreeList({
332
332
  ref={ref}
333
333
  data-testid={testId}
334
334
  {...mergeProps(
335
- themeProps('tree-list', {density}),
335
+ themeProps('tree-list', {density, variant}),
336
336
  stylex.props(styles.root, xstyle),
337
337
  className,
338
338
  style,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {describe, it, expect, vi} from 'vitest';
4
4
  import type {IconRegistry} from '../Icon/globalIconRegistry';
5
+ import type {DefinedTheme} from './defineTheme';
5
6
  import {defineTheme, generateThemeCSS, isDefinedTheme} from './defineTheme';
6
7
 
7
8
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
@@ -1126,4 +1127,130 @@ describe('defineTheme extends', () => {
1126
1127
  base.tokens['--font-size-base'],
1127
1128
  );
1128
1129
  });
1130
+
1131
+ it('inherits indicators when the child has none', () => {
1132
+ const indicator = (() => null) as unknown as NonNullable<
1133
+ DefinedTheme['indicators']
1134
+ >['check'];
1135
+ const base = defineTheme({name: 'base', indicators: {check: indicator}});
1136
+ const child = defineTheme({name: 'child', extends: base});
1137
+ expect(child.indicators?.check).toBe(indicator);
1138
+ });
1139
+
1140
+ it('inherits onDark token overrides from base theme', () => {
1141
+ const base = defineTheme({
1142
+ name: 'base',
1143
+ onDark: {tokens: {'--color-border': '#ffffff'}},
1144
+ });
1145
+ const child = defineTheme({name: 'child', extends: base});
1146
+ expect(child.__onDark?.tokens['--color-border']).toBe('#ffffff');
1147
+ });
1148
+
1149
+ it('inherits onLight token overrides from base theme', () => {
1150
+ const base = defineTheme({
1151
+ name: 'base',
1152
+ onLight: {tokens: {'--color-border': '#000000'}},
1153
+ });
1154
+ const child = defineTheme({name: 'child', extends: base});
1155
+ expect(child.__onLight?.tokens['--color-border']).toBe('#000000');
1156
+ });
1157
+
1158
+ it('lets the child override an inherited onDark token', () => {
1159
+ const base = defineTheme({
1160
+ name: 'base',
1161
+ onDark: {tokens: {'--color-border': '#ffffff', '--color-track': '#eee'}},
1162
+ });
1163
+ const child = defineTheme({
1164
+ name: 'child',
1165
+ extends: base,
1166
+ onDark: {tokens: {'--color-border': '#cccccc'}},
1167
+ });
1168
+ expect(child.__onDark?.tokens['--color-border']).toBe('#cccccc');
1169
+ expect(child.__onDark?.tokens['--color-track']).toBe('#eee');
1170
+ });
1171
+
1172
+ it('inherits and deep-merges onDark component overrides', () => {
1173
+ const base = defineTheme({
1174
+ name: 'base',
1175
+ onDark: {
1176
+ components: {
1177
+ card: {base: {borderColor: '#fff', backgroundColor: '#111'}},
1178
+ badge: {base: {color: '#fff'}},
1179
+ },
1180
+ },
1181
+ });
1182
+ const child = defineTheme({
1183
+ name: 'child',
1184
+ extends: base,
1185
+ onDark: {components: {card: {base: {borderColor: '#ccc'}}}},
1186
+ });
1187
+ expect(child.__onDark?.components?.card?.base).toEqual({
1188
+ borderColor: '#ccc',
1189
+ backgroundColor: '#111',
1190
+ });
1191
+ expect(child.__onDark?.components?.badge?.base).toEqual({color: '#fff'});
1192
+ });
1193
+
1194
+ it('inherits __inputTokens so inherited [light, dark] tuples survive', () => {
1195
+ const base = defineTheme({
1196
+ name: 'base',
1197
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1198
+ });
1199
+ const child = defineTheme({
1200
+ name: 'child',
1201
+ extends: base,
1202
+ tokens: {'--color-border': '#cccccc'},
1203
+ });
1204
+ expect(child.__inputTokens?.['--color-accent']).toEqual([
1205
+ '#111111',
1206
+ '#eeeeee',
1207
+ ]);
1208
+ expect(child.__inputTokens?.['--color-border']).toBe('#cccccc');
1209
+ });
1210
+
1211
+ it('__inputTokens from the child win over the base', () => {
1212
+ const base = defineTheme({
1213
+ name: 'base',
1214
+ tokens: {'--color-accent': ['#111111', '#eeeeee']},
1215
+ });
1216
+ const child = defineTheme({
1217
+ name: 'child',
1218
+ extends: base,
1219
+ tokens: {'--color-accent': '#ff0000'},
1220
+ });
1221
+ expect(child.__inputTokens?.['--color-accent']).toBe('#ff0000');
1222
+ });
1223
+
1224
+ // The failure that motivated these: `extends` resolving to `undefined` (a
1225
+ // named import that silently missed — see the theme-build resolution tests)
1226
+ // used to inherit nothing and build a stylesheet that looked fine.
1227
+ it('throws when extends is present but undefined', () => {
1228
+ expect(() =>
1229
+ defineTheme({
1230
+ name: 'child',
1231
+ extends: undefined,
1232
+ tokens: {'--color-accent': '#ff0000'},
1233
+ }),
1234
+ ).toThrow(/extends/);
1235
+ });
1236
+
1237
+ it('throws when extends is not a theme', () => {
1238
+ expect(() =>
1239
+ defineTheme({
1240
+ name: 'child',
1241
+ // A module namespace object is the shape a bad import hands over.
1242
+ extends: {foo: 'bar'} as unknown as DefinedTheme,
1243
+ }),
1244
+ ).toThrow(/extends/);
1245
+ });
1246
+
1247
+ it('accepts a pre-built theme module as a base', () => {
1248
+ const built = {
1249
+ name: 'built-base',
1250
+ __built: true,
1251
+ tokens: {'--color-accent': '#111111'},
1252
+ } as DefinedTheme;
1253
+ const child = defineTheme({name: 'child', extends: built});
1254
+ expect(child.tokens['--color-accent']).toBe('#111111');
1255
+ });
1129
1256
  });