@astryxdesign/core 0.4.0 → 0.4.1

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 (194) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +2 -2
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/Popover/usePopover.d.ts +15 -0
  64. package/dist/Popover/usePopover.d.ts.map +1 -1
  65. package/dist/Popover/usePopover.js +14 -2
  66. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  67. package/dist/ProgressBar/ProgressBar.js +2 -3
  68. package/dist/Section/Section.d.ts.map +1 -1
  69. package/dist/Section/Section.js +1 -2
  70. package/dist/Section/index.d.ts.map +1 -1
  71. package/dist/Section/index.js +1 -2
  72. package/dist/Selector/Selector.d.ts +10 -0
  73. package/dist/Selector/Selector.d.ts.map +1 -1
  74. package/dist/Selector/Selector.js +135 -95
  75. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  76. package/dist/Skeleton/Skeleton.js +1 -2
  77. package/dist/Skeleton/index.d.ts.map +1 -1
  78. package/dist/Skeleton/index.js +1 -2
  79. package/dist/Slider/Slider.d.ts.map +1 -1
  80. package/dist/Slider/Slider.js +2 -6
  81. package/dist/Switch/Switch.d.ts.map +1 -1
  82. package/dist/Switch/Switch.js +3 -3
  83. package/dist/Table/TableRow.d.ts +1 -1
  84. package/dist/Table/TableRow.d.ts.map +1 -1
  85. package/dist/Table/TableRow.js +4 -2
  86. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  87. package/dist/TimeInput/TimeInput.js +7 -2
  88. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  89. package/dist/Timestamp/Timestamp.js +14 -3
  90. package/dist/Timestamp/formatInstant.d.ts +11 -1
  91. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  92. package/dist/Timestamp/formatInstant.js +3 -1
  93. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  94. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  95. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  96. package/dist/VisuallyHidden/index.js +1 -2
  97. package/dist/astryx.css +18 -17
  98. package/dist/astryx.umd.js +45 -45
  99. package/dist/astryx.umd.js.map +4 -4
  100. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  101. package/dist/hooks/useInputStatusIcon.js +17 -4
  102. package/dist/theme/defineTheme.d.ts +2 -2
  103. package/dist/theme/defineTheme.d.ts.map +1 -1
  104. package/dist/theme/defineTheme.js +2 -1
  105. package/dist/theme/index.d.ts +1 -1
  106. package/dist/theme/index.d.ts.map +1 -1
  107. package/dist/theme/index.js +1 -1
  108. package/dist/theme/tokens.stylex.d.ts +12 -0
  109. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  110. package/dist/theme/tokens.stylex.js +23 -0
  111. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  112. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  113. package/dist/utils/focusOutline.stylex.js +46 -33
  114. package/dist/utils/index.d.ts +1 -0
  115. package/dist/utils/index.d.ts.map +1 -1
  116. package/dist/utils/index.js +5 -1
  117. package/dist/utils/interactionModality.d.ts +34 -0
  118. package/dist/utils/interactionModality.d.ts.map +1 -0
  119. package/dist/utils/interactionModality.js +70 -0
  120. package/locales/en.json +40 -0
  121. package/locales/pseudo.json +30 -0
  122. package/package.json +2 -2
  123. package/src/AppShell/AppShell.doc.mjs +3 -0
  124. package/src/AspectRatio/AspectRatio.tsx +0 -2
  125. package/src/AspectRatio/index.ts +0 -2
  126. package/src/Badge/Badge.tsx +0 -2
  127. package/src/Badge/index.ts +0 -2
  128. package/src/Banner/Banner.test.tsx +58 -1
  129. package/src/Banner/Banner.tsx +24 -5
  130. package/src/Blockquote/Blockquote.tsx +0 -2
  131. package/src/Blockquote/index.ts +0 -2
  132. package/src/Button/Button.doc.mjs +2 -2
  133. package/src/Button/Button.tsx +5 -4
  134. package/src/Card/Card.tsx +0 -2
  135. package/src/Card/index.ts +0 -2
  136. package/src/Center/Center.tsx +0 -2
  137. package/src/Center/index.ts +0 -2
  138. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  139. package/src/Code/Code.tsx +0 -2
  140. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  141. package/src/CommandPalette/CommandPalette.tsx +5 -0
  142. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  143. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  144. package/src/DateInput/DateInput.test.tsx +19 -0
  145. package/src/DateInput/DateInput.tsx +3 -6
  146. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  149. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  150. package/src/Field/PanelSearchInput.tsx +271 -0
  151. package/src/FileInput/FileInput.tsx +21 -8
  152. package/src/Grid/Grid.tsx +0 -2
  153. package/src/Grid/index.ts +0 -2
  154. package/src/Icon/globalIconRegistry.tsx +5 -4
  155. package/src/Indicator/Indicator.doc.mjs +1 -1
  156. package/src/Indicator/index.ts +1 -0
  157. package/src/Indicator/types.ts +11 -0
  158. package/src/Lightbox/Lightbox.tsx +2 -11
  159. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  160. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  161. package/src/MultiSelector/MultiSelector.tsx +170 -127
  162. package/src/Popover/Popover.doc.mjs +2 -0
  163. package/src/Popover/usePopover.tsx +37 -4
  164. package/src/ProgressBar/ProgressBar.tsx +2 -8
  165. package/src/Section/Section.tsx +0 -2
  166. package/src/Section/index.ts +0 -2
  167. package/src/Selector/Selector.doc.mjs +10 -0
  168. package/src/Selector/Selector.test.tsx +386 -2
  169. package/src/Selector/Selector.tsx +182 -122
  170. package/src/Skeleton/Skeleton.tsx +0 -2
  171. package/src/Skeleton/index.ts +0 -2
  172. package/src/Slider/Slider.tsx +2 -11
  173. package/src/Switch/Switch.tsx +10 -2
  174. package/src/Table/Table.test.tsx +31 -0
  175. package/src/Table/TableRow.tsx +6 -0
  176. package/src/TimeInput/TimeInput.test.tsx +54 -0
  177. package/src/TimeInput/TimeInput.tsx +17 -2
  178. package/src/Timestamp/Timestamp.test.tsx +91 -14
  179. package/src/Timestamp/Timestamp.tsx +14 -2
  180. package/src/Timestamp/formatInstant.ts +16 -1
  181. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  182. package/src/TreeList/TreeList.test.tsx +3 -2
  183. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  184. package/src/VisuallyHidden/index.ts +0 -2
  185. package/src/hooks/useInputStatusIcon.tsx +8 -8
  186. package/src/serverSafeComponents.test.ts +665 -0
  187. package/src/theme/defineTheme.ts +3 -0
  188. package/src/theme/generateThemeRules.test.ts +17 -0
  189. package/src/theme/index.ts +2 -0
  190. package/src/theme/tokens.stylex.ts +18 -0
  191. package/src/theme/tokens.test.ts +37 -0
  192. package/src/utils/focusOutline.stylex.ts +37 -23
  193. package/src/utils/index.ts +4 -0
  194. package/src/utils/interactionModality.ts +74 -0
