@astryxdesign/core 0.4.3 → 0.4.4-canary.0e8af82

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 (254) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +75 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +396 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +63 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +441 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +109 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +206 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +185 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +1038 -0
  30. package/dist/Calendar/Calendar.d.ts +17 -0
  31. package/dist/Calendar/Calendar.d.ts.map +1 -1
  32. package/dist/Calendar/Calendar.js +25 -1
  33. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  34. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  35. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  36. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  37. package/dist/Chat/ChatComposerInput.js +3 -4
  38. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  39. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  40. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  41. package/dist/ComplexSelector/ComplexSelector.js +18 -7
  42. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  43. package/dist/ContextMenu/ContextMenu.js +4 -2
  44. package/dist/DateInput/DateInput.d.ts.map +1 -1
  45. package/dist/DateInput/DateInput.js +15 -2
  46. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  47. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  48. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  49. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  50. package/dist/DateTimeInput/DateTimeInput.js +38 -3
  51. package/dist/Dialog/Dialog.d.ts.map +1 -1
  52. package/dist/Dialog/Dialog.js +2 -1
  53. package/dist/Field/FieldLabel.d.ts +2 -1
  54. package/dist/Field/FieldLabel.d.ts.map +1 -1
  55. package/dist/Field/FieldLabel.js +21 -3
  56. package/dist/Field/InputClearButton.d.ts +2 -1
  57. package/dist/Field/InputClearButton.d.ts.map +1 -1
  58. package/dist/Field/InputClearButton.js +6 -1
  59. package/dist/FormLayout/FormLayout.d.ts +24 -2
  60. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  61. package/dist/FormLayout/FormLayout.js +7 -2
  62. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  63. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  64. package/dist/FormLayout/FormLayoutContext.js +19 -4
  65. package/dist/FormLayout/index.d.ts +1 -1
  66. package/dist/FormLayout/index.d.ts.map +1 -1
  67. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  68. package/dist/Lightbox/Lightbox.js +9 -3
  69. package/dist/MobileNav/MobileNav.d.ts +12 -0
  70. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  71. package/dist/MobileNav/MobileNav.js +102 -30
  72. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  73. package/dist/MultiSelector/MultiSelector.js +27 -8
  74. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  75. package/dist/NumberInput/NumberInput.js +15 -2
  76. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  77. package/dist/RadioList/RadioList.d.ts.map +1 -1
  78. package/dist/RadioList/RadioList.js +12 -1
  79. package/dist/Selector/Selector.d.ts.map +1 -1
  80. package/dist/Selector/Selector.js +20 -4
  81. package/dist/StatusDot/StatusDot.d.ts +43 -3
  82. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  83. package/dist/StatusDot/StatusDot.js +42 -5
  84. package/dist/Switch/Switch.d.ts.map +1 -1
  85. package/dist/Switch/Switch.js +14 -1
  86. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  87. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  88. package/dist/TextArea/TextArea.d.ts.map +1 -1
  89. package/dist/TextArea/TextArea.js +6 -1
  90. package/dist/TextInput/TextInput.d.ts.map +1 -1
  91. package/dist/TextInput/TextInput.js +6 -1
  92. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  93. package/dist/TimeInput/TimeInput.js +14 -2
  94. package/dist/Tokenizer/Tokenizer.js +11 -5
  95. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  96. package/dist/Tooltip/useTooltip.js +4 -1
  97. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  98. package/dist/Typeahead/BaseTypeahead.js +5 -3
  99. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  100. package/dist/Typeahead/Typeahead.js +10 -1
  101. package/dist/astryx.css +21 -0
  102. package/dist/hooks/index.d.ts +8 -1
  103. package/dist/hooks/index.d.ts.map +1 -1
  104. package/dist/hooks/index.js +8 -1
  105. package/dist/hooks/useFocusTrap.d.ts +0 -11
  106. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  107. package/dist/hooks/useFocusTrap.js +1 -11
  108. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  109. package/dist/hooks/useInputStatusIcon.js +1 -0
  110. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  111. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  112. package/dist/hooks/useResolvedRequired.js +40 -0
  113. package/dist/index.d.ts +1 -0
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +1 -0
  116. package/dist/theme/defineTheme.d.ts +22 -4
  117. package/dist/theme/defineTheme.d.ts.map +1 -1
  118. package/dist/theme/expandColorScale.d.ts +17 -2
  119. package/dist/theme/expandColorScale.d.ts.map +1 -1
  120. package/dist/theme/expandColorScale.js +63 -33
  121. package/dist/utils/ime.d.ts +5 -0
  122. package/dist/utils/ime.d.ts.map +1 -0
  123. package/dist/utils/ime.js +63 -0
  124. package/dist/utils/index.d.ts +1 -0
  125. package/dist/utils/index.d.ts.map +1 -1
  126. package/dist/utils/index.js +2 -1
  127. package/dist/utils/plainDate.d.ts +6 -0
  128. package/dist/utils/plainDate.d.ts.map +1 -1
  129. package/dist/utils/plainDate.js +12 -0
  130. package/locales/af-ZA.json +958 -0
  131. package/locales/ar-SA.json +998 -0
  132. package/locales/ca-ES.json +974 -0
  133. package/locales/cs-CZ.json +990 -0
  134. package/locales/da-DK.json +966 -0
  135. package/locales/de-DE.json +966 -0
  136. package/locales/el-GR.json +990 -0
  137. package/locales/en.json +52 -0
  138. package/locales/es-ES.json +978 -0
  139. package/locales/fi-FI.json +990 -0
  140. package/locales/fr-FR.json +928 -0
  141. package/locales/he-IL.json +990 -0
  142. package/locales/hu-HU.json +990 -0
  143. package/locales/it-IT.json +978 -0
  144. package/locales/ja-JP.json +998 -0
  145. package/locales/ko-KR.json +990 -0
  146. package/locales/nl-NL.json +954 -0
  147. package/locales/no-NO.json +978 -0
  148. package/locales/pl-PL.json +982 -0
  149. package/locales/pseudo.json +39 -0
  150. package/locales/pt-BR.json +982 -0
  151. package/locales/pt-PT.json +982 -0
  152. package/locales/ro-RO.json +982 -0
  153. package/locales/ru-RU.json +990 -0
  154. package/locales/sr-SP.json +990 -0
  155. package/locales/sv-SE.json +986 -0
  156. package/locales/tr-TR.json +990 -0
  157. package/locales/uk-UA.json +990 -0
  158. package/locales/vi-VN.json +986 -0
  159. package/locales/zh-CN.json +998 -0
  160. package/locales/zh-TW.json +998 -0
  161. package/package.json +9 -8
  162. package/src/Banner/Banner.doc.mjs +3 -3
  163. package/src/Banner/Banner.test.tsx +40 -0
  164. package/src/Banner/Banner.tsx +27 -2
  165. package/src/BottomSheet/BottomSheet.doc.mjs +299 -0
  166. package/src/BottomSheet/BottomSheet.test.tsx +2265 -0
  167. package/src/BottomSheet/BottomSheet.tsx +559 -0
  168. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  169. package/src/BottomSheet/BottomSheetPanel.tsx +601 -0
  170. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  171. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  172. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  173. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  174. package/src/BottomSheet/index.ts +19 -0
  175. package/src/BottomSheet/snapOffsets.test.ts +254 -0
  176. package/src/BottomSheet/snapOffsets.ts +245 -0
  177. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  178. package/src/BottomSheet/useSheetGestures.test.ts +1112 -0
  179. package/src/BottomSheet/useSheetGestures.ts +1503 -0
  180. package/src/Calendar/Calendar.doc.mjs +16 -0
  181. package/src/Calendar/Calendar.test.tsx +96 -0
  182. package/src/Calendar/Calendar.tsx +55 -0
  183. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  184. package/src/Chat/ChatComposerInput.tsx +3 -4
  185. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  186. package/src/ComplexSelector/ComplexSelector.tsx +15 -6
  187. package/src/ContextMenu/ContextMenu.tsx +4 -2
  188. package/src/DateInput/DateInput.test.tsx +21 -0
  189. package/src/DateInput/DateInput.tsx +12 -2
  190. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  191. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  192. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  193. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  194. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  195. package/src/DateTimeInput/DateTimeInput.tsx +43 -4
  196. package/src/Dialog/Dialog.tsx +2 -1
  197. package/src/Field/Field.doc.mjs +1 -0
  198. package/src/Field/FieldLabel.tsx +20 -4
  199. package/src/Field/InputClearButton.test.tsx +25 -0
  200. package/src/Field/InputClearButton.tsx +4 -1
  201. package/src/FileInput/FileInput.test.tsx +53 -9
  202. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  203. package/src/FormLayout/FormLayout.test.tsx +181 -4
  204. package/src/FormLayout/FormLayout.tsx +33 -2
  205. package/src/FormLayout/FormLayoutContext.ts +22 -7
  206. package/src/FormLayout/index.ts +1 -1
  207. package/src/Lightbox/Lightbox.test.tsx +49 -28
  208. package/src/Lightbox/Lightbox.tsx +7 -3
  209. package/src/MobileNav/MobileNav.tsx +128 -37
  210. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  211. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  212. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  213. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  214. package/src/MultiSelector/MultiSelector.tsx +25 -9
  215. package/src/NumberInput/NumberInput.test.tsx +18 -0
  216. package/src/NumberInput/NumberInput.tsx +12 -2
  217. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  218. package/src/RadioList/RadioList.tsx +9 -1
  219. package/src/Selector/Selector.test.tsx +85 -26
  220. package/src/Selector/Selector.tsx +16 -5
  221. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  222. package/src/StatusDot/StatusDot.test.tsx +115 -8
  223. package/src/StatusDot/StatusDot.tsx +74 -9
  224. package/src/Switch/Switch.test.tsx +16 -0
  225. package/src/Switch/Switch.tsx +13 -0
  226. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  227. package/src/TextArea/TextArea.tsx +3 -1
  228. package/src/TextInput/TextInput.tsx +3 -1
  229. package/src/TimeInput/TimeInput.test.tsx +23 -0
  230. package/src/TimeInput/TimeInput.tsx +11 -1
  231. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  232. package/src/Tokenizer/Tokenizer.tsx +5 -5
  233. package/src/Tooltip/useTooltip.tsx +4 -1
  234. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  235. package/src/Typeahead/Typeahead.test.tsx +116 -7
  236. package/src/Typeahead/Typeahead.tsx +10 -1
  237. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  238. package/src/__tests__/stubMatchMedia.ts +95 -0
  239. package/src/hooks/index.ts +9 -5
  240. package/src/hooks/useFocusTrap.ts +1 -14
  241. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  242. package/src/hooks/useInputStatusIcon.tsx +7 -0
  243. package/src/hooks/useResolvedRequired.ts +42 -0
  244. package/src/index.ts +1 -0
  245. package/src/theme/defineTheme.ts +22 -4
  246. package/src/theme/expandColorScale.test.ts +138 -0
  247. package/src/theme/expandColorScale.ts +89 -44
  248. package/src/utils/ime.test.ts +32 -0
  249. package/src/utils/ime.ts +69 -0
  250. package/src/utils/index.ts +2 -0
  251. package/src/utils/plainDate.test.ts +50 -0
  252. package/src/utils/plainDate.ts +12 -0
  253. package/dist/astryx.umd.js +0 -147
  254. package/dist/astryx.umd.js.map +0 -7
