@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
@@ -54,6 +54,7 @@ import {formatFilterValue} from './formatFilterValue';
54
54
  import {PowerSearchEditPopover} from './PowerSearchEditPopover';
55
55
  import {resolveOperatorLabel} from './resolveOperatorLabel';
56
56
  import {themeProps} from '../utils/themeProps';
57
+ import {truncateCharacters} from '../utils/characters';
57
58
  import {useTranslator} from '../i18n';
58
59
  import type {
59
60
  PowerSearchConfig,
@@ -117,7 +118,9 @@ const resultCountStyles = stylex.create({
117
118
  });
118
119
 
119
120
  function truncateString(value: string, limit: number): string {
120
- return value.length > limit + 3 ? value.slice(0, limit) + '...' : value;
121
+ // Same semantics as before — strings within limit + 3 pass through, longer
122
+ // ones cut to limit + '...' — but counted in characters, not code units.
123
+ return truncateCharacters(value, limit + 3, '...');
121
124
  }
122
125
 
123
126
  function getEnumLabel(values: ReadonlyArray<EnumItem>, value: string): string {
@@ -20,7 +20,7 @@ import {HStack, VStack} from '../Stack';
20
20
  import {Icon} from '../Icon';
21
21
  import {TreeList, type TreeListItemData} from '../TreeList';
22
22
  import {useTranslator} from '../i18n';
23
- import {isImeKeyEvent} from '../hooks/useFocusTrap';
23
+ import {isImeKeyEvent} from '../utils/ime';
24
24
  import {spacingVars, typeScaleVars} from '../theme/tokens.stylex';
25
25
  import {PowerSearchValueEditor} from './PowerSearchValueEditor';
26
26
  import {resolveOperatorLabel} from './resolveOperatorLabel';
@@ -62,6 +62,28 @@ describe('formatFilterValue', () => {
62
62
  'abcde',
63
63
  );
64
64
  });
65
+
66
+ it('truncates by characters, never splitting an emoji (#4759)', () => {
67
+ // Four surrogate-pair emoji: 8 code units, 4 user-perceived characters.
68
+ // The exact-string assertion also proves no surrogate pair was split.
69
+ const out = fmt(
70
+ {type: 'string'},
71
+ {type: 'string', value: '\u{1F600}'.repeat(4)},
72
+ 3,
73
+ );
74
+ expect(out).toBe('\u{1F600}'.repeat(2) + ELLIPSIS);
75
+ });
76
+
77
+ it('keeps multi-unit characters within maxLength intact (#4759)', () => {
78
+ // Three emoji are 6 code units but only 3 user-perceived characters.
79
+ expect(
80
+ fmt(
81
+ {type: 'string'},
82
+ {type: 'string', value: '\u{1F600}'.repeat(3)},
83
+ 3,
84
+ ),
85
+ ).toBe('\u{1F600}'.repeat(3));
86
+ });
65
87
  });
66
88
 
