@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
@@ -0,0 +1,172 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file MobileNavCloseVisibility.test.tsx
5
+ * @input Uses vitest, @testing-library/react, MobileNav
6
+ * @output Regression tests keeping the drawer rendered until the dialog closes
7
+ * @position Testing; guards the close path of MobileNav.tsx
8
+ *
9
+ * Repro for #4290 (page unresponsive after closing the drawer in Safari 26.1).
10
+ *
11
+ * A `<dialog>` opened with `showModal()` blocks the rest of the document for as
12
+ * long as it stays in the top layer — whether or not it is rendered. The drawer
13
+ * hid itself the moment `isOpen` flipped, while the native `close()` only ran
14
+ * afterwards from an effect, so `close()` was always called on a dialog that was
15
+ * already `display: none`. A browser that fails to un-block the document when
16
+ * such a dialog closes leaves the page inert with no JavaScript error.
17
+ *
18
+ * The fix keeps `display` in the transition with `allow-discrete`, so the drawer
19
+ * stays rendered until the slide-out finishes, and moves the unmount close into
20
+ * its own effect so the deferred close is no longer cut off by its own cleanup.
21
+ *
22
+ * Note on scope: jsdom has no top layer and no CSS transitions, so the inertness
23
+ * itself is not observable here. These tests pin the two mechanisms that prevent
24
+ * it — the declared transition, and the close actually being deferred.
25
+ */
26
+
27
+ import {describe, it, expect, vi, beforeAll} from 'vitest';
28
+ import {render, screen, act} from '@testing-library/react';
29
+ import {MobileNav} from './MobileNav';
30
+ import {stubMatchMedia} from '../__tests__/stubMatchMedia';
31
+
32
+ // jsdom doesn't implement showModal/close on <dialog>, so we mock them
33
+ beforeAll(() => {
34
+ HTMLDialogElement.prototype.showModal =
35
+ HTMLDialogElement.prototype.showModal ||
36
+ function (this: HTMLDialogElement) {
37
+ this.setAttribute('open', '');
38
+ };
39
+ HTMLDialogElement.prototype.close =
40
+ HTMLDialogElement.prototype.close ||
41
+ function (this: HTMLDialogElement) {
42
+ this.removeAttribute('open');
43
+ };
44
+ });
45
+
46
+ function Drawer({isOpen}: {isOpen: boolean}) {
47
+ return (
48
+ <MobileNav isOpen={isOpen} onOpenChange={() => {}} data-testid="mobile-nav">
49
+ <span>Nav content</span>
50
+ </MobileNav>
51
+ );
52
+ }
53
+
54
+ describe('MobileNav stays rendered while it closes', () => {
55
+ it('transitions display with allow-discrete so it hides only once closed', () => {
56
+ render(<Drawer isOpen />);
57
+ const style = getComputedStyle(screen.getByTestId('mobile-nav'));
58
+
59
+ // Without these two the drawer blanks out the instant `isOpen` flips,
60
+ // leaving an open modal dialog that nothing renders — and nothing can
61
+ // click past. See the file header.
62
+ expect(style.transitionProperty).toContain('display');
63
+ expect(style.transitionBehavior).toBe('allow-discrete');
64
+ // A zero hold is the bug wearing the fix's clothes: the drawer stops being
65
+ // rendered on the next style recalc, before any effect can close it.
66
+ expect(style.transitionDuration.trim()).not.toBe('');
67
+ // The hold must stay tied to the motion token rather than a literal: the
68
+ // close delay is derived by reading this value back, so a hardcoded hold
69
+ // and a themed slide-out would drift apart again. jsdom leaves var()
70
+ // unresolved, which is exactly why this reads as a declaration.
71
+ expect(style.transitionDuration).toContain('var(');
72
+ });
73
+
74
+ it('keeps the native dialog open until the slide-out has run', () => {
75
+ vi.useFakeTimers();
76
+ try {
77
+ const {rerender} = render(<Drawer isOpen />);
78
+ const dialog = screen.getByTestId('mobile-nav');
79
+ expect(dialog).toHaveAttribute('open');
80
+
81
+ act(() => {
82
+ rerender(<Drawer isOpen={false} />);
83
+ });
84
+
85
+ // The drawer is on its way out but the dialog is still open, so it is
86
+ // still rendered and still the thing holding the top layer.
87
+ expect(dialog).toHaveAttribute('open');
88
+
89
+ act(() => {
90
+ vi.advanceTimersByTime(300);
91
+ });
92
+ expect(dialog).not.toHaveAttribute('open');
93
+ } finally {
94
+ vi.useRealTimers();
95
+ }
96
+ });
97
+
98
+ it('still closes the dialog when unmounted mid-slide-out', () => {
99
+ vi.useFakeTimers();
100
+ try {
101
+ const {rerender, unmount} = render(<Drawer isOpen />);
102
+ const dialog = screen.getByTestId('mobile-nav');
103
+
104
+ act(() => {
105
+ rerender(<Drawer isOpen={false} />);
106
+ });
107
+ expect(dialog).toHaveAttribute('open');
108
+
109
+ // Teardown before the timer fires — the unmount-only effect has to close
110
+ // the dialog, or the next showModal() is skipped and the drawer can never
111
+ // be re-opened (#3091).
112
+ unmount();
113
+ expect(dialog).not.toHaveAttribute('open');
114
+ } finally {
115
+ vi.useRealTimers();
116
+ }
117
+ });
118
+
119
+ it('still defers the close under prefers-reduced-motion', () => {
120
+ // Query-aware on purpose. A blanket `matches: true` is the stub shape that
121
+ // once left 9 of 11 tests in the sibling edge-case file silently running
122
+ // against a 0ms close delay — see the header of stubMatchMedia.ts. It is
123
+ // inert for this file today (MobileNav renders standalone here, so
124
+ // prefers-reduced-motion is the only query in play), but the shape is the
125
+ // hazard, not the blast radius.
126
+ stubMatchMedia({reduceMotion: true});
127
+ vi.useFakeTimers();
128
+ try {
129
+ const {rerender} = render(<Drawer isOpen />);
130
+ const dialog = screen.getByTestId('mobile-nav');
131
+
132
+ act(() => {
133
+ rerender(<Drawer isOpen={false} />);
134
+ });
135
+ // Reduced motion caps the delay at 0 — but 0 is still a macrotask. What
136
+ // must not happen is the close landing synchronously in the commit, while
137
+ // the dialog is mid-flip. This pins deferral, not duration.
138
+ expect(dialog).toHaveAttribute('open');
139
+
140
+ act(() => {
141
+ vi.advanceTimersByTime(20);
142
+ });
143
+ expect(dialog).not.toHaveAttribute('open');
144
+ } finally {
145
+ vi.useRealTimers();
146
+ vi.unstubAllGlobals();
147
+ }
148
+ });
149
+
150
+ it('re-opening mid-slide-out cancels the pending close', () => {
151
+ vi.useFakeTimers();
152
+ try {
153
+ const {rerender} = render(<Drawer isOpen />);
154
+ const dialog = screen.getByTestId('mobile-nav');
155
+
156
+ act(() => {
157
+ rerender(<Drawer isOpen={false} />);
158
+ });
159
+ act(() => {
160
+ rerender(<Drawer isOpen />);
161
+ });
162
+ act(() => {
163
+ vi.advanceTimersByTime(300);
164
+ });
165
+
166
+ // The queued close must not fire after the drawer has been re-opened.
167
+ expect(dialog).toHaveAttribute('open');
168
+ } finally {
169
+ vi.useRealTimers();
170
+ }
171
+ });
172
+ });
@@ -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);
@@ -158,6 +158,41 @@ describe('PowerSearch', () => {
158
158
  expect(screen.getByRole('combobox')).toHaveFocus();
159
159
  });
