@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29

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 (214) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +392 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +942 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  53. package/dist/Lightbox/Lightbox.js +9 -3
  54. package/dist/Markdown/parser.d.ts.map +1 -1
  55. package/dist/Markdown/parser.js +55 -12
  56. package/dist/MobileNav/MobileNav.d.ts +12 -0
  57. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  58. package/dist/MobileNav/MobileNav.js +102 -30
  59. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  60. package/dist/MultiSelector/MultiSelector.js +21 -7
  61. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  62. package/dist/NumberInput/NumberInput.js +9 -1
  63. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  64. package/dist/PowerSearch/PowerSearch.js +4 -1
  65. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  66. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  67. package/dist/PowerSearch/formatFilterValue.js +2 -4
  68. package/dist/Selector/Selector.d.ts.map +1 -1
  69. package/dist/Selector/Selector.js +14 -3
  70. package/dist/Switch/Switch.d.ts.map +1 -1
  71. package/dist/Switch/Switch.js +5 -1
  72. package/dist/Table/columnUtils.d.ts.map +1 -1
  73. package/dist/Table/columnUtils.js +4 -1
  74. package/dist/TextArea/TextArea.d.ts +6 -3
  75. package/dist/TextArea/TextArea.d.ts.map +1 -1
  76. package/dist/TextArea/TextArea.js +17 -6
  77. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  78. package/dist/TimeInput/TimeInput.js +8 -1
  79. package/dist/Tokenizer/Tokenizer.js +11 -5
  80. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  81. package/dist/Tooltip/useTooltip.js +4 -1
  82. package/dist/TreeList/TreeList.js +2 -1
  83. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  84. package/dist/Typeahead/BaseTypeahead.js +5 -3
  85. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  86. package/dist/Typeahead/Typeahead.js +10 -1
  87. package/dist/astryx.css +25 -2
  88. package/dist/hooks/index.d.ts +8 -1
  89. package/dist/hooks/index.d.ts.map +1 -1
  90. package/dist/hooks/index.js +8 -1
  91. package/dist/hooks/useFocusTrap.d.ts +0 -11
  92. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  93. package/dist/hooks/useFocusTrap.js +1 -11
  94. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  95. package/dist/hooks/useInputStatusIcon.js +1 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -0
  99. package/dist/theme/defineTheme.d.ts +30 -7
  100. package/dist/theme/defineTheme.d.ts.map +1 -1
  101. package/dist/theme/defineTheme.js +36 -47
  102. package/dist/theme/expandColorScale.d.ts +17 -2
  103. package/dist/theme/expandColorScale.d.ts.map +1 -1
  104. package/dist/theme/expandColorScale.js +63 -33
  105. package/dist/theme/mergeComponents.d.ts +20 -0
  106. package/dist/theme/mergeComponents.d.ts.map +1 -0
  107. package/dist/theme/mergeComponents.js +56 -0
  108. package/dist/theme/onMediaTokens.d.ts +6 -1
  109. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  110. package/dist/theme/onMediaTokens.js +11 -3
  111. package/dist/utils/characters.d.ts +27 -0
  112. package/dist/utils/characters.d.ts.map +1 -0
  113. package/dist/utils/characters.js +83 -0
  114. package/dist/utils/ime.d.ts +5 -0
  115. package/dist/utils/ime.d.ts.map +1 -0
  116. package/dist/utils/ime.js +63 -0
  117. package/dist/utils/index.d.ts +2 -0
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/locales/en.json +48 -0
  121. package/locales/pseudo.json +36 -0
  122. package/package.json +9 -8
  123. package/src/Avatar/Avatar.test.tsx +6 -1
  124. package/src/Avatar/Avatar.tsx +3 -21
  125. package/src/Banner/Banner.doc.mjs +12 -10
  126. package/src/Banner/Banner.test.tsx +108 -0
  127. package/src/Banner/Banner.tsx +124 -38
  128. package/src/Banner/index.ts +15 -13
  129. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  130. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  131. package/src/BottomSheet/BottomSheet.tsx +545 -0
  132. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  133. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  134. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  135. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  137. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  138. package/src/BottomSheet/index.ts +15 -0
  139. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  140. package/src/BottomSheet/snapOffsets.ts +159 -0
  141. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  142. package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
  143. package/src/BottomSheet/useSheetGestures.ts +1407 -0
  144. package/src/Chat/ChatComposerInput.tsx +3 -4
  145. package/src/Chat/ChatTokenizedText.tsx +1 -1
  146. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  147. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  148. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  149. package/src/ComplexSelector/index.ts +3 -1
  150. package/src/ContextMenu/ContextMenu.tsx +4 -2
  151. package/src/DateInput/DateInput.test.tsx +21 -0
  152. package/src/DateInput/DateInput.tsx +9 -1
  153. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  154. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  155. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  156. package/src/Dialog/Dialog.tsx +2 -1
  157. package/src/FileInput/FileInput.test.tsx +53 -9
  158. package/src/Lightbox/Lightbox.test.tsx +49 -28
  159. package/src/Lightbox/Lightbox.tsx +7 -3
  160. package/src/Markdown/parser.test.ts +53 -0
  161. package/src/Markdown/parser.ts +53 -12
  162. package/src/MobileNav/MobileNav.tsx +128 -37
  163. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  164. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  165. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  166. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  167. package/src/MultiSelector/MultiSelector.tsx +22 -8
  168. package/src/NumberInput/NumberInput.test.tsx +18 -0
  169. package/src/NumberInput/NumberInput.tsx +9 -1
  170. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  171. package/src/PowerSearch/PowerSearch.tsx +4 -1
  172. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  173. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  174. package/src/PowerSearch/formatFilterValue.ts +2 -4
  175. package/src/Selector/Selector.test.tsx +85 -26
  176. package/src/Selector/Selector.tsx +13 -4
  177. package/src/Switch/Switch.test.tsx +16 -0
  178. package/src/Switch/Switch.tsx +7 -0
  179. package/src/Table/Table.test.tsx +6 -0
  180. package/src/Table/columnUtils.ts +3 -1
  181. package/src/TextArea/TextArea.doc.mjs +1 -1
  182. package/src/TextArea/TextArea.test.tsx +72 -0
  183. package/src/TextArea/TextArea.tsx +26 -8
  184. package/src/TimeInput/TimeInput.test.tsx +23 -0
  185. package/src/TimeInput/TimeInput.tsx +8 -0
  186. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  187. package/src/Tokenizer/Tokenizer.tsx +5 -5
  188. package/src/Tooltip/useTooltip.tsx +4 -1
  189. package/src/TreeList/TreeList.doc.mjs +2 -2
  190. package/src/TreeList/TreeList.tsx +1 -1
  191. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  192. package/src/Typeahead/Typeahead.test.tsx +116 -7
  193. package/src/Typeahead/Typeahead.tsx +10 -1
  194. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  195. package/src/__tests__/stubMatchMedia.ts +95 -0
  196. package/src/hooks/index.ts +9 -5
  197. package/src/hooks/useFocusTrap.ts +1 -14
  198. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  199. package/src/hooks/useInputStatusIcon.tsx +7 -0
  200. package/src/index.ts +1 -0
  201. package/src/theme/defineTheme.test.ts +127 -0
  202. package/src/theme/defineTheme.ts +70 -55
  203. package/src/theme/expandColorScale.test.ts +138 -0
  204. package/src/theme/expandColorScale.ts +89 -44
  205. package/src/theme/extensibleAxes.test.ts +365 -0
  206. package/src/theme/mergeComponents.ts +59 -0
  207. package/src/theme/onMediaTokens.ts +9 -2
  208. package/src/utils/characters.test.ts +141 -0
  209. package/src/utils/characters.ts +88 -0
  210. package/src/utils/ime.test.ts +32 -0
  211. package/src/utils/ime.ts +69 -0
  212. package/src/utils/index.ts +4 -0
  213. package/dist/astryx.umd.js +0 -147
  214. package/dist/astryx.umd.js.map +0 -7
