@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
@@ -80,11 +80,6 @@ const styles = stylex.create({
80
80
  backgroundColor: 'transparent',
81
81
  cursor: 'pointer',
82
82
  borderRadius: radiusVars['--radius-element'],
83
- outline: {
84
- default: 'none',
85
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
86
- },
87
- outlineOffset: 1,
88
83
  },
89
84
  iconButtonDisabled: {
90
85
  cursor: 'not-allowed',
@@ -163,6 +158,7 @@ import {mergeProps, mergeRefs, isFocusDetached} from '../utils';
163
158
  import type {BaseProps} from '../BaseProps';
164
159
  import type {SizeValue} from '../utils/types';
165
160
  import {themeProps} from '../utils/themeProps';
161
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
166
162
  import {stableClassName} from '../naming';
167
163
  import {useTranslator} from '../i18n';
168
164
 
@@ -694,6 +690,7 @@ export function DateInput({
694
690
  : t('@astryx.dateInput.openCalendar')
695
691
  }
696
692
  {...stylex.props(
693
+ focusOutlineStyles.focusVisible,
697
694
  styles.iconButton,
698
695
  isEffectivelyDisabled && styles.iconButtonDisabled,
699
696
  )}>
@@ -754,7 +751,7 @@ export function DateInput({
754
751
  user would get no feedback that their entry was rejected (WCAG 3.3.1).
755
752
  */}
756
753
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
757
- {!isInputValid ? 'Invalid date' : ''}
754
+ {!isInputValid ? t('@astryx.dateInput.invalidDate') : ''}
758
755
  </VisuallyHidden>
759
756
  {hasClear && value !== undefined && !isEffectivelyDisabled && (
760
757
  <InputClearButton
@@ -61,6 +61,7 @@ import type {SizeValue} from '../utils/types';
61
61
  import {useSize} from '../SizeContext/SizeContext';
62
62
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
63
63
  import {themeProps} from '../utils/themeProps';
64
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
64
65
  import {stableClassName} from '../naming';
65
66
  import {useTranslator} from '../i18n';
66
67
 
@@ -118,11 +119,6 @@ const styles = stylex.create({
118
119
  backgroundColor: 'transparent',
119
120
  cursor: 'pointer',
120
121
  borderRadius: radiusVars['--radius-element'],
121
- outline: {
122
- default: 'none',
123
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
124
- },
125
- outlineOffset: 1,
126
122
  },
127
123
  iconButtonDisabled: {
128
124
  cursor: 'not-allowed',
@@ -160,10 +156,6 @@ const styles = stylex.create({
160
156
  color: colorVars['--color-text-primary'],
161
157
  cursor: 'pointer',
162
158
  textAlign: 'start',
163
- outline: {
164
- default: 'none',
165
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
166
- },
167
159
  },
168
160
  presetButtonActive: {
169
161
  backgroundColor: colorVars['--color-accent-muted'],
@@ -592,6 +584,7 @@ export function DateRangeInput({
592
584
  }
593
585
  tabIndex={-1}
594
586
  {...stylex.props(
587
+ focusOutlineStyles.focusVisible,
595
588
  styles.iconButton,
596
589
  isEffectivelyDisabled && styles.iconButtonDisabled,
597
590
  )}>
@@ -661,6 +654,7 @@ export function DateRangeInput({
661
654
  aria-current={isActive ? 'true' : undefined}
662
655
  onClick={() => handlePresetClick(preset)}
663
656
  {...stylex.props(
657
+ focusOutlineStyles.focusVisible,
664
658
  styles.presetButton,
665
659
  isActive && styles.presetButtonActive,
666
660
  )}>
@@ -9,7 +9,7 @@
9
9
  * SYNC: When DateTimeInput.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeEach} from 'vitest';
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
13
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
  import {getButton, queryButton} from '../__tests__/fastRoleQueries';
@@ -17,6 +17,16 @@ import {DateTimeInput} from './DateTimeInput';
17
17
  import type {ISODateTimeString} from './DateTimeInput';
18
18
  import {defineTheme} from '../theme/defineTheme';
19
19
  import {generateThemeCSS} from '../theme/generateThemeRules';
20
+ import {InternationalizationProvider} from '../i18n';
21
+ import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
22
+
23
+ function politeRegion(): HTMLElement | null {
24
+ return document.querySelector('[data-astryx-live-region="polite"]');
25
+ }
26
+
27
+ afterEach(() => {
28
+ __resetLiveRegionsForTest();
29
+ });
20
30
 
21
31
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
22
32
  const {prose, component} = generateThemeCSS(theme);
@@ -626,6 +636,35 @@ describe('DateTimeInput', () => {
626
636
 
627
637
  expect(screen.getByText('Invalid time')).toBeInTheDocument();
628
638
  });
639
+
640
+ it('resolves the invalid date and time announcements from the i18n catalog', () => {
641
+ render(
642
+ <InternationalizationProvider
643
+ locale="en"
644
+ overrides={{
645
+ en: {
646
+ '@astryx.dateInput.invalidDate': 'Ungültiges Datum',
647
+ '@astryx.timeInput.invalidTime': 'Ungültige Zeit',
648
+ },
649
+ }}>
650
+ <DateTimeInput
651
+ label="Meeting"
652
+ value={'2026-03-15T10:00' as ISODateTimeString}
653
+ onChange={() => {}}
654
+ />
655
+ </InternationalizationProvider>,
656
+ );
657
+
658
+ fireEvent.change(screen.getByRole('combobox'), {
659
+ target: {value: '13/45/2024'},
660
+ });
661
+ expect(screen.getByText('Ungültiges Datum')).toBeInTheDocument();
662
+
663
+ fireEvent.change(screen.getByLabelText('Meeting time'), {
664
+ target: {value: '99:99 zz'},
665
+ });
666
+ expect(screen.getByText('Ungültige Zeit')).toBeInTheDocument();
667
+ });
629
668
  });
630
669
  describe('disabledMessage', () => {
631
670
  // jsdom does not implement the Popover API used by the tooltip, so mock
@@ -819,6 +858,45 @@ describe('DateTimeInput', () => {
819
858
  expect(onChange).toHaveBeenCalledTimes(1);
820
859
  expect(onChange.mock.calls[0][0]).toContain('14:31');
821
860
  });
861
+
862
+ // Arrow-key stepping mutates a plain textbox programmatically, and screen
863
+ // readers do not announce programmatic textbox changes — the new value
864
+ // must be announced through the polite live region (WCAG 4.1.2).
865
+ it('politely announces the new time after ArrowUp stepping', async () => {
866
+ render(
867
+ <DateTimeInput
868
+ label="Meeting"
869
+ value={'2026-03-15T14:30' as ISODateTimeString}
870
+ onChange={() => {}}
871
+ />,
872
+ );
873
+
874
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
875
+ key: 'ArrowUp',
876
+ });
877
+
878
+ await waitFor(() => {
879
+ expect(politeRegion()).toHaveTextContent('2:31 PM');
880
+ });
881
+ });
882
+
883
+ it('politely announces the new time after ArrowDown stepping', async () => {
884
+ render(
885
+ <DateTimeInput
886
+ label="Meeting"
887
+ value={'2026-03-15T14:30' as ISODateTimeString}
888
+ onChange={() => {}}
889
+ />,
890
+ );
891
+
892
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
893
+ key: 'ArrowDown',
894
+ });
895
+
896
+ await waitFor(() => {
897
+ expect(politeRegion()).toHaveTextContent('2:29 PM');
898
+ });
899
+ });
822
900
  });
823
901
 
824
902
  describe('weekStartsOn', () => {
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file DateTimeInput.tsx
7
- * @input Uses React, Field, Calendar, usePopover, time parsing utilities
7
+ * @input Uses React, Field, Calendar, usePopover, useAnnounce, time parsing utilities
8
8
  * @output Exports DateTimeInput component, DateTimeInputProps
9
9
  * @position Core implementation; consumed by index.ts, tested by DateTimeInput.test.tsx
10
10
  *
@@ -82,8 +82,10 @@ import {
82
82
 
83
83
  import type {BaseProps} from '../BaseProps';
84
84
  import type {SizeValue} from '../utils/types';
85
+ import {useAnnounce} from '../hooks/useAnnounce';
85
86
  import {useSize} from '../SizeContext/SizeContext';
86
87
  import {themeProps} from '../utils/themeProps';
88
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
87
89
  import {useTranslator} from '../i18n';
88
90
 
89
91
  export type ISODateTimeString = string & {
@@ -118,11 +120,6 @@ const styles = stylex.create({
118
120
  backgroundColor: 'transparent',
119
121
  cursor: 'pointer',
120
122
  borderRadius: radiusVars['--radius-element'],
121
- outline: {
122
- default: 'none',
123
- ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`,
124
- },
125
- outlineOffset: 1,
126
123
  },
127
124
  iconButtonDisabled: {
128
125
  cursor: 'not-allowed',
@@ -450,6 +447,10 @@ export function DateTimeInput({
450
447
  ...rest
451
448
  }: DateTimeInputProps) {
452
449
  const t = useTranslator();
450
+ // Speaks arrow-key stepping results through the persistent live regions:
451
+ // stepping programmatically rewrites a plain textbox's value, which screen
452
+ // readers do not announce on their own (WCAG 4.1.2).
453
+ const announce = useAnnounce();
453
454
  const placeholder =
454
455
  placeholderFromProps ?? t('@astryx.dateTimeInput.placeholder');
455
456
  const timePlaceholder =
@@ -855,11 +856,23 @@ export function DateTimeInput({
855
856
  const combined = combineDateTime(valueParts.date, newTime);
856
857
  if (combined) {
857
858
  fireChange(combined);
859
+ // Screen readers do not announce the programmatic value rewrite,
860
+ // so speak the new time explicitly (WCAG 4.1.2).
861
+ announce(formatDisplayTime(newTime, hasSeconds));
858
862
  }
859
863
  }
860
864
  }
861
865
  },
862
- [valueParts, hasSeconds, timeIncrement, timeMin, timeMax, fireChange],
866
+ [
867
+ valueParts,
868
+ hasSeconds,
869
+ timeIncrement,
870
+ timeMin,
871
+ timeMax,
872
+ fireChange,
873
+ announce,
874
+ formatDisplayTime,
875
+ ],
863
876
  );
864
877
 
865
878
  // --- Clear ---
@@ -941,6 +954,7 @@ export function DateTimeInput({
941
954
  : t('@astryx.dateInput.openCalendar')
942
955
  }
943
956
  {...stylex.props(
957
+ focusOutlineStyles.focusVisible,
944
958
  styles.iconButton,
945
959
  isEffectivelyDisabled && styles.iconButtonDisabled,
946
960
  )}>
@@ -988,7 +1002,7 @@ export function DateTimeInput({
988
1002
  rejected (WCAG 3.3.1).
989
1003
  */}
990
1004
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
991
- {!isDateInputValid ? 'Invalid date' : ''}
1005
+ {!isDateInputValid ? t('@astryx.dateInput.invalidDate') : ''}
992
1006
  </VisuallyHidden>
993
1007
  {hasClear && value !== undefined && !isEffectivelyDisabled && (
994
1008
  <InputClearButton
@@ -1061,7 +1075,7 @@ export function DateTimeInput({
1061
1075
  technology (WCAG 3.3.1).
1062
1076
  */}
1063
1077
  <VisuallyHidden as="div" role="alert" aria-live="assertive">
1064
- {!isTimeInputValid ? 'Invalid time' : ''}
1078
+ {!isTimeInputValid ? t('@astryx.timeInput.invalidTime') : ''}
1065
1079
  </VisuallyHidden>
1066
1080
  </div>
1067
1081
  </div>
@@ -92,6 +92,7 @@ export function DropdownMenuRadioGroup({
92
92
  label,
93
93
  hasCloseOnSelect = true,
94
94
  children,
95
+ xstyle,
95
96
  className,
96
97
  style,
97
98
  ...rest
@@ -106,7 +107,7 @@ export function DropdownMenuRadioGroup({
106
107
  {...rest}
107
108
  role="group"
108
109
  aria-label={label}
109
- {...mergeProps(stylex.props(styles.group), {className, style})}>
110
+ {...mergeProps(stylex.props(styles.group, xstyle), {className, style})}>
110
111
  <DropdownMenuRadioGroupContext value={contextValue}>
111
112
  {children}
112
113
  </DropdownMenuRadioGroupContext>
@@ -0,0 +1,271 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file PanelSearchInput.tsx
7
+ * @input Uses React, StyleX, Icon, InputClearButton, theme tokens
8
+ * @output Exports PanelSearchInput — the search row that sits at the top of a
9
+ * dropdown panel
10
+ * @position Shared internal primitive; used by Selector and MultiSelector
11
+ *
12
+ * A dropdown panel is already a bordered, elevated surface. Nesting a bordered
13
+ * TextInput inside it draws a second box within that box, so the field reads as
14
+ * a control dropped into the menu rather than part of it. This row is the
15
+ * seamless alternative — magnifier, borderless input, clear button — separated
16
+ * from the options by a divider the panel owns.
17
+ *
18
+ * The field is shaped like the option rows under it rather than like a form
19
+ * input: same inset from the panel edge, same `--radius-element` corners, same
20
+ * height. The focus ring is drawn on that rounded box, so focus reads as "this
21
+ * row of the panel" — the same visual language as an item's highlight — instead
22
+ * of ringing the whole panel header. It appears for keyboard focus only; see
23
+ * `fieldKeyboardFocus` for why `:focus-visible` alone does not mean that.
24
+ *
25
+ * ┌─ panel ──────────────────┐
26
+ * │ ┌──────────────────────┐ │ ← field: rounded, inset, item-sized
27
+ * │ │ ⌕ Search… ✕ │ │
28
+ * │ └──────────────────────┘ │
29
+ * ├──────────────────────────┤ ← divider (full-bleed)
30
+ * │ ┌──────────────────────┐ │
31
+ * │ │ Apple │ │ ← option row: same box
32
+ *
33
+ * The clear button is the shared `InputClearButton`, so the affordance and its
34
+ * ghost-button behavior match every other input's clear. It renders AFTER the
35
+ * input in DOM order: the selector components' Tab handling depends on that
36
+ * order to keep the popup open while focus moves onto it.
37
+ *
38
+ * Not exported from the package: it is an implementation detail of the panels
39
+ * that use it, not public API.
40
+ */
41
+
42
+ import {useCallback, useEffect, useState, type Ref} from 'react';
43
+ import * as stylex from '@stylexjs/stylex';
44
+ import {Icon} from '../Icon';
45
+ import {InputClearButton} from './InputClearButton';
46
+ import {mergeProps} from '../utils';
47
+ import {
48
+ getInteractionModality,
49
+ trackInteractionModality,
50
+ } from '../utils/interactionModality';
51
+ import {
52
+ colorVars,
53
+ durationVars,
54
+ easeVars,
55
+ radiusVars,
56
+ spacingVars,
57
+ typeScaleVars,
58
+ typographyVars,
59
+ } from '../theme/tokens.stylex';
60
+ import type {BaseProps} from '../BaseProps';
61
+
62
+ const styles = stylex.create({
63
+ // Gutter only. Matches the option list's own padding so the field below
64
+ // lines up with the option rows rather than with the panel edge.
65
+ wrapper: {
66
+ paddingBlock: spacingVars['--spacing-1'],
67
+ paddingInline: spacingVars['--spacing-1'],
68
+ },
69
+ // The field proper — the same box an option row draws: `--radius-element`
70
+ // corners and, at md, a 6/8 padding pair that lands it on the option row's
71
+ // 32px height. Focus rings THIS, not the full-width row.
72
+ field: {
73
+ boxSizing: 'border-box',
74
+ display: 'flex',
75
+ alignItems: 'center',
76
+ gap: spacingVars['--spacing-2'],
77
+ width: '100%',
78
+ paddingBlock: spacingVars['--spacing-1-5'],
79
+ paddingInline: spacingVars['--spacing-2'],
80
+ borderRadius: radiusVars['--radius-element'],
81
+ transitionProperty: 'box-shadow',
82
+ transitionDuration: {
83
+ default: durationVars['--duration-fast'],
84
+ '@media (prefers-reduced-motion: reduce)': '0s',
85
+ },
86
+ transitionTimingFunction: easeVars['--ease-standard'],
87
+ },
88
+ // The focus ring, applied only while focus was taken by keyboard (see
89
+ // `isKeyboardFocus`). `:has(input:focus-visible)` stays the CSS condition —
90
+ // the browser's heuristic still decides, this only narrows it — and rings
91
+ // the field rather than the bare <input> so the magnifier and the clear
92
+ // button sit inside it, and so tabbing ONTO the clear button does not
93
+ // re-ring the field.
94
+ //
95
+ // Inset, not the shared 3px-offset outline: the field is inset 4-5px from
96
+ // the panel edge, so an outline at that offset would land on the panel's own
97
+ // border and the divider. Measured, not guessed.
98
+ fieldKeyboardFocus: {
99
+ boxShadow: {
100
+ default: 'none',
101
+ ':has(input:focus-visible)': `inset 0 0 0 2px ${colorVars['--color-accent']}`,
102
+ },
103
+ },
104
+ // The icon span needs explicit flex centering to avoid a line-height offset.
105
+ icon: {
106
+ display: 'flex',
107
+ alignItems: 'center',
108
+ flexShrink: 0,
109
+ },
110
+ input: {
111
+ flexGrow: 1,
112
+ flexShrink: 1,
113
+ minWidth: 0,
114
+ padding: 0,
115
+ margin: 0,
116
+ borderWidth: 0,
117
+ borderStyle: 'none',
118
+ backgroundColor: 'transparent',
119
+ color: colorVars['--color-text-primary'],
120
+ fontFamily: typographyVars['--font-family-body'],
121
+ // Matches the option rows below it, so the query reads as the first line of
122
+ // the list. The coarse-pointer floor keeps iOS from zooming on focus.
123
+ fontSize: {
124
+ default: typeScaleVars['--text-label-size'],
125
+ '@media (pointer: coarse)': `max(1rem, ${typeScaleVars['--text-label-size']})`,
126
+ },
127
+ lineHeight: typeScaleVars['--text-label-leading'],
128
+ // The field draws the focus ring (see `field`), so the bare input must not
129
+ // draw a second one inside it.
130
+ outline: 'none',
131
+ '::placeholder': {
132
+ color: colorVars['--color-text-secondary'],
133
+ },
134
+ },
135
+ });
136
+
137
+ export interface PanelSearchInputProps extends Omit<
138
+ BaseProps<HTMLInputElement>,
139
+ 'onChange'
140
+ > {
141
+ /** Ref forwarded to the input element (for focus management). */
142
+ ref?: Ref<HTMLInputElement>;
143
+
144
+ /**
145
+ * Accessible name for the input. Rendered as `aria-label`: the panel has no
146
+ * visible label for the field, and a placeholder is not a reliable name.
147
+ */
148
+ label: string;
149
+
150
+ /** Accessible name for the clear (✕) button, e.g. `Clear Search options`. */
151
+ clearLabel: string;
152
+
153
+ /** Placeholder text shown while the query is empty. */
154
+ placeholder?: string;
155
+
156
+ /** The current query. */
157
+ value: string;
158
+
159
+ /** Called with the next query on every keystroke and on clear. */
160
+ onValueChange: (value: string) => void;
161
+
162
+ /** Key handler for the input itself. */
163
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
164
+
165
+ /** Focus handler for the input; runs after the ring's own bookkeeping. */
166
+ onFocus?: React.FocusEventHandler<HTMLInputElement>;
167
+
168
+ /** Blur handler for the input; runs after the ring's own bookkeeping. */
169
+ onBlur?: React.FocusEventHandler<HTMLInputElement>;
170
+
171
+ /**
172
+ * Key handler for the row. Events from the input are handled by `onKeyDown`;
173
+ * this one exists for keys pressed on the clear button, which has no other
174
+ * handler of its own.
175
+ */
176
+ onContainerKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
177
+ }
178
+
179
+ /**
180
+ * Search row for the top of a dropdown panel: magnifier, borderless input, and
181
+ * a clear button once a query is typed, in a rounded box shaped like the
182
+ * option rows beneath it.
183
+ *
184
+ * `className`/`style`/`xstyle` apply to the outer row (use them to match the
185
+ * option list's inline padding); every other prop (`role`, `aria-*`, `id`, …)
186
+ * passes through to the `<input>`, so the caller owns the combobox wiring.
187
+ */
188
+ export function PanelSearchInput({
189
+ ref,
190
+ label,
191
+ clearLabel,
192
+ placeholder,
193
+ value,
194
+ onValueChange,
195
+ onKeyDown,
196
+ onFocus,
197
+ onBlur,
198
+ onContainerKeyDown,
199
+ xstyle,
200
+ className,
201
+ style,
202
+ ...props
203
+ }: PanelSearchInputProps) {
204
+ // `:focus-visible` matches a text input focused by POINTER as well (CSS
205
+ // Selectors 4, verified in Chromium), so it cannot express "keyboard focus"
206
+ // by itself. Gate it on how the user last interacted; the CSS condition
207
+ // stays `:focus-visible`, this only narrows it.
208
+ const [isKeyboardFocus, setIsKeyboardFocus] = useState(false);
209
+
210
+ useEffect(() => {
211
+ trackInteractionModality();
212
+ }, []);
213
+
214
+ const handleFocus = useCallback(
215
+ (e: React.FocusEvent<HTMLInputElement>) => {
216
+ setIsKeyboardFocus(getInteractionModality() === 'keyboard');
217
+ onFocus?.(e);
218
+ },
219
+ [onFocus],
220
+ );
221
+
222
+ const handleBlur = useCallback(
223
+ (e: React.FocusEvent<HTMLInputElement>) => {
224
+ setIsKeyboardFocus(false);
225
+ onBlur?.(e);
226
+ },
227
+ [onBlur],
228
+ );
229
+
230
+ const handleClear = useCallback(() => {
231
+ onValueChange('');
232
+ // Clearing puts the caret back where the user was typing, matching
233
+ // TextInput's built-in clear.
234
+ if (typeof ref === 'object' && ref?.current) {
235
+ ref.current.focus();
236
+ }
237
+ }, [onValueChange, ref]);
238
+
239
+ return (
240
+ <div
241
+ onKeyDown={onContainerKeyDown}
242
+ {...mergeProps(stylex.props(styles.wrapper, xstyle), className, style)}>
243
+ <div
244
+ data-keyboard-focus={isKeyboardFocus ? 'true' : undefined}
245
+ {...stylex.props(
246
+ styles.field,
247
+ isKeyboardFocus && styles.fieldKeyboardFocus,
248
+ )}>
249
+ <Icon icon="search" size="sm" color="secondary" xstyle={styles.icon} />
250
+ <input
251
+ ref={ref}
252
+ type="text"
253
+ aria-label={label}
254
+ placeholder={placeholder}
255
+ value={value}
256
+ onChange={e => onValueChange(e.target.value)}
257
+ onKeyDown={onKeyDown}
258
+ onFocus={handleFocus}
259
+ onBlur={handleBlur}
260
+ {...stylex.props(styles.input)}
261
+ {...props}
262
+ />
263
+ {value !== '' && (
264
+ <InputClearButton label={clearLabel} onClick={handleClear} />
265
+ )}
266
+ </div>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ PanelSearchInput.displayName = 'PanelSearchInput';
@@ -59,6 +59,7 @@ import type {BaseProps} from '../BaseProps';
59
59
  import type {SizeValue} from '../utils/types';
60
60
  import {themeProps} from '../utils/themeProps';
61
61
  import {useTranslator} from '../i18n';
62
+ import type {TranslatorFn} from '../i18n';
62
63
 
63
64
  function formatFileSize(bytes: number): string {
64
65
  if (bytes < 1024) {
@@ -76,12 +77,13 @@ function validateFiles(
76
77
  maxSize: number | undefined,
77
78
  maxFiles: number | undefined,
78
79
  isMultiple: boolean,
80
+ t: TranslatorFn,
79
81
  ): {valid: File[]; errors: string[]} {
80
82
  const errors: string[] = [];
81
83
  let valid = files;
82
84
 
83
85
  if (accept) {
84
- const acceptedTypes = accept.split(',').map(t => t.trim().toLowerCase());
86
+ const acceptedTypes = accept.split(',').map(s => s.trim().toLowerCase());
85
87
  valid = valid.filter(file => {
86
88
  const matches = acceptedTypes.some(type => {
87
89
  if (type.startsWith('.')) {
@@ -93,7 +95,9 @@ function validateFiles(
93
95
  return file.type.toLowerCase() === type;
94
96
  });
95
97
  if (!matches) {
96
- errors.push(`"${file.name}" is not an accepted file type`);
98
+ errors.push(
99
+ t('@astryx.fileInput.errorInvalidType', {fileName: file.name}),
100
+ );
97
101
  }
98
102
  return matches;
99
103
  });
@@ -102,7 +106,12 @@ function validateFiles(
102
106
  if (maxSize != null) {
103
107
  valid = valid.filter(file => {
104
108
  if (file.size > maxSize) {
105
- errors.push(`"${file.name}" exceeds ${formatFileSize(maxSize)} limit`);
109
+ errors.push(
110
+ t('@astryx.fileInput.errorMaxSize', {
111
+ fileName: file.name,
112
+ maxSize: formatFileSize(maxSize),
113
+ }),
114
+ );
106
115
  return false;
107
116
  }
108
117
  return true;
@@ -110,7 +119,7 @@ function validateFiles(
110
119
  }
111
120
 
112
121
  if (isMultiple && maxFiles != null && valid.length > maxFiles) {
113
- errors.push(`Maximum ${maxFiles} files allowed`);
122
+ errors.push(t('@astryx.fileInput.errorMaxFiles', {maxFiles}));
114
123
  valid = valid.slice(0, maxFiles);
115
124
  }
116
125
 
@@ -488,7 +497,9 @@ export function FileInput({
488
497
  .filter(Boolean)
489
498
  .join(' ') || undefined;
490
499
 
491
- const defaultPlaceholder = isMultiple ? 'Choose files' : 'Choose file';
500
+ const defaultPlaceholder = isMultiple
501
+ ? t('@astryx.fileInput.placeholderMultiple')
502
+ : t('@astryx.fileInput.placeholder');
492
503
  const displayPlaceholder = placeholder ?? defaultPlaceholder;
493
504
 
494
505
  const handleFiles = useCallback(
@@ -503,6 +514,7 @@ export function FileInput({
503
514
  maxSize,
504
515
  maxFiles,
505
516
  isMultiple,
517
+ t,
506
518
  );
507
519
 
508
520
  if (errors.length > 0) {
@@ -526,8 +538,8 @@ export function FileInput({
526
538
  if (errors.length === 0) {
527
539
  announce(
528
540
  valid.length === 1
529
- ? `1 file selected: ${valid[0].name}`
530
- : `${valid.length} files selected`,
541
+ ? t('@astryx.fileInput.fileSelected', {fileName: valid[0].name})
542
+ : t('@astryx.fileInput.filesSelected', {count: valid.length}),
531
543
  );
532
544
  }
533
545
 
@@ -547,6 +559,7 @@ export function FileInput({
547
559
  changeAction,
548
560
  startTransition,
549
561
  announce,
562
+ t,
550
563
  ],
551
564
  );
552
565
 
@@ -679,7 +692,7 @@ export function FileInput({
679
692
  <>
680
693
  <Icon icon="arrowUp" size="md" color="secondary" />
681
694
  <span {...stylex.props(styles.placeholderText)}>
682
- {isDragOver ? 'Drop files here' : displayPlaceholder}
695
+ {isDragOver ? t('@astryx.fileInput.dropHint') : displayPlaceholder}
683
696
  </span>
684
697
  </>
685
698
  );
package/src/Grid/Grid.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 Grid.tsx
7
5
  * @input Uses React, stylex, spacing tokens
package/src/Grid/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 from Grid.tsx and GridSpan.tsx