@astryxdesign/core 0.4.3 → 0.4.4-canary.2022d94

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 (156) 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/Lightbox/Lightbox.d.ts.map +1 -1
  43. package/dist/Lightbox/Lightbox.js +9 -3
  44. package/dist/MobileNav/MobileNav.d.ts +12 -0
  45. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  46. package/dist/MobileNav/MobileNav.js +102 -30
  47. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  48. package/dist/MultiSelector/MultiSelector.js +21 -7
  49. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  50. package/dist/NumberInput/NumberInput.js +9 -1
  51. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  52. package/dist/Selector/Selector.d.ts.map +1 -1
  53. package/dist/Selector/Selector.js +14 -3
  54. package/dist/Switch/Switch.d.ts.map +1 -1
  55. package/dist/Switch/Switch.js +5 -1
  56. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  57. package/dist/TimeInput/TimeInput.js +8 -1
  58. package/dist/Tokenizer/Tokenizer.js +11 -5
  59. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  60. package/dist/Tooltip/useTooltip.js +4 -1
  61. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  62. package/dist/Typeahead/BaseTypeahead.js +5 -3
  63. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  64. package/dist/Typeahead/Typeahead.js +10 -1
  65. package/dist/astryx.css +21 -0
  66. package/dist/hooks/index.d.ts +8 -1
  67. package/dist/hooks/index.d.ts.map +1 -1
  68. package/dist/hooks/index.js +8 -1
  69. package/dist/hooks/useFocusTrap.d.ts +0 -11
  70. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  71. package/dist/hooks/useFocusTrap.js +1 -11
  72. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  73. package/dist/hooks/useInputStatusIcon.js +1 -0
  74. package/dist/index.d.ts +1 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +1 -0
  77. package/dist/theme/defineTheme.d.ts +22 -4
  78. package/dist/theme/defineTheme.d.ts.map +1 -1
  79. package/dist/theme/expandColorScale.d.ts +17 -2
  80. package/dist/theme/expandColorScale.d.ts.map +1 -1
  81. package/dist/theme/expandColorScale.js +63 -33
  82. package/dist/utils/ime.d.ts +5 -0
  83. package/dist/utils/ime.d.ts.map +1 -0
  84. package/dist/utils/ime.js +63 -0
  85. package/dist/utils/index.d.ts +1 -0
  86. package/dist/utils/index.d.ts.map +1 -1
  87. package/dist/utils/index.js +2 -1
  88. package/locales/en.json +48 -0
  89. package/locales/pseudo.json +36 -0
  90. package/package.json +9 -8
  91. package/src/Banner/Banner.doc.mjs +3 -3
  92. package/src/Banner/Banner.test.tsx +40 -0
  93. package/src/Banner/Banner.tsx +27 -2
  94. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  95. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  96. package/src/BottomSheet/BottomSheet.tsx +545 -0
  97. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  98. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  99. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  100. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  101. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  102. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  103. package/src/BottomSheet/index.ts +15 -0
  104. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  105. package/src/BottomSheet/snapOffsets.ts +159 -0
  106. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  107. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  108. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  109. package/src/Chat/ChatComposerInput.tsx +3 -4
  110. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  111. package/src/ContextMenu/ContextMenu.tsx +4 -2
  112. package/src/DateInput/DateInput.test.tsx +21 -0
  113. package/src/DateInput/DateInput.tsx +9 -1
  114. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  115. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  116. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  117. package/src/Dialog/Dialog.tsx +2 -1
  118. package/src/FileInput/FileInput.test.tsx +53 -9
  119. package/src/Lightbox/Lightbox.test.tsx +49 -28
  120. package/src/Lightbox/Lightbox.tsx +7 -3
  121. package/src/MobileNav/MobileNav.tsx +128 -37
  122. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  123. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  124. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  125. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  126. package/src/MultiSelector/MultiSelector.tsx +22 -8
  127. package/src/NumberInput/NumberInput.test.tsx +18 -0
  128. package/src/NumberInput/NumberInput.tsx +9 -1
  129. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  130. package/src/Selector/Selector.test.tsx +85 -26
  131. package/src/Selector/Selector.tsx +13 -4
  132. package/src/Switch/Switch.test.tsx +16 -0
  133. package/src/Switch/Switch.tsx +7 -0
  134. package/src/TimeInput/TimeInput.test.tsx +23 -0
  135. package/src/TimeInput/TimeInput.tsx +8 -0
  136. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  137. package/src/Tokenizer/Tokenizer.tsx +5 -5
  138. package/src/Tooltip/useTooltip.tsx +4 -1
  139. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  140. package/src/Typeahead/Typeahead.test.tsx +116 -7
  141. package/src/Typeahead/Typeahead.tsx +10 -1
  142. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  143. package/src/__tests__/stubMatchMedia.ts +95 -0
  144. package/src/hooks/index.ts +9 -5
  145. package/src/hooks/useFocusTrap.ts +1 -14
  146. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  147. package/src/hooks/useInputStatusIcon.tsx +7 -0
  148. package/src/index.ts +1 -0
  149. package/src/theme/defineTheme.ts +22 -4
  150. package/src/theme/expandColorScale.test.ts +138 -0
  151. package/src/theme/expandColorScale.ts +89 -44
  152. package/src/utils/ime.test.ts +32 -0
  153. package/src/utils/ime.ts +69 -0
  154. package/src/utils/index.ts +2 -0
  155. package/dist/astryx.umd.js +0 -147
  156. package/dist/astryx.umd.js.map +0 -7