67
89
  describe('integer / float', () => {
@@ -13,12 +13,10 @@
13
13
  import type {OperatorValue, FilterValue, EnumItem} from './types';
14
14
  import type {InternalConfig} from './useInternalConfig';
15
15
  import type {TranslatorFn} from '../i18n';
16
+ import {truncateCharacters} from '../utils/characters';
16
17
 
17
18
  function truncate(str: string, maxLength: number): string {
18
- if (str.length <= maxLength) {
19
- return str;
20
- }
21
- return str.slice(0, maxLength - 1) + '\u2026';
19
+ return truncateCharacters(str, maxLength);
22
20
  }
23
21
 
24
22
  function formatEnumLabel(
@@ -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
  )}>
@@ -95,6 +95,12 @@ describe('columnUtils', () => {
95
95
  it('handles single character', () => {
96
96
  expect(capitalize('a')).toBe('A');
97
97
  });
98
+
99
+ it('uppercases an astral-plane letter without splitting it (#4759)', () => {
100
+ // Deseret 𐐨 (U+10428) uppercases to 𐐀 (U+10400); charAt(0) would grab
101
+ // half the surrogate pair and leave the string unchanged.
102
+ expect(capitalize('\u{10428}pple')).toBe('\u{10400}pple');
103
+ });
98
104
  });
99
105
 
100
106
  describe('generateColumns', () => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type {CSSProperties, ReactNode} from 'react';
15
15
  import type {TableColumn, ProportionalWidth, PixelWidth} from './types';
16
+ import {firstCharacter} from '../utils/characters';
16
17
 
17
18
  /** Default minimum width (in px) for proportional columns. */
18
19
  export const DEFAULT_MIN_COLUMN_WIDTH = 120;
@@ -168,7 +169,8 @@ export function capitalize(str: string): string {
168
169
  if (str.length === 0) {
169
170
  return str;
170
171
  }
171
- return str.charAt(0).toUpperCase() + str.slice(1);
172
+ const first = firstCharacter(str);
173
+ return first.toUpperCase() + str.slice(first.length);
172
174
  }
173
175
 
174
176
  /**
@@ -105,7 +105,7 @@ export const docs = {
105
105
  name: 'maxLength',
106
106
  type: 'number',
107
107
  description:
108
- 'Maximum number of characters allowed. When set, a character counter (current/max) is displayed inside the input container, anchored to the bottom-right beneath the text. Does not enforce the limit natively; when exceeded the counter turns red and shows a warning icon (a non-color cue), and screen-reader users hear the remaining/over-limit count announced.',
108
+ 'Maximum number of characters allowed, counted as user-perceived characters — an emoji or flag sequence counts as one. When set, a character counter (current/max) is displayed inside the input container, anchored to the bottom-right beneath the text. Does not enforce the limit natively; when exceeded the counter turns red and shows a warning icon (a non-color cue), and screen-reader users hear the remaining/over-limit count announced. Consumers validating the limit should count with characterCount (exported from the package) so enforcement matches the displayed count.',
109
109
  },
110
110
  {
111
111
  name: 'status',
@@ -504,6 +504,33 @@ describe('TextArea', () => {
504
504
  expect(screen.getByRole('textbox')).not.toHaveAttribute('maxlength');
505
505
  });
506
506
 
507
+ it('counts user-perceived characters, not code units (#4759)', () => {
508
+ // Two surrogate-pair emoji: 4 code units, but 2 user-perceived characters.
509
+ render(
510
+ <TextArea
511
+ label="Description"
512
+ value={'\u{1F600}\u{1F600}'}
513
+ onChange={() => {}}
514
+ maxLength={5}
515
+ />,
516
+ );
517
+ expect(screen.getByText('2/5')).toBeInTheDocument();
518
+ });
519
+
520
+ it('measures the over-limit state in characters (#4759)', () => {
521
+ // Three ZWJ family emoji: 33 code units, 3 user-perceived characters.
522
+ const family = '\u{1F468}‍\u{1F469}‍\u{1F467}‍\u{1F466}';
523
+ render(
524
+ <TextArea
525
+ label="Description"
526
+ value={family.repeat(3)}
527
+ onChange={() => {}}
528
+ maxLength={2}
529
+ />,
530
+ );
531
+ expect(screen.getByText('3/2')).toBeInTheDocument();
532
+ });
533
+
507
534
  it('counter updates as user types (controlled)', async () => {
508
535
  const user = userEvent.setup();
509
536
  function Wrapper() {
@@ -570,6 +597,51 @@ describe('TextArea', () => {
570
597
  });
571
598
  });
572
599
 
600
+ it('announces zone transitions using character counts (#4759)', async () => {
601
+ function Wrapper() {
602
+ const [val, setVal] = useState('x'.repeat(7));
603
+ return (
604
+ <TextArea
605
+ label="Description"
606
+ value={val}
607
+ onChange={setVal}
608
+ maxLength={10}
609
+ />
610
+ );
611
+ }
612
+ render(<Wrapper />);
613
+ // Appending two emoji makes 9 characters (11 code units): near the limit
614
+ // with 1 remaining. Code-unit counting would call this over the limit
615
+ // and announce assertively instead.
616
+ fireEvent.change(screen.getByRole('textbox'), {
617
+ target: {value: 'x'.repeat(7) + '\u{1F600}\u{1F600}'},
618
+ });
619
+ const politeRegion = () =>
620
+ document.querySelector('[data-astryx-live-region="polite"]');
621
+ const assertiveRegion = () =>
622
+ document.querySelector('[data-astryx-live-region="assertive"]');
623
+ await waitFor(() => {
624
+ expect(politeRegion()).toHaveTextContent('1 character remaining');
625
+ });
626
+ expect(assertiveRegion()).not.toHaveTextContent('over the limit');
627
+ });
628
+
629
+ it('does not flag over-limit while characters fit, even when code units exceed (#4759)', () => {
630
+ // Three emoji: 6 code units but 3 user-perceived characters — within a
631
+ // maxLength of 4, so no error state anywhere.
632
+ render(
633
+ <TextArea
634
+ label="Description"
635
+ value={'\u{1F600}'.repeat(3)}
636
+ onChange={() => {}}
637
+ maxLength={4}
638
+ />,
639
+ );
640
+ const counter = screen.getByText('3/4');
641
+ expect(counter.querySelector('svg')).toBeNull();
642
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-invalid');
643
+ });
644
+
573
645
  it('shows a non-color over-limit indicator icon when exceeded', () => {
574
646
  const {container} = render(
575
647
  <TextArea
@@ -22,6 +22,7 @@ import {
22
22
  useTransition,
23
23
  useRef,
24
24
  useCallback,
25
+ useMemo,
25
26
  type ChangeEvent,
26
27
  type ClipboardEvent,
27
28
  type FocusEvent,
@@ -53,6 +54,7 @@ import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
53
54
  import {useAnnounce} from '../hooks/useAnnounce';
54
55
  import {useSize} from '../SizeContext/SizeContext';
55
56
  import {themeProps} from '../utils/themeProps';
57
+ import {characterCount} from '../utils/characters';
56
58
  import {useTranslator} from '../i18n';
57
59
 
58
60
  const COUNTER_WARNING_THRESHOLD = 0.8;
@@ -312,10 +314,13 @@ export interface TextAreaProps extends Omit<
312
314
  */
313
315
  onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void;
314
316
  /**
315
- * Maximum number of characters allowed.
316
- * When set, displays a character counter below the textarea.
317
+ * Maximum number of characters allowed, counted as user-perceived
318
+ * characters — an emoji or flag sequence counts as one. When set,
319
+ * displays a character counter below the textarea.
317
320
  * Does not enforce the limit natively — the counter shows error styling
318
- * when exceeded, and the consumer can validate via onChange.
321
+ * when exceeded, and the consumer can validate via onChange. Validate with
322
+ * `characterCount` (exported from this package) rather than `value.length`
323
+ * so enforcement matches the displayed count.
319
324
  */
320
325
  maxLength?: number;
321
326
  /**
@@ -482,7 +487,11 @@ export function TextArea({
482
487
  return;
483
488
  }
484
489
  const newValue = e.target.value;
485
- announceCounter(newValue.length);
490
+ // Guarded here, not just inside announceCounter, so textareas without a
491
+ // maxLength never pay for segmenting the whole value on each keystroke.
492
+ if (maxLength != null) {
493
+ announceCounter(characterCount(newValue));
494
+ }
486
495
  onChange?.(newValue, e);
487
496
  if (changeAction && !e.defaultPrevented) {
488
497
  startTransition(async () => {
@@ -492,6 +501,15 @@ export function TextArea({
492
501
  }
493
502
  };
494
503
 
504
+ // Counter semantics count user-perceived characters, so an emoji or flag
505
+ // sequence counts as one character, not its code units.
506
+ // Only measured when a counter exists — segmentation is O(value length),
507
+ // and memoized so re-renders that keep the same value skip it entirely.
508
+ const valueLength = useMemo(
509
+ () => (maxLength != null ? characterCount(optimisticValue) : 0),
510
+ [maxLength, optimisticValue],
511
+ );
512
+
495
513
  const effectivelyDisabled = isDisabled || isBusy;
496
514
 
497
515
  // Focus textarea when clicking anywhere on the wrapper (icons, padding, etc.)
@@ -583,7 +601,7 @@ export function TextArea({
583
601
  aria-required={isRequired && !isOptional ? 'true' : undefined}
584
602
  aria-invalid={
585
603
  status?.type === 'error' ||
586
- (maxLength != null && optimisticValue.length > maxLength)
604
+ (maxLength != null && valueLength > maxLength)
587
605
  ? 'true'
588
606
  : undefined
589
607
  }
@@ -614,15 +632,15 @@ export function TextArea({
614
632
  id={counterID}
615
633
  {...stylex.props(
616
634
  styles.counter,
617
- optimisticValue.length > maxLength && styles.counterError,
635
+ valueLength > maxLength && styles.counterError,
618
636
  )}>
619
- {optimisticValue.length > maxLength && (
637
+ {valueLength > maxLength && (
620
638
  // Non-color cue so the over-limit state isn't conveyed by the red
621
639
  // color alone (WCAG 1.4.1). Decorative — the count text and the
622
640
  // live-region announcement carry the meaning.
623
641
  <Icon icon="warning" size="sm" />
624
642
  )}
625
- {optimisticValue.length}/{maxLength}
643
+ {valueLength}/{maxLength}
626
644
  </div>
627
645
  )}
628
646
  </div>
@@ -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) {