package/locales/en.json CHANGED
@@ -639,6 +639,10 @@
639
639
  "defaultMessage": "Clear {label}",
640
640
  "description": "Aria label for the clear-value button in DateInput / DateRangeInput / DateTimeInput. `{label}` is the field's own label so screen readers announce e.g. 'Clear Start date'."
641
641
  },
642
+ "@astryx.dateInput.invalidDate": {
643
+ "defaultMessage": "Invalid date",
644
+ "description": "Screen-reader-only live-region alert in DateInput / DateTimeInput when the typed date cannot be parsed. The field silently reverts on blur, so this is the only rejection feedback a screen-reader user gets."
645
+ },
642
646
  "@astryx.dateTimeInput.timeSuffix": {
643
647
  "defaultMessage": "{label} time",
644
648
  "description": "Screen-reader-only accessible name for the time-of-day slot in a DateTimeInput. Example: `Start date time`; reorder freely (e.g. \"time of {label}\")."
@@ -711,6 +715,10 @@
711
715
  "defaultMessage": "Select a time",
712
716
  "description": "\"Time\" = clock time (HH:MM), not duration or era. Grey placeholder inside an empty TimeInput field; imperative verb."
713
717
  },
718
+ "@astryx.timeInput.invalidTime": {
719
+ "defaultMessage": "Invalid time",
720
+ "description": "Screen-reader-only live-region alert in TimeInput / DateTimeInput when the typed time cannot be parsed. \"Time\" = clock time (HH:MM). The field silently reverts on blur, so this is the only rejection feedback a screen-reader user gets."
721
+ },
714
722
  "@astryx.topNav.heading.dialogLabel": {
715
723
  "defaultMessage": "Navigation menu",
716
724
  "description": "Screen-reader-only accessible name for the dropdown dialog opened from a TopNavHeading's overflow menu. Kept separate from the SideNav sibling."
@@ -807,6 +815,38 @@
807
815
  "defaultMessage": "Clear {label}",
808
816
  "description": "Screen-reader-only label on the X that removes the selected file from a FileInput. Example: `Clear Attachment`, `Clear Résumé`."
809
817
  },