@@ -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
@@ -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
@@ -135,6 +135,12 @@ const styles = stylex.create({
135
135
  alignItems: 'center',
136
136
  gap: spacingVars['--spacing-2'],
137
137
  },
138
+ // A hidden label is sr-only, so its wrapper is a zero-width flex item — the
139
+ // row gap would still be painted beside the track, making the field box
140
+ // wider than the control it contains. Matches CheckboxInput.
141
+ containerLabelHidden: {
142
+ gap: 0,
143
+ },
138
144
  containerSpread: {
139
145
  justifyContent: 'space-between',
140
146
  width: '100%',
@@ -655,6 +661,7 @@ export function Switch({
655
661
  }}
656
662
  {...stylex.props(
657
663
  styles.container,
664
+ isLabelHidden && styles.containerLabelHidden,
658
665
  labelSpacing === 'spread' && styles.containerSpread,
659
666
  !isDisabled && switchScope,
660
667
  )}>
@@ -42,6 +42,29 @@ describe('TimeInput', () => {
42
42
  expect(screen.getByPlaceholderText('Pick a time')).toBeInTheDocument();
43
43
  });
44
44
 
45
+ it('does not step the time on a composing ArrowUp/ArrowDown (IME)', () => {
46
+ const onChange = vi.fn();
47
+ render(
48
+ <TimeInput
49
+ label="Time"
50
+ value={'14:30' as ISOTimeString}
51
+ onChange={onChange}
52
+ />,
53
+ );
54
+ const input = screen.getByLabelText('Time');
55
+
56
+ // An IME candidate window navigates with the arrows; a composing keydown
57
+ // (isComposing / legacy keyCode 229) must not step the time value.
58
+ fireEvent.keyDown(input, {key: 'ArrowUp', isComposing: true});
59
+ expect(onChange).not.toHaveBeenCalled();
60
+ fireEvent.keyDown(input, {key: 'ArrowDown', keyCode: 229});
61
+ expect(onChange).not.toHaveBeenCalled();
62
+
63
+ // A real, non-composing ArrowUp still steps the time by one minute.
64
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
65
+ expect(onChange).toHaveBeenCalledWith('14:31');
66
+ });
67
+
45
68
  it('displays formatted time in 12h format', () => {
46
69
  render(
47
70
  <TimeInput
@@ -55,6 +55,7 @@ import {
55
55
  formatDisplayTime24h,
56
56
  formatISOTime,
57
57
  adjustTime,
58
+ isImeKeyEvent,
58
59
  isTimeInRange,
59
60
  mergeProps,
60
61
  mergeRefs,
@@ -530,6 +531,13 @@ export function TimeInput({
530
531
  // Handle keyboard navigation on input
531
532
  const handleInputKeyDown = useCallback(
532
533
  (e: KeyboardEvent<HTMLInputElement>) => {
534
+ // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
535
+ // window uses those same arrows to navigate candidates, so guard the
536
+ // composing keydown (fires before compositionend) to avoid stealing them
537
+ // mid-composition. See utils/ime.ts.
538
+ if (isImeKeyEvent(e.nativeEvent)) {
539
+ return;
540
+ }
533
541
  // Arrow-key adjustment mutates the value; block it while showing a
534
542
  // disabled reason (the input keeps focusability via aria-disabled).
535
543
  if (isDisabled) {
@@ -16,6 +16,16 @@ import {Tokenizer} from './Tokenizer';
16
16
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
17
17
  import type {SearchSource, SearchableItem} from '../Typeahead/types';
18
18
  import {TestIcon} from '../__tests__/TestIcon';
19
+ import {InternationalizationProvider} from '../i18n';
20
+
21
+ // Test-supplied announcement strings: the assertions below depend on no
22
+ // catalog, and no hardcoded English in the component can satisfy them.
23
+ const TOKEN_MESSAGES = {
24
+ fr: {
25
+ '@astryx.tokenizer.tokenAdded': 'Ajouté : {label}',
26
+ '@astryx.tokenizer.tokenRemoved': 'Retiré : {label}',
27
+ },
28
+ };
19
29
 
20
30
  function politeRegion(): HTMLElement | null {
21
31
  return document.querySelector('[data-astryx-live-region="polite"]');
@@ -980,12 +990,14 @@ describe('Tokenizer', () => {
980
990
  it('announces removal politely on Backspace with an empty input', async () => {
981
991
  const onChange = vi.fn();
982
992
  render(
983
- <Tokenizer
984
- label="Members"
985
- searchSource={userSource}
986
- value={[users[0], users[1]]}
987
- onChange={onChange}
988
- />,
993
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
994
+ <Tokenizer
995
+ label="Members"
996
+ searchSource={userSource}
997
+ value={[users[0], users[1]]}
998
+ onChange={onChange}
999
+ />
1000
+ </InternationalizationProvider>,
989
1001
  );
990
1002
  const input = screen.getByRole('combobox');
991
1003
  fireEvent.keyDown(input, {key: 'Backspace'});
@@ -994,35 +1006,39 @@ describe('Tokenizer', () => {
994
1006
  type: 'remove',
995
1007
  });
996
1008
  await waitFor(() => {
997
- expect(politeRegion()).toHaveTextContent('Removed Bob');
1009
+ expect(politeRegion()?.textContent).toBe('Retiré : Bob');
998
1010
  });
999
1011
  });
1000
1012
 
1001
1013
  it("announces removal politely when clicking a token's remove button", async () => {
1002
1014
  render(
1003
- <Tokenizer
1004
- label="Members"
1005
- searchSource={userSource}
1006
- value={[users[0], users[1]]}
1007
- onChange={() => {}}
1008
- />,
1015
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
1016
+ <Tokenizer
1017
+ label="Members"
1018
+ searchSource={userSource}
1019
+ value={[users[0], users[1]]}
1020
+ onChange={() => {}}
1021
+ />
1022
+ </InternationalizationProvider>,
1009
1023
  );
1010
1024
  fireEvent.click(screen.getByRole('button', {name: 'Remove Alice'}));
1011
1025
  await waitFor(() => {
1012
- expect(politeRegion()).toHaveTextContent('Removed Alice');
1026
+ expect(politeRegion()?.textContent).toBe('Retiré : Alice');
1013
1027
  });
1014
1028
  });
1015
1029
 
1016
1030
  it('announces addition politely when selecting a search result', async () => {
1017
1031
  render(
1018
- <Tokenizer
1019
- label="Members"
1020
- searchSource={userSource}
1021
- value={[]}
1022
- onChange={() => {}}
1023
- hasEntriesOnFocus
1024
- debounceMs={0}
1025
- />,
1032
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
1033
+ <Tokenizer
1034
+ label="Members"
1035
+ searchSource={userSource}
1036
+ value={[]}
1037
+ onChange={() => {}}
1038
+ hasEntriesOnFocus
1039
+ debounceMs={0}
1040
+ />
1041
+ </InternationalizationProvider>,
1026
1042
  );
1027
1043
  const input = screen.getByRole('combobox');
1028
1044
  fireEvent.focus(input);
@@ -1031,7 +1047,7 @@ describe('Tokenizer', () => {
1031
1047
  });
1032
1048
  fireEvent.click(screen.getByText('Alice'));
1033
1049
  await waitFor(() => {
1034
- expect(politeRegion()).toHaveTextContent('Added Alice');
1050
+ expect(politeRegion()?.textContent).toBe('Ajouté : Alice');
1035
1051
  });
1036
1052
  });
1037
1053
 
@@ -1041,14 +1057,16 @@ describe('Tokenizer', () => {
1041
1057
  bootstrap: () => [],
1042
1058
  };
1043
1059
  render(
1044
- <Tokenizer
1045
- label="Tags"
1046
- searchSource={emptySource}
1047
- value={[]}
1048
- onChange={() => {}}
1049
- hasCreate
1050
- debounceMs={0}
1051
- />,
1060
+ <InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
1061
+ <Tokenizer
1062
+ label="Tags"
1063
+ searchSource={emptySource}
1064
+ value={[]}
1065
+ onChange={() => {}}
1066
+ hasCreate
1067
+ debounceMs={0}
1068
+ />
1069
+ </InternationalizationProvider>,
1052
1070
  );
1053
1071
  const input = screen.getByRole('combobox');
1054
1072
  await act(async () => {
@@ -1059,7 +1077,7 @@ describe('Tokenizer', () => {
1059
1077
  });
1060
1078
  fireEvent.click(screen.getByText('Create "new-tag"'));
1061
1079
  await waitFor(() => {
1062
- expect(politeRegion()).toHaveTextContent('Added new-tag');
1080
+ expect(politeRegion()?.textContent).toBe('Ajouté : new-tag');
1063
1081
  });
1064
1082
  });
1065
1083
 
@@ -600,7 +600,7 @@ export function Tokenizer<T extends SearchableItem>({
600
600
  const realItem = base as T;
601
601
  const newItems = [...value, realItem];
602
602
  onChange(newItems, {item: realItem, type: 'create'});
603
- announce(`Added ${createdValue}`);
603
+ announce(t('@astryx.tokenizer.tokenAdded', {label: createdValue}));
604
604
  return;
605
605
  }
606
606
 
@@ -609,9 +609,9 @@ export function Tokenizer<T extends SearchableItem>({
609
609
  }
610
610
  const newItems = [...value, item];
611
611
  onChange(newItems, {item, type: 'add'});
612
- announce(`Added ${item.label}`);
612
+ announce(t('@astryx.tokenizer.tokenAdded', {label: item.label}));
613
613
  },
614
- [value, onChange, isAtMax, selectedIds, hasCreate, announce],
614
+ [value, onChange, isAtMax, selectedIds, hasCreate, announce, t],
615
615
  );
616
616
 
617
617
  // Handle removing an item. Single removal path: both Backspace on an empty
@@ -621,10 +621,10 @@ export function Tokenizer<T extends SearchableItem>({
621
621
  (item: T) => {
622
622
  const newItems = value.filter(v => v.id !== item.id);
623
623
  onChange(newItems, {item, type: 'remove'});
624
- announce(`Removed ${item.label}`);
624
+ announce(t('@astryx.tokenizer.tokenRemoved', {label: item.label}));
625
625
  inputRef.current?.focus();
626
626
  },
627
- [value, onChange, announce],
627
+ [value, onChange, announce, t],
628
628
  );
629
629
 
630
630
  // Handle clearing all items
@@ -28,6 +28,7 @@ import {
28
28
  } from '../Layer/useLayer';
29
29
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
30
30
  import {themeProps} from '../utils/themeProps';
31
+ import {isImeKeyEvent} from '../utils/ime';
31
32
  import {
32
33
  colorVars,
33
34
  radiusVars,
@@ -454,7 +455,9 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
454
455
  if (e.key !== 'Escape') {
455
456
  return;
456
457
  }
457
- if (e.isComposing || e.keyCode === 229) {
458
+ if (isImeKeyEvent(e)) {
459
+ // Ignore Escape that is committing/cancelling an IME composition;
460
+ // see utils/ime.ts for why.
458
461
  return;
459
462
  }
460
463
  clearTimeouts();
@@ -30,7 +30,7 @@ import * as stylex from '@stylexjs/stylex';
30
30
  import type {StyleXStyles} from '@stylexjs/stylex';
31
31
  import {usePopover} from '../Popover/usePopover';
32
32
  import {useAnnounce} from '../hooks/useAnnounce';
33
- import {isImeKeyEvent} from '../hooks/useFocusTrap';
33
+ import {isImeKeyEvent} from '../utils/ime';
34
34
  import {TypeaheadItem} from './TypeaheadItem';
35
35
  import {Icon} from '../Icon';
36
36
  import {
@@ -449,7 +449,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
449
449
  announce(
450
450
  shown.length === 0
451
451
  ? emptySearchResultsText
452
- : `${shown.length} ${shown.length === 1 ? 'result' : 'results'}`,
452
+ : t('@astryx.typeahead.resultCount', {count: shown.length}),
453
453
  );
454
454
  }
455
455
  } catch {
@@ -464,7 +464,14 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
464
464
  }
465
465
  }
466
466
  },
467
- [searchSource, maxMenuItems, showLayer, announce, emptySearchResultsText],
467
+ [
468
+ searchSource,
469
+ maxMenuItems,
470
+ showLayer,
471
+ announce,
472
+ emptySearchResultsText,
473
+ t,
474
+ ],
468
475
  );
469
476
 
470
477
  // Perform bootstrap
@@ -23,6 +23,7 @@ import userEvent from '@testing-library/user-event';
23
23
  import {Typeahead} from './Typeahead';
24
24
  import {BaseTypeahead} from './BaseTypeahead';
25
25
  import type {SearchSource, SearchableItem} from './types';
26
+ import {InternationalizationProvider} from '../i18n';
26
27
 
27
28
  // Store original matches to restore later
28
29
  const originalMatches = HTMLElement.prototype.matches;
@@ -132,21 +133,94 @@ describe('BaseTypeahead', () => {
132
133
 
133
134
  it('announces the result count to a live region (comboboxes-6)', async () => {
134
135
  render(
135
- <BaseTypeahead
136
- searchSource={fruitSource}
137
- value={null}
138
- onChange={() => {}}
139
- debounceMs={0}
140
- />,
136
+ <InternationalizationProvider
137
+ locale="fr"
138
+ overrides={{
139
+ fr: {
140
+ '@astryx.typeahead.resultCount':
141
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
142
+ },
143
+ }}>
144
+ <BaseTypeahead
145
+ searchSource={fruitSource}
146
+ value={null}
147
+ onChange={() => {}}
148
+ debounceMs={0}
149
+ />
150
+ </InternationalizationProvider>,
141
151
  );
142
152
  const input = screen.getByRole('combobox');
153
+ // "Ap" matches Apple only — the singular ICU branch.
143
154
  fireEvent.change(input, {target: {value: 'Ap'}});
144
155
 
145
156
  await waitFor(() => {
146
157
  const region = document.querySelector(
147
158
  '[data-astryx-live-region="polite"]',
148
159
  );
149
- expect(region?.textContent).toMatch(/\d+ results?/);
160
+ // Exact, so the plural branch ("1 résultats") would fail.
161
+ expect(region?.textContent).toBe('1 résultat');
162
+ });
163
+ });
164
+
165
+ it('announces the plural result count', async () => {
166
+ render(
167
+ <InternationalizationProvider
168
+ locale="fr"
169
+ overrides={{
170
+ fr: {
171
+ '@astryx.typeahead.resultCount':
172
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
173
+ },
174
+ }}>
175
+ <BaseTypeahead
176
+ searchSource={fruitSource}
177
+ value={null}
178
+ onChange={() => {}}
179
+ debounceMs={0}
180
+ />
181
+ </InternationalizationProvider>,
182
+ );
183
+ const input = screen.getByRole('combobox');
184
+ // "err" matches Cherry and Elderberry.
185
+ fireEvent.change(input, {target: {value: 'err'}});
186
+
187
+ await waitFor(() => {
188
+ const region = document.querySelector(
189
+ '[data-astryx-live-region="polite"]',
190
+ );
191
+ expect(region?.textContent).toBe('2 résultats');
192
+ });
193
+ });
194
+
195
+ it('speaks the result count from a provider catalog', async () => {
196
+ render(
197
+ <InternationalizationProvider
198
+ locale="fr"
199
+ messages={{
200
+ fr: {
201
+ '@astryx.typeahead.resultCount': {
202
+ defaultMessage:
203
+ '{count, number} {count, plural, one {résultat} other {résultats}}',
204
+ },
205
+ },
206
+ }}>
207
+ <BaseTypeahead
208
+ searchSource={fruitSource}
209
+ value={null}
210
+ onChange={() => {}}
211
+ debounceMs={0}
212
+ />
213
+ </InternationalizationProvider>,
214
+ );
215
+ const input = screen.getByRole('combobox');
216
+ fireEvent.change(input, {target: {value: 'err'}});
217
+
218
+ await waitFor(() => {
219
+ const region = document.querySelector(
220
+ '[data-astryx-live-region="polite"]',
221
+ );
222
+ // Same key through the catalog path rather than `overrides`.
223
+ expect(region?.textContent).toBe('2 résultats');
150
224
  });
151
225
  });
152
226
 
@@ -796,6 +870,41 @@ describe('Typeahead edit mode', () => {
796
870
  // onChange should not have been called — value restored
797
871
  expect(onChange).not.toHaveBeenCalled();
798
872
  });
873
+
874
+ it('does not exit edit mode on a composing Escape (IME)', async () => {
875
+ const onChange = vi.fn();
876
+ render(
877
+ <Typeahead
878
+ label="Fruit"
879
+ searchSource={fruitSource}
880
+ value={fruits[0]}
881
+ onChange={onChange}
882
+ />,
883
+ );
884
+
885
+ // Enter edit mode by clicking the token; the input uncollapses and
886
+ // rejoins the Tab order (tabindex is cleared).
887
+ const tokenText = screen.getByText(fruits[0].label);
888
+ fireEvent.click(tokenText.closest('div')!);
889
+ await act(async () => {
890
+ await new Promise(r => requestAnimationFrame(r));
891
+ });
892
+ const input = screen.getByRole('combobox');
893
+ expect(input).not.toHaveAttribute('tabindex', '-1');
894
+
895
+ // BaseTypeahead invokes this external handler before its own IME guard, so
896
+ // a composing Escape (isComposing / legacy keyCode 229) — which an IME uses
897
+ // to cancel the pending candidate — must not exit edit mode here.
898
+ fireEvent.keyDown(input, {key: 'Escape', isComposing: true});
899
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex', '-1');
900
+ fireEvent.keyDown(input, {key: 'Escape', keyCode: 229});
901
+ expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex', '-1');
902
+
903
+ // A real, non-composing Escape still exits edit mode: the token is
904
+ // restored and the collapsed input drops back out of the Tab order.
905
+ fireEvent.keyDown(input, {key: 'Escape'});
906
+ expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
907
+ });
799
908
  });
800
909
 
801
910
  describe('Typeahead collapsed input tab order', () => {