@@ -29,6 +29,7 @@ import {spacingVars} from '../theme/tokens.stylex';
29
29
  import {Field} from '../Field/Field';
30
30
  import type {InputStatus} from '../Field/types';
31
31
  import {useTooltip} from '../Tooltip';
32
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
32
33
  import {mergeProps} from '../utils';
33
34
  import type {BaseProps} from '../BaseProps';
34
35
  import type {SizeValue} from '../utils/types';
@@ -218,6 +219,13 @@ export function RadioList({
218
219
 
219
220
  const groupRef = useRef<HTMLDivElement>(null);
220
221
 
222
+ // The radiogroup exposes the *effective* required state so a form-wide
223
+ // `defaultOptionality="required"` is announced even when the group carries no
224
+ // visible indicator. Individual radios keep their native `required` bound to
225
+ // the explicit `isRequired` (via context), so a layout default never switches
226
+ // on browser validation for the group.
227
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
228
+
221
229
  // Disabled-reason tooltip. Applies to the whole-group disabled state. Disabled
222
230
  // controls swallow pointer events, so the tooltip listeners attach to the
223
231
  // radiogroup container and the radios stay perceivable via aria-disabled
@@ -387,7 +395,7 @@ export function RadioList({
387
395
  .join(' ') || undefined
388
396
  }
389
397
  aria-invalid={status?.type === 'error' ? true : undefined}
390
- aria-required={isRequired || undefined}
398
+ aria-required={isEffectivelyRequired || undefined}
391
399
  {...mergeProps(
392
400
  themeProps('radio-list', {orientation, size}),
393
401
  stylex.props(
@@ -26,6 +26,7 @@ import {RadioIndicator} from '../Indicator';
26
26
  import {InputGroup, InputGroupText} from '../InputGroup';
27
27
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
28
28
  import {__resetInteractionModalityForTest} from '../utils/interactionModality';
29
+ import {InternationalizationProvider} from '../i18n';
29
30
  import {defineTheme} from '../theme/defineTheme';
30
31
  import {Theme} from '../theme/Theme';
31
32
  import {generateThemeCSS} from '../theme/generateThemeRules';
@@ -1000,60 +1001,118 @@ describe('Selector', () => {
1000
1001
  ).toBeInTheDocument();
1001
1002
  });
1002
1003
 
1004
+ it('does not select the highlighted option on a composing Enter (IME)', async () => {
1005
+ const user = userEvent.setup();
1006
+ const onChange = vi.fn();
1007
+ render(
1008
+ <Selector
1009
+ label="Fruit"
1010
+ options={OPTIONS}
1011
+ onChange={onChange}
1012
+ hasSearch
1013
+ />,
1014
+ );
1015
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
1016
+ const search = screen.getByRole('combobox', h);
1017
+ // Filter to Banana and highlight it so an unguarded Enter would commit a
1018
+ // selection.
1019
+ await user.type(search, 'ban');
1020
+ await user.keyboard('{ArrowDown}');
1021
+ expect(search).toHaveAttribute('aria-activedescendant');
1022
+
1023
+ // The browser fires this composing keydown for the Enter that commits an
1024
+ // IME candidate (isComposing: true, or the legacy keyCode 229) before
1025
+ // compositionend writes the syllable. It must NOT be read as "select the
1026
+ // highlighted option".
1027
+ fireEvent.keyDown(search, {key: 'Enter', isComposing: true});
1028
+ expect(onChange).not.toHaveBeenCalled();
1029
+ fireEvent.keyDown(search, {key: 'Enter', keyCode: 229});
1030
+ expect(onChange).not.toHaveBeenCalled();
1031
+
1032
+ // A real, non-composing Enter still selects the highlighted option.
1033
+ fireEvent.keyDown(search, {key: 'Enter'});
1034
+ expect(onChange).toHaveBeenCalledWith('Banana');
1035
+ });
1036
+
1003
1037
  describe('result announcements', () => {
1004
1038
  it('announces the match count politely while searching', async () => {
1005
1039
  const user = userEvent.setup();
1006
1040
  render(
1007
- <Selector
1008
- label="Fruit"
1009
- options={OPTIONS}
1010
- value="Apple"
1011
- onChange={() => {}}
1012
- hasSearch
1013
- />,
1041
+ <InternationalizationProvider
1042
+ locale="fr"
1043
+ overrides={{
1044
+ fr: {
1045
+ '@astryx.selector.resultCount':
1046
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
1047
+ },
1048
+ }}>
1049
+ <Selector
1050
+ label="Fruit"
1051
+ options={OPTIONS}
1052
+ value="Apple"
1053
+ onChange={() => {}}
1054
+ hasSearch
1055
+ />
1056
+ </InternationalizationProvider>,
1014
1057
  );
1015
1058
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
1016
1059
  // "a" matches Apple and Banana.
1017
1060
  await user.type(screen.getByRole('combobox', h), 'a');
1018
1061
  await waitFor(() => {
1019
- expect(politeRegion()).toHaveTextContent('2 results');
1062
+ // The plural branch of a message no catalog supplies.
1063
+ expect(politeRegion()?.textContent).toBe('2 résultats');
1020
1064
  });
1021
1065
  });
1022
1066
 
1023
1067
  it('announces the singular form when one option matches', async () => {
1024
1068
  const user = userEvent.setup();
1025
1069
  render(
1026
- <Selector
1027
- label="Fruit"
1028
- options={OPTIONS}
1029
- value="Apple"
1030
- onChange={() => {}}
1031
- hasSearch
1032
- />,
1070
+ <InternationalizationProvider
1071
+ locale="fr"
1072
+ overrides={{
1073
+ fr: {
1074
+ '@astryx.selector.resultCount':
1075
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
1076
+ },
1077
+ }}>
1078
+ <Selector
1079
+ label="Fruit"
1080
+ options={OPTIONS}
1081
+ value="Apple"
1082
+ onChange={() => {}}
1083
+ hasSearch
1084
+ />
1085
+ </InternationalizationProvider>,
1033
1086
  );
1034
1087
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
1035
- // "ban" matches only Banana. Anchored so it cannot pass on "1 results".
1088
+ // "ban" matches only Banana. Exact, so "1 résultats" would fail.
1036
1089
  await user.type(screen.getByRole('combobox', h), 'ban');
1037
1090
  await waitFor(() => {
1038
- expect(politeRegion()).toHaveTextContent(/^1 result$/);
1091
+ expect(politeRegion()?.textContent).toBe('1 résultat');
1039
1092
  });
1040
1093
  });
1041
1094
 
1042
- it('announces "No results found" when nothing matches', async () => {
1095
+ it('announces the empty-results message when nothing matches', async () => {
1043
1096
  const user = userEvent.setup();
1044
1097
  render(
1045
- <Selector
1046
- label="Fruit"
1047
- options={OPTIONS}
1048
- value="Apple"
1049
- onChange={() => {}}
1050
- hasSearch
1051
- />,
1098
+ <InternationalizationProvider
1099
+ locale="fr"
1100
+ overrides={{
1101
+ fr: {'@astryx.selector.emptySearchResults': 'Aucun résultat'},
1102
+ }}>
1103
+ <Selector
1104
+ label="Fruit"
1105
+ options={OPTIONS}
1106
+ value="Apple"
1107
+ onChange={() => {}}
1108
+ hasSearch
1109
+ />
1110
+ </InternationalizationProvider>,
1052
1111
  );
1053
1112
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
1054
1113
  await user.type(screen.getByRole('combobox', h), 'xyz');
1055
1114
  await waitFor(() => {
1056
- expect(politeRegion()).toHaveTextContent('No results found');
1115
+ expect(politeRegion()?.textContent).toBe('Aucun résultat');
1057
1116
  });
1058
1117
  });
1059
1118
 
@@ -71,8 +71,9 @@ import {
71
71
  } from './utils';
72
72
  import {useCombobox, useSelectedItemOffset} from './hooks';
73
73
  import {useTypeahead} from '../hooks/useTypeahead';
74
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
74
75
  import {SelectorOption} from './SelectorOption';
75
- import {getInputARIA, mergeProps} from '../utils';
76
+ import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
76
77
  import {useSize} from '../SizeContext/SizeContext';
77
78
  import type {BaseProps} from '../BaseProps';
78
79
  import type {SizeValue} from '../utils/types';
@@ -699,6 +700,7 @@ export function Selector<T extends SelectorOptionType>(
699
700
  hasClear: hasClearProp,
700
701
  ...rest
701
702
  } = props as SelectorPropsClearable<T>;
703
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
702
704
  const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
703
705
  const searchPlaceholder =
704
706
  searchPlaceholderFromProps ?? t('@astryx.selector.searchPlaceholder');
@@ -844,11 +846,11 @@ export function Selector<T extends SelectorOptionType>(
844
846
  const count = filterOptionsByQuery(selectableItems, nextQuery).length;
845
847
  announce(
846
848
  count === 0
847
- ? 'No results found'
848
- : `${count} result${count === 1 ? '' : 's'}`,
849
+ ? t('@astryx.selector.emptySearchResults')
850
+ : t('@astryx.selector.resultCount', {count}),
849
851
  );
850
852
  },
851
- [announce, selectableItems],
853
+ [announce, selectableItems, t],
852
854
  );
853
855
 
854
856
  // Calculate offset to position selected item over trigger. Explicit
@@ -1055,6 +1057,15 @@ export function Selector<T extends SelectorOptionType>(
1055
1057
  }
1056
1058
  }}
1057
1059
  onKeyDown={e => {
1060
+ // An in-progress IME composition uses these same keys (Enter to
1061
+ // commit the candidate, Escape/Arrows to navigate the candidate
1062
+ // window); the composing keydown fires before compositionend, so
1063
+ // without this guard a Korean/Japanese/Chinese user committing a
1064
+ // syllable with Enter would instead select the highlighted option.
1065
+ // See utils/ime.ts.
1066
+ if (isImeKeyEvent(e.nativeEvent)) {
1067
+ return;
1068
+ }
1058
1069
  // Arrow keys navigate options; Enter selects; Escape closes.
1059
1070
  // Home/End are left to the input for caret movement (APG editable
1060
1071
  // combobox); PageUp/PageDown are the sanctioned substitute for
@@ -1336,7 +1347,7 @@ export function Selector<T extends SelectorOptionType>(
1336
1347
  }
1337
1348
  aria-describedby={ariaDescribedBy}
1338
1349
  aria-labelledby={ariaLabelledBy}
1339
- aria-required={isRequired ? 'true' : undefined}
1350
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
1340
1351
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
1341
1352
  aria-busy={isBusy || undefined}
1342
1353
  // With a disabledMessage the trigger keeps focusability via
@@ -33,6 +33,12 @@ export const docs = {
33
33
  description:
34
34
  'Tooltip text shown on hover to explain the status meaning.',
35
35
  },
36
+ {
37
+ name: 'icon',
38
+ type: 'ReactNode',
39
+ description:
40
+ 'Optional icon rendered centered inside the dot, painted in currentColor (the variant\'s ink). Gives the status a non-color mark, so use a different icon per status. Booleans and empty strings are ignored, so `cond && <Icon />` is safe. Same contract as AvatarStatusDot.',
41
+ },
36
42
  {
37
43
  name: 'xstyle',
38
44
  type: 'StyleXStyles',
@@ -84,6 +90,12 @@ export const docsZh = {
84
90
  '启用脉冲动画;尊重 prefers-reduced-motion: reduce 设置。',
85
91
  default: 'false',
86
92
  },
93
+ {
94
+ name: 'icon',
95
+ type: 'ReactNode',
96
+ description:
97
+ '可选图标,居中渲染于圆点内,以 currentColor(变体的前景色)着色。为状态提供非颜色标记,请为每个状态使用不同图标。布尔值和空字符串会被忽略,因此 `cond && <Icon />` 是安全的。与 AvatarStatusDot 的契约一致。',
98
+ },
87
99
  {
88
100
  name: 'xstyle',
89
101
  type: 'StyleXStyles',
@@ -132,6 +144,7 @@ export const docsDense = {
132
144
  label: 'Accessible label via aria-label.',
133
145
  isPulsing: 'Pulse animation; respects prefers-reduced-motion: reduce.',
134
146
  tooltip: 'Tooltip text on hover to explain status meaning.',
147
+ icon: 'Optional ReactNode rendered centered in the dot (currentColor ink); a non-color mark for the status, use a different icon per status. Booleans/empty strings ignored (safe for cond && <Icon/>). Same contract as AvatarStatusDot.',
135
148
  xstyle: 'StyleX layout styles; must be stylex.create() value.',
136
149
  },
137
150
  };
@@ -2,8 +2,16 @@
2
2
 
3
3
  import {describe, it, expect} from 'vitest';
4
4
  import {render, screen} from '@testing-library/react';
5
+ import * as stylex from '@stylexjs/stylex';
6
+ import {colorVars} from '../theme/tokens.stylex';
5
7
  import {StatusDot} from './StatusDot';
6
8
 
9
+ /** Renders a StatusDot and returns its root element. */
10
+ function renderDot(props: React.ComponentProps<typeof StatusDot>) {
11
+ render(<StatusDot {...props} />);
12
+ return screen.getByRole('img', {name: props.label});
13
+ }
14
+
7
15
  describe('StatusDot', () => {
8
16
  it('renders with role="img" and aria-label', () => {
9
17
  render(<StatusDot variant="success" label="Online" />);
@@ -27,9 +35,7 @@ describe('StatusDot', () => {
27
35
  ] as const;
28
36
 
29
37
  for (const variant of variants) {
30
- const {unmount} = render(
31
- <StatusDot variant={variant} label={variant} />,
32
- );
38
+ const {unmount} = render(<StatusDot variant={variant} label={variant} />);
33
39
  expect(screen.getByRole('img', {name: variant})).toBeInTheDocument();
34
40
  unmount();
35
41
  }
@@ -49,11 +55,7 @@ describe('StatusDot', () => {
49
55
 
50
56
  it('supports data-testid', () => {
51
57
  render(
52
- <StatusDot
53
- variant="success"
54
- label="Online"
55
- data-testid="status-dot"
56
- />,
58
+ <StatusDot variant="success" label="Online" data-testid="status-dot" />,
57
59
  );
58
60
  expect(screen.getByTestId('status-dot')).toBeInTheDocument();
59
61
  });
@@ -81,4 +83,109 @@ describe('StatusDot', () => {
81
83
  const dot = screen.getByRole('img', {name: 'Online'});
82
84
  expect(dot).toBeInTheDocument();
83
85
  });
86
+
87
+ it('renders every variant as a plain childless dot by default (design review #4373)', () => {
88
+ // The dot is deliberately a colour-only signal by default — no built-in
89
+ // per-variant glyph. Making the status accessible in context (label,
90
+ // icon, or an accessible alternative) is the builder's responsibility;
91
+ // see the usage guidance.
92
+ const variants = [
93
+ 'success',
94
+ 'warning',
95
+ 'error',
96
+ 'accent',
97
+ 'neutral',
98
+ ] as const;
99
+ for (const variant of variants) {
100
+ const dot = renderDot({variant, label: `plain-${variant}`});
101
+ expect(dot.childElementCount, variant).toBe(0);
102
+ }
103
+ });
104
+
105
+ describe('custom icon (parity with AvatarStatusDot)', () => {
106
+ const ICON_TESTID = 'custom-icon';
107
+ function Icon() {
108
+ return <svg data-testid={ICON_TESTID} />;
109
+ }
110
+
111
+ it('renders a provided icon inside the dot', () => {
112
+ renderDot({variant: 'success', label: 'Verified', icon: <Icon />});
113
+ expect(screen.getByTestId(ICON_TESTID)).toBeInTheDocument();
114
+ });
115
+
116
+ it('hides the icon wrapper from assistive tech (the label carries the status)', () => {
117
+ renderDot({variant: 'success', label: 'Verified', icon: <Icon />});
118
+ const iconEl = screen.getByTestId(ICON_TESTID);
119
+ expect(iconEl.closest('[aria-hidden="true"]')).not.toBeNull();
120
+ });
121
+
122
+ it.each([false, true, null, undefined, ''])(
123
+ 'ignores %s and keeps the plain dot (safe for `cond && <Icon />`)',
124
+ value => {
125
+ const dot = renderDot({
126
+ variant: 'error',
127
+ label: 'Offline',
128
+ icon: value,
129
+ });
130
+ expect(dot.childElementCount).toBe(0);
131
+ },
132
+ );
133
+
134
+ it('keeps the accessible name on the dot when an icon renders', () => {
135
+ const dot = renderDot({
136
+ variant: 'success',
137
+ label: 'Verified',
138
+ icon: <Icon />,
139
+ });
140
+ expect(dot).toHaveAttribute('role', 'img');
141
+ expect(dot).toHaveAttribute('aria-label', 'Verified');
142
+ });
143
+ });
144
+
145
+ describe('variant ink (a passed icon paints from currentColor)', () => {
146
+ it('pairs the warning plate with the dedicated dark on-warning ink', () => {
147
+ // The regression this guards: a light surface ink on the yellow warning
148
+ // plate lands near 2:1, while `--color-on-warning` is the fixed dark
149
+ // ink (~9.6:1). An icon inherits the ink via `currentColor`, so the
150
+ // pairing is what keeps custom icons legible.
151
+ const probe = stylex.create({
152
+ warning: {
153
+ backgroundColor: colorVars['--color-warning'],
154
+ color: colorVars['--color-on-warning'],
155
+ },
156
+ });
157
+ const dot = renderDot({variant: 'warning', label: 'Degraded'});
158
+ const atomicClasses = (stylex.props(probe.warning).className ?? '')
159
+ .split(' ')
160
+ // Dev mode prepends a per-file `File__style.key` debug class that
161
+ // legitimately differs between the probe and the component.
162
+ .filter(cls => !cls.includes('__'));
163
+ expect(atomicClasses.length).toBeGreaterThan(0);
164
+ for (const cls of atomicClasses) {
165
+ expect(dot.className).toContain(cls);
166
+ }
167
+ });
168
+ });
169
+
170
+ describe('accessible name (label reaches AT without hover)', () => {
171
+ it('exposes the status label as the accessible name for every variant, without a tooltip', () => {
172
+ const variants = [
173
+ 'success',
174
+ 'warning',
175
+ 'error',
176
+ 'accent',
177
+ 'neutral',
178
+ ] as const;
179
+ for (const variant of variants) {
180
+ const {unmount} = render(
181
+ <StatusDot variant={variant} label={`Status: ${variant}`} />,
182
+ );
183
+ const dot = screen.getByRole('img', {name: `Status: ${variant}`});
184
+ expect(dot).toHaveAttribute('aria-label', `Status: ${variant}`);
185
+ // The name must not depend on hover or focus.
186
+ expect(dot.getAttribute('tabindex')).toBeNull();
187
+ unmount();
188
+ }
189
+ });
190
+ });
84
191
  });
@@ -16,11 +16,13 @@
16
16
  * - /packages/cli/assets/templates/blocks/components/StatusDot/ (showcase blocks)
17
17
  */
18
18
 
19
+ import type {ReactNode} from 'react';
20
+
19
21
  import * as stylex from '@stylexjs/stylex';
20
22
  import {colorVars} from '../theme/tokens.stylex';
21
23
  import type {BaseProps} from '../BaseProps';
22
24
  import {Tooltip} from '../Tooltip/Tooltip';
23
- import {mergeProps} from '../utils';
25
+ import {isRenderable, mergeProps} from '../utils';
24
26
  import {themeProps} from '../utils/themeProps';
25
27
  import type {StatusDotVariantMap} from './index';
26
28
 
@@ -33,16 +35,21 @@ const pulseKeyframes = stylex.keyframes({
33
35
  '100%': {opacity: 1},
34
36
  });
35
37
 
38
+ /** Fixed dot size in px. A user-supplied icon is drawn into this same field. */
39
+ const DOT_SIZE = 8;
40
+
36
41
  /**
37
42
  * Base styles
38
43
  */
39
44
  const styles = stylex.create({
40
45
  base: {
41
- display: 'inline-block',
46
+ display: 'inline-flex',
47
+ alignItems: 'center',
48
+ justifyContent: 'center',
42
49
  borderRadius: '50%',
43
50
  flexShrink: 0,
44
- width: '8px',
45
- height: '8px',
51
+ width: `${DOT_SIZE}px`,
52
+ height: `${DOT_SIZE}px`,
46
53
  },
47
54
  pulsing: {
48
55
  animationName: pulseKeyframes,
@@ -55,26 +62,49 @@ const styles = stylex.create({
55
62
  animationName: 'none',
56
63
  },
57
64
  },
65
+ // A user-supplied icon fills the whole 8px field and paints from
66
+ // `currentColor` (the variant's ink).
67
+ icon: {
68
+ display: 'flex',
69
+ alignItems: 'center',
70
+ justifyContent: 'center',
71
+ lineHeight: 0,
72
+ width: `${DOT_SIZE}px`,
73
+ height: `${DOT_SIZE}px`,
74
+ },
58
75
  });
59
76
 
60
77
  /**
61
- * Variant styles mapping to theme color tokens
78
+ * Variant styles mapping to theme color tokens.
79
+ *
80
+ * Each variant sets both the plate colour and an ink colour: a user-supplied
81
+ * `icon` paints from `currentColor`, so plate and ink can never drift out of
82
+ * contrast (same contract as AvatarStatusDot). The ink is each plate's
83
+ * dedicated `--color-on-*` pairing (the Badge precedent), not the surface
84
+ * colour: on-warning is a fixed dark ink, which keeps an icon legible on the
85
+ * yellow plate (~9.6:1) where a light surface ink lands near 2:1. Neutral has
86
+ * no `--color-on-*` token; its mid-grey plate takes the surface ink.
62
87
  */
63
88
  const variants = stylex.create({
64
89
  success: {
65
90
  backgroundColor: colorVars['--color-success'],
91
+ color: colorVars['--color-on-success'],
66
92
  },
67
93
  warning: {
68
94
  backgroundColor: colorVars['--color-warning'],
95
+ color: colorVars['--color-on-warning'],
69
96
  },
70
97
  error: {
71
98
  backgroundColor: colorVars['--color-error'],
99
+ color: colorVars['--color-on-error'],
72
100
  },
73
101
  accent: {
74
102
  backgroundColor: colorVars['--color-accent'],
103
+ color: colorVars['--color-on-accent'],
75
104
  },
76
105
  neutral: {
77
106
  backgroundColor: colorVars['--color-icon-secondary'],
107
+ color: colorVars['--color-background-surface'],
78
108
  },
79
109
  });
80
110
 
@@ -92,7 +122,8 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
92
122
  */
93
123
  variant: StatusDotVariant;
94
124
  /**
95
- * Accessible label describing the status. Required for a11y.
125
+ * Accessible label describing the status. Required for a11y — it is the
126
+ * dot's `aria-label`, so the status reaches screen readers without hover.
96
127
  */
97
128
  label: string;
98
129
  /**
@@ -106,12 +137,36 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
106
137
  * When omitted, no tooltip is rendered.
107
138
  */
108
139
  tooltip?: string;
140
+ /**
141
+ * Optional icon to render centered inside the dot. Accepts any ReactNode
142
+ * (typically an SVG icon), painted in the dot's `currentColor` ink. Same
143
+ * contract as `AvatarStatusDot`'s `icon`.
144
+ *
145
+ * By default the dot is a colour-only signal; an icon gives the status a
146
+ * non-colour mark, so use a different icon per status — the same icon on
147
+ * every variant leaves the statuses distinguishable by colour alone
148
+ * (WCAG 2.1 SC 1.4.1). Booleans and empty strings are ignored (safe for
149
+ * `cond && <Icon />`).
150
+ *
151
+ * @example
152
+ * ```
153
+ * <StatusDot variant="success" label="Verified" icon={<CheckIcon />} />
154
+ * ```
155
+ */
156
+ icon?: ReactNode;
109
157
  }
110
158
 
111
159
  /**
112
160
  * A small colored dot indicator for status display (online/offline, severity, etc).
113
161
  *
114
- * Fixed 8px size. Renders as a non-focusable `<span>` with `role="img"` and
162
+ * Fixed 8px size. By default the dot conveys status by colour only, which is
163
+ * not accessible in isolation (WCAG 2.1 SC 1.4.1) — treat it as a signal the
164
+ * surrounding UI must make accessible: use it as a binary present/absent
165
+ * signal, pair it with a visible label, pass `icon` so the status carries a
166
+ * non-colour mark, or convey the status accessibly elsewhere. See the usage
167
+ * guidance in the component docs.
168
+ *
169
+ * Renders as a non-focusable `<span>` with `role="img"` and
115
170
  * `aria-label` for accessibility. Styles use Astryx theme tokens via StyleX.
116
171
  * Wrap your app in `<Theme>` to apply a theme.
117
172
  *
@@ -121,6 +176,7 @@ export interface StatusDotProps extends BaseProps<HTMLSpanElement> {
121
176
  * <StatusDot variant="error" label="Offline" />
122
177
  * <StatusDot variant="success" label="Live" isPulsing />
123
178
  * <StatusDot variant="success" label="Online" tooltip="Online" />
179
+ * <StatusDot variant="success" label="Verified" icon={<CheckIcon />} />
124
180
  * ```
125
181
  */
126
182
  export function StatusDot({
@@ -128,12 +184,16 @@ export function StatusDot({
128
184
  label,
129
185
  isPulsing = false,
130
186
  tooltip,
187
+ icon,
131
188
  xstyle,
132
189
  className,
133
190
  style,
134
191
  ref,
135
192
  ...props
136
193
  }: StatusDotProps) {
194
+ // Booleans and empty renders don't count (safe for `cond && <Icon />`) —
195
+ // same contract as AvatarStatusDot.
196
+ const showsIcon = isRenderable(icon);
137
197
  const dot = (
138
198
  <span
139
199
  ref={ref}
@@ -151,8 +211,13 @@ export function StatusDot({
151
211
  className,
152
212
  style,
153
213
  )}
154
- {...props}
155
- />
214
+ {...props}>
215
+ {showsIcon && (
216
+ <span aria-hidden="true" {...stylex.props(styles.icon)}>
217
+ {icon}
218
+ </span>
219
+ )}
220
+ </span>
156
221
  );
157
222
 
158
223
  if (tooltip) {
@@ -262,6 +262,22 @@ describe('Switch', () => {
262
262
  expect(screen.getByLabelText('Toggle row')).toBeInTheDocument();
263
263
  });
264
264
 
265
+ it('drops the label gap when isLabelHidden so the row is only as wide as the track', () => {
266
+ const {rerender} = render(
267
+ <Switch
268
+ label="Toggle row"
269
+ isLabelHidden
270
+ value={false}
271
+ onChange={() => {}}
272
+ />,
273
+ );
274
+ const row = screen.getByRole('switch').parentElement!.parentElement!;
275
+ expect(getComputedStyle(row).gap).toMatch(/^0(px)?$/);
276
+
277
+ rerender(<Switch label="Toggle row" value={false} onChange={() => {}} />);
278
+ expect(getComputedStyle(row).gap).not.toMatch(/^0(px)?$/);
279
+ });
280
+
265
281
  it('keeps description linked via aria-describedby when isLabelHidden', () => {
266
282
  render(
267
283
  <Switch
@@ -47,6 +47,7 @@ import type {BaseProps} from '../BaseProps';
47
47
  import type {SizeValue} from '../utils/types';
48
48
  import {themeProps} from '../utils/themeProps';
49
49
  import {VisuallyHidden} from '../VisuallyHidden';
50
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
50
51
 
51
52
  const wrapperSizeStyles = stylex.create({
52
53
  sm: {
@@ -135,6 +136,12 @@ const styles = stylex.create({
135
136
  alignItems: 'center',
136
137
  gap: spacingVars['--spacing-2'],
137
138
  },
139
+ // A hidden label is sr-only, so its wrapper is a zero-width flex item — the
140
+ // row gap would still be painted beside the track, making the field box
141
+ // wider than the control it contains. Matches CheckboxInput.
142
+ containerLabelHidden: {
143
+ gap: 0,
144
+ },
138
145
  containerSpread: {
139
146
  justifyContent: 'space-between',
140
147
  width: '100%',
@@ -497,6 +504,10 @@ export function Switch({
497
504
  const id = useId();
498
505
  const descriptionID = useId();
499
506
  const statusMessageID = useId();
507
+ // Announce the effective required state (form default included) while the
508
+ // native `required` stays bound to the explicit `isRequired` so a layout
509
+ // default never switches on browser validation.
510
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
500
511
 
501
512
  const [, startTransition] = useTransition();
502
513
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
@@ -553,6 +564,7 @@ export function Switch({
553
564
  aria-disabled={showsDisabledMessage ? 'true' : undefined}
554
565
  form={showsDisabledMessage ? '' : undefined}
555
566
  required={isRequired}
567
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
556
568
  onChange={e => {
557
569
  if (isDisabled || isBusy) {
558
570
  return;
@@ -655,6 +667,7 @@ export function Switch({
655
667
  }}
656
668
  {...stylex.props(
657
669
  styles.container,
670
+ isLabelHidden && styles.containerLabelHidden,
658
671
  labelSpacing === 'spread' && styles.containerSpread,
659
672
  !isDisabled && switchScope,
660
673
  )}>