818
+ "@astryx.fileInput.dropHint": {
819
+ "defaultMessage": "Drop files here",
820
+ "description": "Text shown in the dropzone area when a user is dragging files over the FileInput."
821
+ },
822
+ "@astryx.fileInput.errorInvalidType": {
823
+ "defaultMessage": "\"{fileName}\" is not an accepted file type",
824
+ "description": "Validation error shown when a selected file does not match the accepted types."
825
+ },
826
+ "@astryx.fileInput.errorMaxFiles": {
827
+ "defaultMessage": "Maximum {maxFiles} files allowed",
828
+ "description": "Validation error shown when the number of selected files exceeds the maximum."
829
+ },
830
+ "@astryx.fileInput.errorMaxSize": {
831
+ "defaultMessage": "\"{fileName}\" exceeds {maxSize} limit",
832
+ "description": "Validation error shown when a selected file exceeds the maximum size. {maxSize} is a formatted size string like \"2.0 MB\"."
833
+ },
834
+ "@astryx.fileInput.fileSelected": {
835
+ "defaultMessage": "1 file selected: {fileName}",
836
+ "description": "Live-region announcement when a single file is successfully selected."
837
+ },
838
+ "@astryx.fileInput.filesSelected": {
839
+ "defaultMessage": "{count} files selected",
840
+ "description": "Live-region announcement when multiple files are successfully selected."
841
+ },
842
+ "@astryx.fileInput.placeholder": {
843
+ "defaultMessage": "Choose file",
844
+ "description": "Default placeholder text shown when no file is selected in a single-file FileInput."
845
+ },
846
+ "@astryx.fileInput.placeholderMultiple": {
847
+ "defaultMessage": "Choose files",
848
+ "description": "Default placeholder text shown when no files are selected in a multi-file FileInput."
849
+ },
810
850
  "@astryx.fileInput.required": {
811
851
  "defaultMessage": "Required",
812
852
  "description": "Screen-reader-only description on the FileInput trigger indicating the field must be filled in. Mirrors the visible `Required` indicator next to the field label; conveyed via description because aria-required is not supported on role=\"button\"."
@@ -479,6 +479,9 @@
479
479
  "@astryx.dateInput.clear": {
480
480
  "defaultMessage": "⟦Çłéàř {label}⟧"
481
481
  },
482
+ "@astryx.dateInput.invalidDate": {
483
+ "defaultMessage": "⟦Íñṽàłíð ðàţé⟧"
484
+ },
482
485
  "@astryx.dateTimeInput.timeSuffix": {
483
486
  "defaultMessage": "⟦{label} ţíɱé⟧"
484
487
  },
@@ -533,6 +536,9 @@
533
536
  "@astryx.timeInput.placeholder": {
534
537
  "defaultMessage": "⟦Šéłéçţ à ţíɱé⟧"
535
538
  },
539
+ "@astryx.timeInput.invalidTime": {
540
+ "defaultMessage": "⟦Íñṽàłíð ţíɱé⟧"
541
+ },
536
542
  "@astryx.topNav.heading.dialogLabel": {
537
543
  "defaultMessage": "⟦Ñàṽíĝàţíóñ ɱéñú⟧"
538
544
  },
@@ -605,6 +611,30 @@
605
611
  "@astryx.fileInput.clearLabel": {
606
612
  "defaultMessage": "⟦Çłéàř {label}⟧"
607
613
  },
614
+ "@astryx.fileInput.dropHint": {
615
+ "defaultMessage": "⟦Ðřóþ ƒíłéš ĥéřé⟧"
616
+ },
617
+ "@astryx.fileInput.errorInvalidType": {
618
+ "defaultMessage": "⟦\"{fileName}\" íš ñóţ àñ àççéþţéð ƒíłé ţýþé⟧"
619
+ },
620
+ "@astryx.fileInput.errorMaxFiles": {
621
+ "defaultMessage": "⟦Ṁàẋíɱúɱ {maxFiles} ƒíłéš àłłóŵéð⟧"
622
+ },
623
+ "@astryx.fileInput.errorMaxSize": {
624
+ "defaultMessage": "⟦\"{fileName}\" éẋçééðš {maxSize} łíɱíţ⟧"
625
+ },
626
+ "@astryx.fileInput.fileSelected": {
627
+ "defaultMessage": "⟦1 ƒíłé šéłéçţéð: {fileName}⟧"
628
+ },
629
+ "@astryx.fileInput.filesSelected": {
630
+ "defaultMessage": "⟦{count} ƒíłéš šéłéçţéð⟧"
631
+ },
632
+ "@astryx.fileInput.placeholder": {
633
+ "defaultMessage": "⟦Çĥóóšé ƒíłé⟧"
634
+ },
635
+ "@astryx.fileInput.placeholderMultiple": {
636
+ "defaultMessage": "⟦Çĥóóšé ƒíłéš⟧"
637
+ },
608
638
  "@astryx.fileInput.required": {
609
639
  "defaultMessage": "⟦Řéɋúířéð⟧"
610
640
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -663,7 +663,7 @@
663
663
  "@testing-library/jest-dom": "^6.6.0",
664
664
  "@testing-library/react": "^16.3.2",
665
665
  "rimraf": "^6.0.1",
666
- "@astryxdesign/cli": "0.4.0"
666
+ "@astryxdesign/cli": "0.4.1"
667
667
  },
668
668
  "dependencies": {
669
669
  "intl-messageformat": "^11.2.9"
@@ -203,8 +203,11 @@ export const docsDense = {
203
203
  bestPractices: [
204
204
  {guidance: true, description: 'Choose the right height: use "fill" for dashboards with internal scrolling and "auto" for pages that grow with content.'},
205
205
  {guidance: true, description: 'Set `contentPadding` based on content type: 4 for forms and settings, 0 for tables and dashboards.'},
206
+ {guidance: true, description: 'Give every nav slot an accessible name: TopNav and SideNav are separate navigation landmarks, so pass `label` to each.'},
207
+ {guidance: true, description: 'Start the page heading inside `children`: AppShell owns the skip link, banner, and main landmarks but renders no heading, so the first content heading is the page h1.'},
206
208
  {guidance: false, description: "Nest one AppShell inside another; it's the outermost layout frame."},
207
209
  {guidance: false, description: 'Use for sub-page layouts; use Layout for content areas within AppShell.'},
210
+ {guidance: false, description: 'Add your own skip link or <main>; AppShell renders both, and a second main landmark makes the first ambiguous.'},
208
211
  ],
209
212
  },
210
213
  propDescriptions: {
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file AspectRatio.tsx
7
5
  * @input Uses React, stylex
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports from AspectRatio.tsx
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Badge.tsx
7
5
  * @input Uses React, HTMLAttributes
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports Badge component and types from Badge.tsx
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * @file Banner.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, Banner component
6
- * @output Unit tests for Banner component behavior
6
+ * @output Unit tests for Banner component behavior, including the
7
+ * 'banner-icon' theme target riding on the status icon glyph (#4166)
7
8
  * @position Testing; validates Banner.tsx implementation
8
9
  *
9
10
  * SYNC: When modified, update this header
@@ -337,6 +338,62 @@ describe('Banner', () => {
337
338
  expect(ref.current).toBeInstanceOf(HTMLDivElement);
338
339
  });
339
340
 
341
+ // =========================================================================
342
+ // Status icon color theming (#4166)
343
+ // =========================================================================
344
+
345
+ it("carries the 'banner-icon' theme target on the default status icon glyph", () => {
346
+ // Theme overrides for 'banner-icon' + 'status:X' compile to
347
+ // '.astryx-banner-icon.<status>' (parseStyleKey). The target must sit on
348
+ // the <Icon> span itself so those same-element rules in
349
+ // @layer astryx-theme beat the Icon's own color variant.
350
+ const statuses = ['info', 'warning', 'error', 'success'] as const;
351
+ for (const status of statuses) {
352
+ const {container, unmount} = render(
353
+ <Banner status={status} title={`${status} banner`} />,
354
+ );
355
+ const glyph = container.querySelector(
356
+ `.astryx-icon.astryx-banner-icon.${status}`,
357
+ );
358
+ expect(glyph).not.toBeNull();
359
+ expect(glyph).toHaveAttribute('data-status', status);
360
+ // Exactly one element carries the target — the layout wrapper no
361
+ // longer does.
362
+ expect(container.querySelectorAll('.astryx-banner-icon')).toHaveLength(1);
363
+ unmount();
364
+ }
365
+ });
366
+
367
+ it('keeps the color variant on the theme-target element (regression pin for #4166)', () => {
368
+ // Pre-fix, '.astryx-banner-icon.info' matched the layout wrapper while
369
+ // the color variant (data-color="accent") sat on an inner span that a
370
+ // theme override could never reach. Target and paint now share one
371
+ // element.
372
+ const {container} = render(<Banner status="info" title="Info" />);
373
+ const target = container.querySelector('.astryx-banner-icon.info');
374
+ expect(target).toHaveAttribute('data-color', 'accent');
375
+ });
376
+
377
+ it("keeps the 'banner-icon' target on the wrapper for a custom icon node", () => {
378
+ // Core never injects props into consumer elements, so with a custom
379
+ // `icon` the target stays on the (layout-only) wrapper and overrides
380
+ // reach the node via inheritance. The node itself is untouched.
381
+ const {container} = render(
382
+ <Banner
383
+ status="info"
384
+ title="Custom icon"
385
+ icon={<span data-testid="custom-glyph">i</span>}
386
+ />,
387
+ );
388
+ const targets = container.querySelectorAll('.astryx-banner-icon');
389
+ expect(targets).toHaveLength(1);
390
+ expect(targets[0]?.tagName).toBe('DIV');
391
+ expect(targets[0]).toHaveAttribute('aria-hidden', 'true');
392
+ const custom = container.querySelector('[data-testid="custom-glyph"]');
393
+ expect(custom).not.toBeNull();
394
+ expect(custom?.className).toBe('');
395
+ });
396
+
340
397
  // =========================================================================
341
398
  // Icon registry integration
342
399
  // =========================================================================
@@ -12,6 +12,9 @@
12
12
  * - Root container: layout-only wrapper (flex column), no visual styling, no theme target
13
13
  * - Header area (themeProps 'banner'): colored status background with icon, title, description, actions, dismiss
14
14
  * - Content area (themeProps 'banner-content'): collapsible card background for additional content (children)
15
+ * - Status icon (themeProps 'banner-icon'): the target rides on the default
16
+ * <Icon> itself — the element that paints — so 'status:X' overrides reach
17
+ * the glyph (#4166); for a custom `icon` node it stays on the layout wrapper
15
18
  * - No left border accent — color is expressed through the full header background
16
19
  * - Each visual area owns its own border-radius (no overflow:clip on the container)
17
20
  * - When children are provided, a collapse/expand toggle button appears in the end area
@@ -445,16 +448,32 @@ export function Banner({
445
448
  : styles.headerCardStandalone),
446
449
  ),
447
450
  )}>
451
+ {/* The 'banner-icon' target rides on the element that paints: the
452
+ default <Icon> below, or this wrapper when a custom `icon` node
453
+ is passed (core never injects props into consumer elements, so
454
+ overrides reach it via inheritance). The wrapper itself stays
455
+ layout-only. */}
448
456
  <div
449
- {...mergeProps(
450
- themeProps('banner-icon', {status}),
451
- stylex.props(styles.iconWrapper),
452
- )}
457
+ {...(icon != null
458
+ ? mergeProps(
459
+ themeProps('banner-icon', {status}),
460
+ stylex.props(styles.iconWrapper),
461
+ )
462
+ : stylex.props(styles.iconWrapper))}
453
463
  aria-hidden="true">
454
464
  {icon != null ? (
455
465
  icon
456
466
  ) : (
457
- <Icon icon={defaultIconName} size="md" color={iconColor} />
467
+ // Applied to the status <Icon> itself rather than the wrapper, so
468
+ // the element that paints the glyph is the element a theme
469
+ // targets — a 'banner-icon' 'status:X' color override beats the
470
+ // Icon's own variant from @layer astryx-theme (#4166).
471
+ <Icon
472
+ icon={defaultIconName}
473
+ size="md"
474
+ color={iconColor}
475
+ {...themeProps('banner-icon', {status})}
476
+ />
458
477
  )}
459
478
  </div>
460
479
  <div {...stylex.props(styles.headerContent)}>
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Blockquote.tsx
7
5
  * @input Uses React, stylex, spacing and color tokens
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports from Blockquote.tsx
@@ -163,7 +163,7 @@ export const docs = {
163
163
  ],
164
164
  vars: [
165
165
  {name: '--_button-radius', description: 'Border radius', default: 'var(--radius-element)', private: true},
166
- {name: '--button-focus-offset', description: 'Focus ring outline offset', default: '3px'},
166
+ {name: '--button-focus-offset', description: 'Focus ring outline offset', default: 'var(--focus-outline-offset)'},
167
167
  {name: '--button-icon-only-aspect', description: 'Aspect ratio for icon-only buttons', default: '1 / 1'},
168
168
  ],
169
169
  derived: [
@@ -245,7 +245,7 @@ export const docsZh = {
245
245
  ],
246
246
  vars: [
247
247
  {name: '--_button-radius', description: '圆角半径', default: 'var(--radius-element)', private: true},
248
- {name: '--button-focus-offset', description: '焦点环轮廓偏移', default: '3px'},
248
+ {name: '--button-focus-offset', description: '焦点环轮廓偏移', default: 'var(--focus-outline-offset)'},
249
249
  {name: '--button-icon-only-aspect', description: '纯图标按钮的宽高比', default: '1 / 1'},
250
250
  ],
251
251
  derived: [
@@ -34,6 +34,7 @@ import {
34
34
  fontWeightVars,
35
35
  typeScaleVars,
36
36
  shadowVars,
37
+ focusVars,
37
38
  } from '../theme/tokens.stylex';
38
39
  import {Spinner} from '../Spinner';
39
40
  import {VisuallyHidden} from '../VisuallyHidden';
@@ -57,10 +58,10 @@ import type {ButtonVariantMap} from './index';
57
58
  const styles = stylex.create({
58
59
  base: {
59
60
  // Kept as a public themeable var (documented in Button.doc.mjs) even though
60
- // its default now matches the shared outline: removing it would break any
61
- // theme setting it, for no gain. It overrides the shared offset, so a theme
62
- // can still tune the ring distance on buttons specifically.
63
- '--button-focus-offset': '3px',
61
+ // it now defaults to the shared token: removing it would break any theme
62
+ // setting it, for no gain. It overrides the shared offset, so a theme can
63
+ // still tune the ring distance on buttons specifically.
64
+ '--button-focus-offset': focusVars['--focus-outline-offset'],
64
65
  outlineOffset: {
65
66
  default: '0',
66
67
  ':focus-visible': 'var(--button-focus-offset)',
package/src/Card/Card.tsx CHANGED
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Card.tsx
7
5
  * @input Uses container utility, StyleX
package/src/Card/index.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports Card component
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Center.tsx
7
5
  * @input Uses React, StyleX for centering styles, Layout padding.stylex for spacing-scale padding
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file index.ts
7
5
  * @input Imports Center component
@@ -17,6 +17,7 @@ import {Theme} from '../theme/Theme';
17
17
  import {defineTheme} from '../theme/defineTheme';
18
18
  import {getForcedColorsRules} from '../__tests__/forcedColors';
19
19
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
20
+ import {FOCUS_OUTLINE_PARTS} from '../utils/focusOutline.stylex';
20
21
 
21
22
  afterEach(() => {
22
23
  __resetLiveRegionsForTest();
@@ -686,7 +687,9 @@ describe('focus ring ownership (WCAG 2.4.7)', () => {
686
687
  <CheckboxInput label="Accept" value={false} onChange={() => {}} />,
687
688
  );
688
689
  focusInput(container);
689
- expect(indicatorOf(container).style.outlineStyle).toBe('solid');
690
+ expect(indicatorOf(container).style.outlineStyle).toBe(
691
+ FOCUS_OUTLINE_PARTS.outlineStyle,
692
+ );
690
693
  });
691
694
 
692
695
  it('paints it on a replacement that forwards nothing', () => {
@@ -701,7 +704,7 @@ describe('focus ring ownership (WCAG 2.4.7)', () => {
701
704
  expect(replaced.className).toBe('');
702
705
  // ...and it still gets a ring, because the owner drew it.
703
706
  focusInput(container);
704
- expect(replaced.style.outlineStyle).toBe('solid');
707
+ expect(replaced.style.outlineStyle).toBe(FOCUS_OUTLINE_PARTS.outlineStyle);
705
708
  });
706
709
 
707
710
  it('clears the ring on blur', () => {
@@ -709,7 +712,9 @@ describe('focus ring ownership (WCAG 2.4.7)', () => {
709
712
  <CheckboxInput label="Accept" value={false} onChange={() => {}} />,
710
713
  );
711
714
  const input = focusInput(container);
712
- expect(indicatorOf(container).style.outlineStyle).toBe('solid');
715
+ expect(indicatorOf(container).style.outlineStyle).toBe(
716
+ FOCUS_OUTLINE_PARTS.outlineStyle,
717
+ );
713
718
  fireEvent.blur(input);
714
719
  expect(indicatorOf(container).style.outlineStyle).toBe('');
715
720
  });
package/src/Code/Code.tsx CHANGED
@@ -1,7 +1,5 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- 'use client';
4
-
5
3
  /**
6
4
  * @file Code.tsx
7
5
  * @input Uses React, StyleX, theme tokens
@@ -9,6 +9,7 @@
9
9
 
10
10
  import {describe, it, expect, vi, beforeEach} from 'vitest';
11
11
  import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
12
+ import {useState} from 'react';
12
13
  import {CommandPalette} from './CommandPalette';
13
14
  import {createStaticSource} from '@astryxdesign/core/Typeahead';
14
15
  import type {SearchSource, SearchableItem} from '@astryxdesign/core/Typeahead';
@@ -274,6 +275,59 @@ describe('CommandPalette', () => {
274
275
  );
275
276
  });
276
277
 
278
+ it('discards a search response that resolves after the palette closed', async () => {
279
+ // A source whose search resolves only when released, and no cancel()
280
+ // implementation — closing the palette must invalidate the request itself.
281
+ const resolvers: ((items: SearchableItem[]) => void)[] = [];
282
+ const source: SearchSource = {
283
+ bootstrap: () => [],
284
+ async search(): Promise<SearchableItem[]> {
285
+ return new Promise<SearchableItem[]>(resolve => {
286
+ resolvers.push(resolve);
287
+ });
288
+ },
289
+ };
290
+
291
+ function Harness() {
292
+ const [isOpen, setIsOpen] = useState(true);
293
+ return (
294
+ <CommandPalette
295
+ isOpen={isOpen}
296
+ onOpenChange={setIsOpen}
297
+ searchSource={source}
298
+ />
299
+ );
300
+ }
301
+ render(<Harness />);
302
+
303
+ const input = screen.getByRole('combobox');
304
+ fireEvent.change(input, {target: {value: 'a'}});
305
+ await waitFor(() => expect(resolvers).toHaveLength(1));
306
+
307
+ // Close while the search is still in flight.
308
+ const dialog = screen.getByRole('dialog');
309
+ const escapeEvent = new KeyboardEvent('keydown', {
310
+ key: 'Escape',
311
+ bubbles: true,
312
+ cancelable: true,
313
+ });
314
+ dialog.dispatchEvent(escapeEvent);
315
+ await waitFor(() =>
316
+ expect(screen.getByRole('dialog', {hidden: true})).not.toHaveAttribute(
317
+ 'open',
318
+ ),
319
+ );
320
+
321
+ // The stale response arrives after close — it must not re-commit the
322
+ // abandoned query/results into the closed palette.
323
+ await act(async () => {
324
+ resolvers[0]([{id: 'stale', label: 'Stale item'}]);
325
+ });
326
+
327
+ expect(screen.getByRole('combobox', {hidden: true})).toHaveValue('');
328
+ expect(screen.queryByText('Stale item')).not.toBeInTheDocument();
329
+ });
330
+
277
331
  it('does not move the highlight while typing in the search input', async () => {
278
332
  // The palette's input already filters; letting the shared combobox
279
333
  // typeahead also chase prefixes would drag the highlight — and Enter —
@@ -319,6 +319,11 @@ export function CommandPalette<T extends SearchableItem = SearchableItem>({
319
319
  );
320
320
 
321
321
  const handleClose = useCallback(() => {
322
+ // Invalidate any in-flight search. Most sources don't implement cancel(),
323
+ // and a response that resolves after close would still pass runSearch's
324
+ // version check and re-commit the stale query/results into the closed
325
+ // palette (visible as a ghost query on reopen while bootstrap is pending).
326
+ searchVersionRef.current++;
322
327
  // Reset both committed and optimistic search on close
323
328
  setSearch('');
324
329
  setSearchResults([]);
@@ -177,7 +177,7 @@ describe('ComplexSelector', () => {
177
177
  });
178
178
 
179
179
  describe('ComplexSelector popup theme target', () => {
180
- it('puts astryx-complex-selector-popup on the content box inside the layer', async () => {
180
+ it('puts astryx-complex-selector-popup on the surface that paints, not the content box', async () => {
181
181
  const user = userEvent.setup();
182
182
  render(
183
183
  <ComplexSelector label="Fruit blend" value="Apple" triggerLabel="Apple">
@@ -186,18 +186,27 @@ describe('ComplexSelector popup theme target', () => {
186
186
  );
187
187
  await user.click(screen.getByRole('button', {name: 'Fruit blend'}));
188
188
 
189
- const layer = document.querySelector('[popover]') as HTMLElement;
190
189
  const popup = document.querySelector(
191
190
  '.astryx-complex-selector-popup',
192
191
  ) as HTMLElement;
193
192
  expect(popup).not.toBeNull();
194
- // The layer is a bare positioning box; the target must be the content box
195
- // it contains, which is what actually paints.
196
- expect(popup).not.toBe(layer);
197
- expect(layer.contains(popup)).toBe(true);
193
+
194
+ // The surface is the element usePopover renders: it carries the dialog
195
+ // role and the shared surface class, and the component's content box —
196
+ // the one with the padding and the scroll — sits INSIDE it. A target on
197
+ // that inner box cannot paint the popup's background or radius, which is
198
+ // what a theme reaches for this class to do.
199
+ expect(popup).toHaveAttribute('role', 'dialog');
200
+ expect(popup).toHaveClass('astryx-popover-surface');
201
+ expect(popup.querySelector('[id]')).not.toBeNull();
198
202
  expect(popup).toContainElement(
199
203
  screen.getByRole('button', {name: 'Done', ...h}),
200
204
  );
205
+
206
+ // And it is not the bare positioning layer either.
207
+ const layer = document.querySelector('[popover]') as HTMLElement;
208
+ expect(popup).not.toBe(layer);
209
+ expect(layer.contains(popup)).toBe(true);
201
210
  });
202
211
 
203
212
  it('keeps the target when the consumer also passes contentXstyle', async () => {
@@ -292,6 +292,11 @@ export function ComplexSelector<Value>({
292
292
  dialogLabel: label,
293
293
  hasCloseButton: false,
294
294
  hasAutoFocus: true,
295
+ // The popup's theme target belongs on the SURFACE — the element painting
296
+ // background, radius and elevation — which `usePopover` owns. Rendered on
297
+ // the content box below it, a theme's background or radius rule paints the
298
+ // wrong box.
299
+ surfaceTarget: 'complex-selector-popup',
295
300
  onHide: () => {
296
301
  document.getElementById(triggerId)?.focus();
297
302
  },
@@ -313,16 +318,7 @@ export function ComplexSelector<Value>({
313
318
  const triggerContent = triggerLabel ?? placeholder;
314
319
 
315
320
  const content = (
316
- // The theme target sits here, not on the layer: the layer element is a
317
- // bare positioning box (useLayer zeroes its borders, padding and
318
- // background), so this content box is the surface a theme has to reach to
319
- // paint the popup.
320
- <div
321
- id={contentId}
322
- {...mergeProps(
323
- themeProps('complex-selector-popup'),
324
- stylex.props(styles.content, contentXstyle),
325
- )}>
321
+ <div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
326
322
  {children(optimisticValue, commitValue, popover.hide, {
327
323
  isOpen: popover.isOpen,
328
324
  isBusy,
@@ -25,6 +25,7 @@ import {InputGroup} from '../InputGroup';
25
25
  import {InputGroupText} from '../InputGroup/InputGroupText';
26
26
  import {defineTheme} from '../theme/defineTheme';
27
27
  import {generateThemeCSS} from '../theme/generateThemeRules';
28
+ import {InternationalizationProvider} from '../i18n';
28
29
 
29
30
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
30
31
  const {prose, component} = generateThemeCSS(theme);
@@ -170,6 +171,24 @@ describe('DateInput', () => {
170
171
  expect(screen.queryByText('Invalid date')).not.toBeInTheDocument();
171
172
  });
172
173
 
174
+ it('resolves the invalid-date announcement from the i18n catalog', () => {
175
+ const {container} = render(
176
+ <InternationalizationProvider
177
+ locale="en"
178
+ overrides={{en: {'@astryx.dateInput.invalidDate': 'Ungültiges Datum'}}}>
179
+ <DateInput label="Date" onChange={() => {}} />
180
+ </InternationalizationProvider>,
181
+ );
182
+
183
+ fireEvent.change(screen.getByRole('combobox'), {
184
+ target: {value: '13/45/2024'},
185
+ });
186
+
187
+ expect(within(container).getByRole('alert')).toHaveTextContent(
188
+ 'Ungültiges Datum',
189
+ );
190
+ });
191
+
173
192
  it('reverts to previous value on blur when input is invalid', async () => {
174
193
  const onChange = vi.fn();
175
194
  render(<DateInput label="Date" value="2026-01-25" onChange={onChange} />);