160
160
 
161
+ describe('token value truncation (#4759)', () => {
162
+ it('truncates token values by characters, not code units', () => {
163
+ const truncConfig: PowerSearchConfig = {
164
+ name: 'trunc',
165
+ fields: [
166
+ {
167
+ key: 'status',
168
+ label: 'Status',
169
+ operators: [{key: 'is', label: 'is', value: {type: 'string'}}],
170
+ },
171
+ ],
172
+ };
173
+ // 14 emoji = 28 code units but 14 user-perceived characters.
174
+ // adjustedMaxLength = max(15 - 'Status'.length - 'is'.length, 10) = 10,
175
+ // so the value truncates only past 13 characters, cutting at 10.
176
+ render(
177
+ <PowerSearch
178
+ config={truncConfig}
179
+ filters={[
180
+ {
181
+ field: 'status',
182
+ operator: 'is',
183
+ value: {type: 'string', value: '\u{1F600}'.repeat(14)},
184
+ },
185
+ ]}
186
+ onChange={() => {}}
187
+ maxTokenLength={15}
188
+ />,
189
+ );
190
+ expect(
191
+ screen.getByText('\u{1F600}'.repeat(10) + '...'),
192
+ ).toBeInTheDocument();
193
+ });
194
+ });
195
+
161
196
  describe('startIcon', () => {
162
197
  it('does not render a start icon when omitted', () => {
163
198
  render(<PowerSearch config={config} filters={[]} onChange={() => {}} />);
@@ -425,11 +460,15 @@ describe('PowerSearch', () => {
425
460
  });
426
461
  });
427
462
 
428
-
429
463
  describe('PowerSearch statusVariant forwarding', () => {
430
464
  it('defaults to attached (status renders with data-variant="attached")', () => {
431
465
  const {container} = render(
432
- <PowerSearch config={config} filters={[]} onChange={() => {}} status={{type: 'error', message: 'Required'}} />,
466
+ <PowerSearch
467
+ config={config}
468
+ filters={[]}
469
+ onChange={() => {}}
470
+ status={{type: 'error', message: 'Required'}}
471
+ />,
433
472
  );
434
473
  expect(container.querySelector('.astryx-field-status')).toHaveAttribute(
435
474
  'data-variant',
@@ -439,7 +478,13 @@ describe('PowerSearch statusVariant forwarding', () => {
439
478
 
440
479
  it('forwards statusVariant="detached" to the underlying Field status', () => {
441
480
  const {container} = render(
442
- <PowerSearch config={config} filters={[]} onChange={() => {}} status={{type: 'error', message: 'Required'}} statusVariant="detached" />,
481
+ <PowerSearch
482
+ config={config}
483
+ filters={[]}
484
+ onChange={() => {}}
485
+ status={{type: 'error', message: 'Required'}}
486
+ statusVariant="detached"
487
+ />,
443
488
  );
444
489
  expect(container.querySelector('.astryx-field-status')).toHaveAttribute(
445
490
  'data-variant',