@@ -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();
@@ -24,7 +24,7 @@ export const docs = {
24
24
  },
25
25
  theming: {
26
26
  targets: [
27
- {className: 'astryx-tree-list', visualProps: ['density']},
27
+ {className: 'astryx-tree-list', visualProps: ['density', 'variant']},
28
28
  {className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
29
29
  {className: 'astryx-tree-list-chevron', states: ['state']},
30
30
  {className: 'astryx-tree-list-item-label', states: ['selected']},
@@ -104,7 +104,7 @@ export const docsZh = {
104
104
  group: 'TreeList',
105
105
  theming: {
106
106
  targets: [
107
- {className: 'astryx-tree-list', visualProps: ['density']},
107
+ {className: 'astryx-tree-list', visualProps: ['density', 'variant']},
108
108
  {className: 'astryx-tree-list-item', visualProps: ['density'], states: ['selected', 'disabled']},
109
109
  {className: 'astryx-tree-list-chevron', states: ['state']},
110
110
  {className: 'astryx-tree-list-item-label', states: ['selected']},
@@ -332,7 +332,7 @@ export function TreeList({
332
332
  ref={ref}
333
333
  data-testid={testId}
334
334
  {...mergeProps(
335
- themeProps('tree-list', {density}),
335
+ themeProps('tree-list', {density, variant}),
336
336
  stylex.props(styles.root, xstyle),
337
337
  className,
338
338
  style,
@@ -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', () => {
@@ -45,7 +45,7 @@ import {VisuallyHidden} from '../VisuallyHidden';
45
45
  import {spacingVars, sizeVars} from '../theme/tokens.stylex';
46
46
  import {groupStyles} from '../InputGroup/groupStyles';
47
47
  import {useInputGroup} from '../InputGroup/InputGroupContext';
48
- import {getInputARIA, mergeProps, mergeRefs} from '../utils';
48
+ import {getInputARIA, isImeKeyEvent, mergeProps, mergeRefs} from '../utils';
49
49
  import type {BaseProps} from '../BaseProps';
50
50
  import type {SizeValue} from '../utils/types';
51
51
  import type {SearchableItem, SearchSource} from './types';
@@ -369,6 +369,15 @@ export function Typeahead<T extends SearchableItem>({
369
369
  // Handle Escape during edit mode — restore token
370
370
  const handleKeyDown = useCallback(
371
371
  (e: React.KeyboardEvent<HTMLInputElement>) => {
372
+ // BaseTypeahead invokes this external handler *before* its own IME
373
+ // guard, so we must guard here too: an IME candidate window uses Escape
374
+ // to cancel the pending composition, and that composing Escape fires
375
+ // before compositionend. Without this, a Korean/Japanese/Chinese user
376
+ // cancelling a candidate would instead exit edit mode and blur the
377
+ // field. See utils/ime.ts.
378
+ if (isImeKeyEvent(e.nativeEvent)) {
379
+ return;
380
+ }
372
381
  if (e.key === 'Escape' && editingValue) {
373
382
  e.preventDefault();
374
383
  setIsEditing(false);
@@ -0,0 +1,79 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file stubMatchMedia.test.ts
5
+ * @input Uses vitest, stubMatchMedia
6
+ * @output Unit coverage for the shared query-aware matchMedia stub
7
+ * @position Testing; guards the helper other suites trust to route queries
8
+ *
9
+ * The helper exists because a stub that ignores its query silently reroutes
10
+ * code branching on a different one — the failure mode leaves every test
11
+ * green. A helper sold on that promise has to be held to it, so these assert
12
+ * the routing itself rather than any component behaviour.
13
+ */
14
+
15
+ import {describe, it, expect, afterEach, vi} from 'vitest';
16
+ import {stubMatchMedia} from './stubMatchMedia';
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals();
20
+ });
21
+
22
+ describe('stubMatchMedia', () => {
23
+ it('routes prefers-reduced-motion independently of every other query', () => {
24
+ // The exact combination the blanket stub could not express: below a
25
+ // breakpoint (matches) while motion is NOT reduced.
26
+ stubMatchMedia({reduceMotion: false, matches: true});
27
+
28
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(false);
29
+ expect(matchMedia('(max-width: 768px)').matches).toBe(true);
30
+ });
31
+
32
+ it('answers no-preference as the opposite of reduce', () => {
33
+ // Two spellings, opposite meanings. A substring test on
34
+ // 'prefers-reduced-motion' alone would hand both the same answer.
35
+ stubMatchMedia({reduceMotion: true});
36
+
37
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true);
38
+ expect(matchMedia('(prefers-reduced-motion: no-preference)').matches).toBe(
39
+ false,
40
+ );
41
+ });
42
+
43
+ it('treats the bare preference query as reduce', () => {
44
+ stubMatchMedia({reduceMotion: true});
45
+ expect(matchMedia('(prefers-reduced-motion)').matches).toBe(true);
46
+
47
+ stubMatchMedia({reduceMotion: false});
48
+ expect(matchMedia('(prefers-reduced-motion)').matches).toBe(false);
49
+ });
50
+
51
+ it('defaults to full motion and matching everything else', () => {
52
+ stubMatchMedia();
53
+
54
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(false);
55
+ expect(matchMedia('(max-width: 768px)').matches).toBe(true);
56
+ });
57
+
58
+ it('lets a non-motion query be answered false', () => {
59
+ // Above the breakpoint, motion still reduced — the other axis of the
60
+ // combination, so neither knob can be quietly driving both.
61
+ stubMatchMedia({reduceMotion: true, matches: false});
62
+
63
+ expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true);
64
+ expect(matchMedia('(max-width: 768px)').matches).toBe(false);
65
+ });
66
+
67
+ it('echoes the query and exposes the listener API components subscribe to', () => {
68
+ stubMatchMedia();
69
+ const mql = matchMedia('(max-width: 768px)');
70
+
71
+ // useMediaQuery subscribes; a stub without these throws on mount rather
72
+ // than failing an assertion, which reads as an unrelated crash.
73
+ expect(mql.media).toBe('(max-width: 768px)');
74
+ expect(typeof mql.addEventListener).toBe('function');
75
+ expect(typeof mql.removeEventListener).toBe('function');
76
+ expect(typeof mql.addListener).toBe('function');
77
+ expect(typeof mql.removeListener).toBe('function');
78
+ });
79
+ });
@@ -0,0 +1,95 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file stubMatchMedia.ts
5
+ * @input Uses vitest
6
+ * @output stubMatchMedia — a query-aware window.matchMedia stub
7
+ * @position Shared test helper; imported by component tests that render below
8
+ * a breakpoint while also exercising motion-sensitive behaviour
9
+ *
10
+ * jsdom has no matchMedia, so tests stub it. The usual stub answers every query
11
+ * with one boolean:
12
+ *
13
+ * vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({matches: true, ...}))
14
+ *
15
+ * That is enough to put AppShell below its breakpoint — and it silently also
16
+ * matches `prefers-reduced-motion`, rerouting any code that branches on motion.
17
+ * MobileNav's close delay is capped at 0 under reduced motion, so a suite using
18
+ * the blanket stub runs against an immediate close while its test names claim
19
+ * it is exercising the real delay. The failure mode is invisible: every test
20
+ * still passes.
21
+ *
22
+ * So the two answers are separate knobs here. `reduceMotion` answers
23
+ * `prefers-reduced-motion` only; `matches` answers everything else (breakpoint
24
+ * width queries, which tests below a breakpoint want to be true).
25
+ *
26
+ * SYNC: When modified, update this header.
27
+ */
28
+
29
+ import {vi} from 'vitest';
30
+
31
+ export interface StubMatchMediaOptions {
32
+ /** Whether the user prefers reduced motion. */
33
+ reduceMotion?: boolean;
34
+ /** Answer for every other query — width breakpoints, hover, etc. */
35
+ matches?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Answer a `prefers-reduced-motion` query, or null if it isn't one.
40
+ *
41
+ * The preference has two spellings and they are opposites, so a single
42
+ * `includes('prefers-reduced-motion')` would answer `no-preference` with the
43
+ * value meant for `reduce` — the same answer-without-reading mistake this
44
+ * helper exists to prevent. Core only queries `reduce` today (MobileNav,
45
+ * Carousel); handling both is what keeps that true if one changes.
46
+ */
47
+ function answerReducedMotion(
48
+ query: string,
49
+ reduceMotion: boolean,
50
+ ): boolean | null {
51
+ if (query.includes('prefers-reduced-motion: reduce')) {
52
+ return reduceMotion;
53
+ }
54
+ if (query.includes('prefers-reduced-motion: no-preference')) {
55
+ return !reduceMotion;
56
+ }
57
+ // Bare `(prefers-reduced-motion)` is true for any value other than
58
+ // no-preference, i.e. the same as `reduce`.
59
+ if (query.includes('prefers-reduced-motion')) {
60
+ return reduceMotion;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Install a `window.matchMedia` stub whose answer depends on the query.
67
+ *
68
+ * Call from `beforeEach` (or inside a test that needs different motion
69
+ * settings) and pair with `vi.unstubAllGlobals()` in `afterEach`.
70
+ *
71
+ * @example
72
+ * ```
73
+ * beforeEach(() => {
74
+ * stubMatchMedia({reduceMotion: false});
75
+ * });
76
+ * ```
77
+ */
78
+ export function stubMatchMedia({
79
+ reduceMotion = false,
80
+ matches = true,
81
+ }: StubMatchMediaOptions = {}): void {
82
+ vi.stubGlobal(
83
+ 'matchMedia',
84
+ vi.fn((query: string) => ({
85
+ matches: answerReducedMotion(query, reduceMotion) ?? matches,
86
+ media: query,
87
+ onchange: null,
88
+ addEventListener: vi.fn(),
89
+ removeEventListener: vi.fn(),
90
+ addListener: vi.fn(),
91
+ removeListener: vi.fn(),
92
+ dispatchEvent: vi.fn(),
93
+ })),
94
+ );
95
+ }
@@ -11,13 +11,17 @@
11
11
  * SYNC: When modified, update this header
12
12
  */
13
13
 
14
- export {
15
- hasActiveFocusTrapEscape,
16
- isImeKeyEvent,
17
- useFocusTrap,
18
- } from './useFocusTrap';
14
+ export {hasActiveFocusTrapEscape, useFocusTrap} from './useFocusTrap';
19
15
  export type {UseFocusTrapOptions, UseFocusTrapReturn} from './useFocusTrap';
20
16
 
17
+ /**
18
+ * @deprecated Import from `@astryxdesign/core/utils` instead — this is a pure
19
+ * predicate, not a hook, and the `hooks` barrel is a `'use client'` boundary.
20
+ * Re-exported here for one release so consumers can move; will be removed in
21
+ * an upcoming major.
22
+ */
23
+ export {isImeKeyEvent} from '../utils/ime';
24
+
21
25
  export {useAnnounce} from './useAnnounce';
22
26
  export type {AnnounceFn, AnnouncePoliteness} from './useAnnounce';
23
27
 
@@ -19,6 +19,7 @@
19
19
  import {useCallback, useEffect, useRef} from 'react';
20
20
 
21
21
  import {FOCUSABLE_SELECTOR} from './focusableSelector';
22
+ import {isImeKeyEvent} from '../utils/ime';
22
23
 
23
24
  /**
24
25
  * Module-level stack of active focus-trap Escape handlers.
@@ -94,20 +95,6 @@ export function hasActiveFocusTrapEscape(): boolean {
94
95
  return escapeStack.length > 0;
95
96
  }
96
97
 
97
- /**
98
- * Whether an Escape keydown should be ignored because it is cancelling an
99
- * in-progress IME composition. CJK/IME users press Escape to cancel
100
- * composition; that must not close the surrounding overlay. `keyCode === 229`
101
- * covers browsers that fire keydown before `isComposing` is set. Exported so
102
- * other overlays (Dialog, Drawer, CommandPalette) share one definition.
103
- */
104
- export function isImeKeyEvent(event: {
105
- isComposing?: boolean;
106
- keyCode?: number;
107
- }): boolean {
108
- return event.isComposing === true || event.keyCode === 229;
109
- }
110
-
111
98
  /**
112
99
  * Whether an element is currently perceivable/focusable — excludes ones hidden
113
100
  * via `display:none`/`visibility:hidden` or inside an `inert`/`hidden` subtree,