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

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 (200) 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 +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -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 +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -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 +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -20,6 +20,7 @@ import {
20
20
  import userEvent from '@testing-library/user-event';
21
21
  import {MultiSelector} from './MultiSelector';
22
22
  import {Icon} from '../Icon';
23
+ import {InternationalizationProvider} from '../i18n';
23
24
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
24
25
  import {defineTheme} from '../theme/defineTheme';
25
26
  import {generateThemeCSS} from '../theme/generateThemeRules';
@@ -415,6 +416,37 @@ describe('MultiSelector', () => {
415
416
  expect(onChange).toHaveBeenCalledWith(['Apple']);
416
417
  });
417
418
 
419
+ it('does not toggle the highlighted option on a composing Enter (IME)', async () => {
420
+ const user = userEvent.setup();
421
+ const onChange = vi.fn();
422
+ render(
423
+ <MultiSelector
424
+ label="Fruit"
425
+ options={defaultOptions}
426
+ value={[]}
427
+ onChange={onChange}
428
+ hasSearch
429
+ />,
430
+ );
431
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
432
+ const search = screen.getByRole('combobox', h);
433
+ // Filter to Banana and highlight it so an unguarded Enter would toggle it.
434
+ await user.type(search, 'ban');
435
+ await user.keyboard('{ArrowDown}');
436
+ expect(search).toHaveAttribute('aria-activedescendant');
437
+
438
+ // The composing keydown (isComposing / legacy keyCode 229) that commits an
439
+ // IME candidate must not be read as "toggle the highlighted option".
440
+ fireEvent.keyDown(search, {key: 'Enter', isComposing: true});
441
+ expect(onChange).not.toHaveBeenCalled();
442
+ fireEvent.keyDown(search, {key: 'Enter', keyCode: 229});
443
+ expect(onChange).not.toHaveBeenCalled();
444
+
445
+ // A real, non-composing Enter still toggles the highlighted option.
446
+ fireEvent.keyDown(search, {key: 'Enter'});
447
+ expect(onChange).toHaveBeenCalledWith(['Banana']);
448
+ });
449
+
418
450
  it('toggles the correct item when selected items are sorted to top', async () => {
419
451
  const user = userEvent.setup();
420
452
  const onChange = vi.fn();
@@ -798,56 +830,112 @@ describe('MultiSelector', () => {
798
830
  it('announces the match count politely while searching', async () => {
799
831
  const user = userEvent.setup();
800
832
  render(
801
- <MultiSelector
802
- label="Fruit"
803
- options={defaultOptions}
804
- value={EMPTY_VALUE}
805
- onChange={() => {}}
806
- hasSearch
807
- />,
833
+ <InternationalizationProvider
834
+ locale="fr"
835
+ overrides={{
836
+ fr: {
837
+ '@astryx.multiSelector.resultCount':
838
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
839
+ },
840
+ }}>
841
+ <MultiSelector
842
+ label="Fruit"
843
+ options={defaultOptions}
844
+ value={EMPTY_VALUE}
845
+ onChange={() => {}}
846
+ hasSearch
847
+ />
848
+ </InternationalizationProvider>,
808
849
  );
809
850
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
810
851
  // "an" matches Banana and Orange.
811
852
  await user.type(screen.getByRole('combobox', h), 'an');
812
853
  await waitFor(() => {
813
- expect(politeRegion()).toHaveTextContent('2 results');
854
+ // The plural branch of a message no catalog supplies.
855
+ expect(politeRegion()?.textContent).toBe('2 résultats');
814
856
  });
815
857
  });
816
858
 
817
859
  it('announces the singular form when one option matches', async () => {
818
860
  const user = userEvent.setup();
819
861
  render(
820
- <MultiSelector
821
- label="Fruit"
822
- options={defaultOptions}
823
- value={EMPTY_VALUE}
824
- onChange={() => {}}
825
- hasSearch
826
- />,
862
+ <InternationalizationProvider
863
+ locale="fr"
864
+ overrides={{
865
+ fr: {
866
+ '@astryx.multiSelector.resultCount':
867
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
868
+ },
869
+ }}>
870
+ <MultiSelector
871
+ label="Fruit"
872
+ options={defaultOptions}
873
+ value={EMPTY_VALUE}
874
+ onChange={() => {}}
875
+ hasSearch
876
+ />
877
+ </InternationalizationProvider>,
827
878
  );
828
879
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
829
- // "app" matches only Apple. Anchored so it cannot pass on "1 results".
880
+ // "app" matches only Apple. Exact match, so "1 résultats" would fail.
830
881
  await user.type(screen.getByRole('combobox', h), 'app');
831
882
  await waitFor(() => {
832
- expect(politeRegion()).toHaveTextContent(/^1 result$/);
883
+ expect(politeRegion()?.textContent).toBe('1 résultat');
833
884
  });
834
885
  });
835
886
 
836
- it('announces "No results found" when nothing matches', async () => {
887
+ it('announces the empty-results message when nothing matches', async () => {
837
888
  const user = userEvent.setup();
838
889
  render(
839
- <MultiSelector
840
- label="Fruit"
841
- options={defaultOptions}
842
- value={EMPTY_VALUE}
843
- onChange={() => {}}
844
- hasSearch
845
- />,
890
+ <InternationalizationProvider
891
+ locale="fr"
892
+ overrides={{
893
+ fr: {'@astryx.multiSelector.emptySearchResults': 'Aucun résultat'},
894
+ }}>
895
+ <MultiSelector
896
+ label="Fruit"
897
+ options={defaultOptions}
898
+ value={EMPTY_VALUE}
899
+ onChange={() => {}}
900
+ hasSearch
901
+ />
902
+ </InternationalizationProvider>,
846
903
  );
847
904
  await user.click(screen.getByRole('button', {name: 'Fruit'}));
848
905
  await user.type(screen.getByRole('combobox', h), 'xyz');
849
906
  await waitFor(() => {
850
- expect(politeRegion()).toHaveTextContent('No results found');
907
+ expect(politeRegion()?.textContent).toBe('Aucun résultat');
908
+ });
909
+ });
910
+
911
+ it('speaks the result count from a provider catalog (plural)', async () => {
912
+ const user = userEvent.setup();
913
+ render(
914
+ <InternationalizationProvider
915
+ locale="fr"
916
+ messages={{
917
+ fr: {
918
+ '@astryx.multiSelector.resultCount': {
919
+ defaultMessage:
920
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
921
+ },
922
+ },
923
+ }}>
924
+ <MultiSelector
925
+ label="Fruit"
926
+ options={defaultOptions}
927
+ value={EMPTY_VALUE}
928
+ onChange={() => {}}
929
+ hasSearch
930
+ />
931
+ </InternationalizationProvider>,
932
+ );
933
+ await user.click(screen.getByRole('button', {name: 'Fruit'}));
934
+ // "an" matches Banana and Orange.
935
+ await user.type(screen.getByRole('combobox', h), 'an');
936
+ await waitFor(() => {
937
+ // Same key through the catalog path rather than `overrides`.
938
+ expect(politeRegion()?.textContent).toBe('2 résultats');
851
939
  });
852
940
  });
853
941
 
@@ -1159,53 +1247,109 @@ describe('MultiSelector', () => {
1159
1247
  it('announces the selection count politely when toggling an option', async () => {
1160
1248
  const user = userEvent.setup();
1161
1249
  render(
1162
- <MultiSelector
1163
- label="Fruit"
1164
- options={[...ANNOUNCE_OPTIONS]}
1165
- value={EMPTY_VALUE}
1166
- onChange={() => {}}
1167
- />,
1250
+ <InternationalizationProvider
1251
+ locale="fr"
1252
+ overrides={{
1253
+ fr: {
1254
+ '@astryx.multiSelector.selectionCount':
1255
+ '{count, number} sur {total, number} sélectionnés',
1256
+ },
1257
+ }}>
1258
+ <MultiSelector
1259
+ label="Fruit"
1260
+ options={[...ANNOUNCE_OPTIONS]}
1261
+ value={EMPTY_VALUE}
1262
+ onChange={() => {}}
1263
+ />
1264
+ </InternationalizationProvider>,
1168
1265
  );
1169
1266
  await user.click(screen.getByRole('combobox'));
1170
1267
  const options = screen.getAllByRole('option', {hidden: true});
1171
1268
  await user.click(options[0]);
1172
1269
  await waitFor(() => {
1173
- expect(politeRegion()).toHaveTextContent('1 of 3 selected');
1270
+ // Both arguments land, in the order the message asks for them.
1271
+ expect(politeRegion()?.textContent).toBe('1 sur 3 sélectionnés');
1174
1272
  });
1175
1273
  });
1176
1274
 
1177
- it('announces "All selected" when select-all selects everything', async () => {
1275
+ it('announces the all-selected message when select-all selects everything', async () => {
1178
1276
  const user = userEvent.setup();
1179
1277
  render(
1180
- <MultiSelector
1181
- label="Fruit"
1182
- options={[...ANNOUNCE_OPTIONS]}
1183
- value={EMPTY_VALUE}
1184
- onChange={() => {}}
1185
- hasSelectAll
1186
- />,
1278
+ <InternationalizationProvider
1279
+ locale="fr"
1280
+ overrides={{
1281
+ fr: {'@astryx.multiSelector.allSelected': 'Tout est là'},
1282
+ }}>
1283
+ <MultiSelector
1284
+ label="Fruit"
1285
+ options={[...ANNOUNCE_OPTIONS]}
1286
+ value={EMPTY_VALUE}
1287
+ onChange={() => {}}
1288
+ hasSelectAll
1289
+ />
1290
+ </InternationalizationProvider>,
1187
1291
  );
1188
1292
  await user.click(screen.getByRole('combobox'));
1189
1293
  await user.click(screen.getByText('Select all'));
1190
1294
  await waitFor(() => {
1191
- expect(politeRegion()).toHaveTextContent('All selected');
1295
+ expect(politeRegion()?.textContent).toBe('Tout est là');
1192
1296
  });
1193
1297
  });
1194
1298
 
1195
- it('announces "Selection cleared" when clearing', async () => {
1299
+ it('announces the selection-cleared message when clearing', async () => {
1196
1300
  const user = userEvent.setup();
1197
1301
  render(
1198
- <MultiSelector
1199
- label="Fruit"
1200
- options={[...ANNOUNCE_OPTIONS]}
1201
- value={['Apple', 'Banana']}
1202
- onChange={() => {}}
1203
- hasClear
1204
- />,
1302
+ <InternationalizationProvider
1303
+ locale="fr"
1304
+ overrides={{
1305
+ fr: {'@astryx.multiSelector.selectionCleared': 'Plus rien'},
1306
+ }}>
1307
+ <MultiSelector
1308
+ label="Fruit"
1309
+ options={[...ANNOUNCE_OPTIONS]}
1310
+ value={['Apple', 'Banana']}
1311
+ onChange={() => {}}
1312
+ hasClear
1313
+ />
1314
+ </InternationalizationProvider>,
1205
1315
  );
1206
1316
  await user.click(screen.getByRole('button', {name: 'Clear all Fruit'}));
1207
1317
  await waitFor(() => {
1208
- expect(politeRegion()).toHaveTextContent('Selection cleared');
1318
+ expect(politeRegion()?.textContent).toBe('Plus rien');
1319
+ });
1320
+ });
1321
+
1322
+ it('prefers a provider override over the provider catalog', async () => {
1323
+ const user = userEvent.setup();
1324
+ render(
1325
+ <InternationalizationProvider
1326
+ locale="fr"
1327
+ messages={{
1328
+ fr: {
1329
+ '@astryx.multiSelector.selectionCount': {
1330
+ defaultMessage: '{count, number} du catalogue',
1331
+ },
1332
+ },
1333
+ }}
1334
+ overrides={{
1335
+ fr: {
1336
+ '@astryx.multiSelector.selectionCount':
1337
+ '{count, number} remplacé',
1338
+ },
1339
+ }}>
1340
+ <MultiSelector
1341
+ label="Fruit"
1342
+ options={[...ANNOUNCE_OPTIONS]}
1343
+ value={EMPTY_VALUE}
1344
+ onChange={() => {}}
1345
+ />
1346
+ </InternationalizationProvider>,
1347
+ );
1348
+ await user.click(screen.getByRole('combobox'));
1349
+ const options = screen.getAllByRole('option', {hidden: true});
1350
+ await user.click(options[0]);
1351
+ await waitFor(() => {
1352
+ expect(politeRegion()?.textContent).toBe('1 remplacé');
1209
1353
  });
1210
1354
  });
1211
1355
  });
@@ -71,7 +71,7 @@ import {
71
71
  getSelectableOptions,
72
72
  } from '../Selector/utils';
73
73
  import {useMultiCombobox} from './hooks';
74
- import {getInputARIA, mergeProps} from '../utils';
74
+ import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
75
75
  import {useAnnounce} from '../hooks/useAnnounce';
76
76
  import type {BaseProps} from '../BaseProps';
77
77
  import type {SizeValue} from '../utils/types';
@@ -792,14 +792,19 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
792
792
  const selectableSet = new Set(selectableItems.map(item => item.value));
793
793
  const selectedCount = nextValue.filter(v => selectableSet.has(v)).length;
794
794
  if (selectedCount === 0) {
795
- announce('Selection cleared');
795
+ announce(t('@astryx.multiSelector.selectionCleared'));
796
796
  } else if (total > 0 && selectedCount === total) {
797
- announce('All selected');
797
+ announce(t('@astryx.multiSelector.allSelected'));
798
798
  } else {
799
- announce(`${selectedCount} of ${total} selected`);
799
+ announce(
800
+ t('@astryx.multiSelector.selectionCount', {
801
+ count: selectedCount,
802
+ total,
803
+ }),
804
+ );
800
805
  }
801
806
  },
802
- [announce, selectableItems],
807
+ [announce, selectableItems, t],
803
808
  );
804
809
 
805
810
  // Filter items by search query
@@ -903,11 +908,11 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
903
908
  const count = filterOptionsByQuery(selectableItems, nextQuery).length;
904
909
  announce(
905
910
  count === 0
906
- ? 'No results found'
907
- : `${count} result${count === 1 ? '' : 's'}`,
911
+ ? t('@astryx.multiSelector.emptySearchResults')
912
+ : t('@astryx.multiSelector.resultCount', {count}),
908
913
  );
909
914
  },
910
- [announce, selectableItems],
915
+ [announce, selectableItems, t],
911
916
  );
912
917
 
913
918
  // Handle toggle
@@ -1182,6 +1187,15 @@ export function MultiSelector<T extends MultiSelectorOptionType>({
1182
1187
  }
1183
1188
  }}
1184
1189
  onKeyDown={e => {
1190
+ // An in-progress IME composition uses these same keys (Enter to
1191
+ // commit the candidate, Escape/Arrows to navigate the candidate
1192
+ // window); the composing keydown fires before compositionend, so
1193
+ // without this guard a Korean/Japanese/Chinese user committing a
1194
+ // syllable with Enter would instead toggle the highlighted option.
1195
+ // See utils/ime.ts.
1196
+ if (isImeKeyEvent(e.nativeEvent)) {
1197
+ return;
1198
+ }
1185
1199
  // Arrow keys navigate options; Enter toggles; Escape closes.
1186
1200
  // Space and Home/End are left to the input (type a space / move
1187
1201
  // the caret) per the APG editable combobox; PageUp/PageDown are
@@ -1340,6 +1340,24 @@ describe('NumberInput stepping', () => {
1340
1340
  expect(onChange).toHaveBeenLastCalledWith(4);
1341
1341
  });
1342
1342
 
1343
+ it('does not step or commit on a composing keydown (IME)', () => {
1344
+ const onChange = vi.fn();
1345
+ render(<NumberInput label="Amount" value={5} onChange={onChange} />);
1346
+ const input = screen.getByRole('spinbutton');
1347
+
1348
+ // The field is type="text", so an IME composes into it. Enter commits the
1349
+ // candidate and the arrows walk the candidate window; neither should reach
1350
+ // the stepping or commit paths. Both signals a browser may report.
1351
+ fireEvent.keyDown(input, {key: 'ArrowUp', isComposing: true});
1352
+ fireEvent.keyDown(input, {key: 'ArrowDown', keyCode: 229});
1353
+ fireEvent.keyDown(input, {key: 'Enter', isComposing: true});
1354
+ expect(onChange).not.toHaveBeenCalled();
1355
+
1356
+ // A real, non-composing ArrowUp still steps.
1357
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
1358
+ expect(onChange).toHaveBeenLastCalledWith(6);
1359
+ });
1360
+
1343
1361
  it('lets onKeyDown cancel keyboard stepping', () => {
1344
1362
  const onChange = vi.fn();
1345
1363
  const onKeyDown = vi.fn((event: React.KeyboardEvent<HTMLInputElement>) =>
@@ -169,7 +169,7 @@ export type {
169
169
  InputStatus as NumberInputStatus,
170
170
  InputStatusType as NumberInputStatusType,
171
171
  } from '../Field';
172
- import {mergeProps, mergeRefs} from '../utils';
172
+ import {isImeKeyEvent, mergeProps, mergeRefs} from '../utils';
173
173
  import type {BaseProps} from '../BaseProps';
174
174
  import type {SizeValue} from '../utils/types';
175
175
  import {themeProps} from '../utils/themeProps';
@@ -746,6 +746,14 @@ export function NumberInput({
746
746
  // Handle keyboard events
747
747
  const handleKeyDown = useCallback(
748
748
  (e: KeyboardEvent<HTMLInputElement>) => {
749
+ // The field is type="text" for formatted display, so an IME can compose
750
+ // into it: Enter commits the candidate and the arrows walk the candidate
751
+ // window. The composing keydown fires before compositionend, so without
752
+ // this guard those keystrokes would commit or step the value instead.
753
+ // See utils/ime.ts.
754
+ if (isImeKeyEvent(e.nativeEvent)) {
755
+ return;
756
+ }
749
757
  const hasModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
750
758
  if (!hasModifier && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
751
759
  onKeyDown?.(e);
@@ -20,7 +20,7 @@ import {HStack, VStack} from '../Stack';
20
20
  import {Icon} from '../Icon';
21
21
  import {TreeList, type TreeListItemData} from '../TreeList';
22
22
  import {useTranslator} from '../i18n';
23
- import {isImeKeyEvent} from '../hooks/useFocusTrap';
23
+ import {isImeKeyEvent} from '../utils/ime';
24
24
  import {spacingVars, typeScaleVars} from '../theme/tokens.stylex';
25
25
  import {PowerSearchValueEditor} from './PowerSearchValueEditor';
26
26
  import {resolveOperatorLabel} from './resolveOperatorLabel';
@@ -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
 
@@ -72,7 +72,7 @@ import {
72
72
  import {useCombobox, useSelectedItemOffset} from './hooks';
73
73
  import {useTypeahead} from '../hooks/useTypeahead';
74
74
  import {SelectorOption} from './SelectorOption';
75
- import {getInputARIA, mergeProps} from '../utils';
75
+ import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
76
76
  import {useSize} from '../SizeContext/SizeContext';
77
77
  import type {BaseProps} from '../BaseProps';
78
78
  import type {SizeValue} from '../utils/types';
@@ -844,11 +844,11 @@ export function Selector<T extends SelectorOptionType>(
844
844
  const count = filterOptionsByQuery(selectableItems, nextQuery).length;
845
845
  announce(
846
846
  count === 0
847
- ? 'No results found'
848
- : `${count} result${count === 1 ? '' : 's'}`,
847
+ ? t('@astryx.selector.emptySearchResults')
848
+ : t('@astryx.selector.resultCount', {count}),
849
849
  );
850
850
  },
851
- [announce, selectableItems],
851
+ [announce, selectableItems, t],
852
852
  );
853
853
 
854
854
  // Calculate offset to position selected item over trigger. Explicit
@@ -1055,6 +1055,15 @@ export function Selector<T extends SelectorOptionType>(
1055
1055
  }
1056
1056
  }}
1057
1057
  onKeyDown={e => {
1058
+ // An in-progress IME composition uses these same keys (Enter to
1059
+ // commit the candidate, Escape/Arrows to navigate the candidate
1060
+ // window); the composing keydown fires before compositionend, so
1061
+ // without this guard a Korean/Japanese/Chinese user committing a
1062
+ // syllable with Enter would instead select the highlighted option.
1063
+ // See utils/ime.ts.
1064
+ if (isImeKeyEvent(e.nativeEvent)) {
1065
+ return;
1066
+ }
1058
1067
  // Arrow keys navigate options; Enter selects; Escape closes.
1059
1068
  // Home/End are left to the input for caret movement (APG editable
1060
1069
  // combobox); PageUp/PageDown are the sanctioned substitute for