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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -43,7 +43,7 @@ import {
43
43
  typeScaleVars,
44
44
  typographyVars,
45
45
  } from '../theme/tokens.stylex';
46
- import {mergeProps} from '../utils';
46
+ import {mergeProps, isImeKeyEvent} from '../utils';
47
47
  import {useTriggerMenu} from './useTriggerMenu';
48
48
  import {useChatComposerTokens, isCustomToken} from './useChatComposerTokens';
49
49
  import {ensureCaretInside, insertTextAtCursor} from './chatComposerSelection';
@@ -559,9 +559,8 @@ export function ChatComposerInput(props: ChatComposerInputProps) {
559
559
 
560
560
  if (e.key === 'Enter' && !e.shiftKey) {
561
561
  // Never submit mid-composition — an IME uses Enter to commit a
562
- // candidate (e.g. Japanese/Chinese/Korean input), and browsers may
563
- // also surface the legacy keyCode 229 for composing keystrokes.
564
- if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) {
562
+ // candidate. See utils/ime.ts for the full rationale.
563
+ if (isImeKeyEvent(e.nativeEvent)) {
565
564
  return;
566
565
  }
567
566
 
@@ -46,7 +46,7 @@ import {
46
46
  typographyVars,
47
47
  typeScaleVars,
48
48
  } from '../theme/tokens.stylex';
49
- import {mergeProps} from '../utils';
49
+ import {isRenderable, mergeProps} from '../utils';
50
50
  import {composeEventHandlers} from '../utils/composeEventHandlers';
51
51
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
52
52
  import type {SizeValue} from '../utils/types';
@@ -439,6 +439,11 @@ export function ComplexSelector<Value>({
439
439
 
440
440
  const triggerContent = triggerLabel ?? placeholder;
441
441
 
442
+ const startIconSlot = renderIconSlot(startIcon, {
443
+ size: 'sm',
444
+ color: 'secondary',
445
+ });
446
+
442
447
  const content = (
443
448
  <div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
444
449
  {children(optimisticValue, commitValue, close, {
@@ -467,9 +472,12 @@ export function ComplexSelector<Value>({
467
472
  inputWrapperStyles.base,
468
473
  styles.triggerContainer,
469
474
  styles[size],
470
- variant === 'input' && focusOutlineStyles.focusWithin,
475
+ // The ring belongs to the wrapper (the focusable `<button>` sits
476
+ // inside it), but it must still be a KEYBOARD ring: `:focus-within`
477
+ // matched a mouse click on the trigger and drew the outline for
478
+ // pointer users too. `focusWithin` here is `:has(:focus-visible)`.
479
+ focusOutlineStyles.focusWithin,
471
480
  variant === 'ghost' && styles.triggerGhost,
472
- variant === 'ghost' && focusOutlineStyles.focusWithin,
473
481
  isDisabled && inputWrapperStyles.disabled,
474
482
  variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
475
483
  isDisabled && styles.disabled,
@@ -479,8 +487,7 @@ export function ComplexSelector<Value>({
479
487
  className,
480
488
  style,
481
489
  )}>
482
- {startIcon &&
483
- renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})}
490
+ {isRenderable(startIconSlot) && startIconSlot}
484
491
  <button
485
492
  ref={triggerRef}
486
493
  id={triggerId}
@@ -63,7 +63,7 @@ import {
63
63
  easeVars,
64
64
  shadowVars,
65
65
  } from '../theme/tokens.stylex';
66
- import {mergeProps, mergeRefs} from '../utils';
66
+ import {mergeProps, mergeRefs, isImeKeyEvent} from '../utils';
67
67
  import type {BaseProps} from '../BaseProps';
68
68
  import type {StyleXStyles} from '../theme/types';
69
69
  import {themeProps} from '../utils/themeProps';
@@ -334,7 +334,9 @@ export function ContextMenu({
334
334
  if (e.key !== 'Escape') {
335
335
  return;
336
336
  }
337
- if (e.isComposing || e.keyCode === 229) {
337
+ if (isImeKeyEvent(e)) {
338
+ // Ignore Escape that is committing/cancelling an IME composition;
339
+ // see utils/ime.ts for why.
338
340
  return;
339
341
  }
340
342
  e.preventDefault();
@@ -536,6 +536,27 @@ describe('DateInput', () => {
536
536
  expect(onChange).toHaveBeenCalledWith('2026-03-15');
537
537
  });
538
538
 
539
+ it('does not commit on a composing Enter (IME)', () => {
540
+ const onChange = vi.fn();
541
+ render(<DateInput label="Date" onChange={onChange} />);
542
+
543
+ const input = screen.getByRole('combobox');
544
+ fireEvent.change(input, {target: {value: '03/15/2026'}});
545
+ onChange.mockClear();
546
+
547
+ // The composing keydown (isComposing / legacy keyCode 229) that commits an
548
+ // IME candidate fires before compositionend; it must not be read as
549
+ // "commit the typed date".
550
+ fireEvent.keyDown(input, {key: 'Enter', isComposing: true});
551
+ expect(onChange).not.toHaveBeenCalled();
552
+ fireEvent.keyDown(input, {key: 'Enter', keyCode: 229});
553
+ expect(onChange).not.toHaveBeenCalled();
554
+
555
+ // A real, non-composing Enter still commits.
556
+ fireEvent.keyDown(input, {key: 'Enter'});
557
+ expect(onChange).toHaveBeenCalledWith('2026-03-15');
558
+ });
559
+
539
560
  // --- Arrow-down opens calendar popover ---
540
561
 
541
562
  // Note: Tests involving popover rendering (show/hide with calendar)
@@ -60,7 +60,7 @@ import {useCalendarConstraints} from '../Calendar/hooks';
60
60
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
61
61
  import {usePopover} from '../Popover';
62
62
  import {useTooltip} from '../Tooltip';
63
- import {getInputARIA, parseDateInput} from '../utils';
63
+ import {getInputARIA, isImeKeyEvent, parseDateInput} from '../utils';
64
64
  import {
65
65
  plainDateFromISO,
66
66
  plainDateToISO,
@@ -627,6 +627,14 @@ export function DateInput({
627
627
  // Handle keyboard events on input
628
628
  const handleInputKeyDown = useCallback(
629
629
  (e: React.KeyboardEvent) => {
630
+ // An in-progress IME composition uses Enter to commit the candidate and
631
+ // Escape to cancel it; that composing keydown fires before
632
+ // compositionend, so without this guard a Korean/Japanese/Chinese user
633
+ // committing a syllable with Enter would instead commit the pending date
634
+ // (or Escape would close the calendar mid-composition). See utils/ime.ts.
635
+ if (isImeKeyEvent(e.nativeEvent)) {
636
+ return;
637
+ }
630
638
  if (e.key === 'Escape' && popover.isOpen) {
631
639
  e.preventDefault();
632
640
  popover.hide();
@@ -204,6 +204,8 @@ export const docs = {
204
204
  className: 'astryx-date-time-input-time-segment',
205
205
  visualProps: ['size', 'status'],
206
206
  },
207
+ {className: 'astryx-date-time-input-toggle-icon', states: ['state']},
208
+ {className: 'astryx-date-time-input-clock-icon'},
207
209
  ],
208
210
  },
209
211
  usage: {
@@ -508,6 +510,8 @@ export const docsZh = {
508
510
  className: 'astryx-date-time-input-time-segment',
509
511
  visualProps: ['size', 'status'],
510
512
  },
513
+ {className: 'astryx-date-time-input-toggle-icon', states: ['state']},
514
+ {className: 'astryx-date-time-input-clock-icon'},
511
515
  ],
512
516
  },
513
517
  };
@@ -15,6 +15,7 @@ import userEvent from '@testing-library/user-event';
15
15
  import {getButton, queryButton} from '../__tests__/fastRoleQueries';
16
16
  import {DateTimeInput} from './DateTimeInput';
17
17
  import type {ISODateTimeString} from './DateTimeInput';
18
+ import {Icon} from '../Icon';
18
19
  import {defineTheme} from '../theme/defineTheme';
19
20
  import {generateThemeCSS} from '../theme/generateThemeRules';
20
21
  import {InternationalizationProvider} from '../i18n';
@@ -96,6 +97,48 @@ describe('DateTimeInput', () => {
96
97
  expect(screen.getByLabelText('Meeting time')).toBeInTheDocument();
97
98
  });
98
99
 
100
+ it('does not commit the date on a composing Enter (IME)', () => {
101
+ const onChange = vi.fn();
102
+ render(<DateTimeInput label="Meeting" onChange={onChange} />);
103
+ const dateInput = screen.getByRole('combobox');
104
+ fireEvent.change(dateInput, {target: {value: '03/15/2026'}});
105
+ onChange.mockClear();
106
+
107
+ // The composing keydown (isComposing / legacy keyCode 229) that commits an
108
+ // IME candidate must not be read as "commit the typed date".
109
+ fireEvent.keyDown(dateInput, {key: 'Enter', isComposing: true});
110
+ expect(onChange).not.toHaveBeenCalled();
111
+ fireEvent.keyDown(dateInput, {key: 'Enter', keyCode: 229});
112
+ expect(onChange).not.toHaveBeenCalled();
113
+
114
+ // A real, non-composing Enter still commits.
115
+ fireEvent.keyDown(dateInput, {key: 'Enter'});
116
+ expect(onChange).toHaveBeenCalled();
117
+ });
118
+
119
+ it('does not step the time on a composing ArrowUp (IME)', () => {
120
+ const onChange = vi.fn();
121
+ render(
122
+ <DateTimeInput
123
+ label="Meeting"
124
+ value={'2026-03-15T14:30' as ISODateTimeString}
125
+ onChange={onChange}
126
+ />,
127
+ );
128
+ const timeInput = screen.getByLabelText('Meeting time');
129
+
130
+ // An IME candidate window navigates with the arrows; a composing ArrowUp
131
+ // must not step the time value.
132
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp', isComposing: true});
133
+ expect(onChange).not.toHaveBeenCalled();
134
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp', keyCode: 229});
135
+ expect(onChange).not.toHaveBeenCalled();
136
+
137
+ // A real, non-composing ArrowUp still steps the time by one minute.
138
+ fireEvent.keyDown(timeInput, {key: 'ArrowUp'});
139
+ expect(onChange).toHaveBeenCalledWith('2026-03-15T14:31');
140
+ });
141
+
99
142
  it('displays formatted date in date input when value is provided', () => {
100
143
  render(
101
144
  <DateTimeInput
@@ -1063,6 +1106,100 @@ describe('DateTimeInput', () => {
1063
1106
  expect(css).toContain('block-size: var(--size-element-lg)');
1064
1107
  expect(css).toContain('padding-inline: var(--spacing-4)');
1065
1108
  });
1109
+
1110
+ it('renders the toggle-icon target on the calendar glyph with open/closed state', async () => {
1111
+ const {container} = render(
1112
+ <DateTimeInput label="Meeting" onChange={() => {}} />,
1113
+ );
1114
+ // The stable target lands on the icon element itself (not the button),
1115
+ // so a theme can restyle just this glyph — mirroring
1116
+ // `date-input-toggle-icon`.
1117
+ const icon = container.querySelector(
1118
+ '.astryx-date-time-input-toggle-icon',
1119
+ );
1120
+ expect(icon).not.toBeNull();
1121
+ expect(icon).toHaveClass('astryx-icon');
1122
+ expect(icon).toHaveAttribute('data-state', 'collapsed');
1123
+
1124
+ fireEvent.click(getButton('Open calendar'));
1125
+ await waitFor(() => {
1126
+ expect(
1127
+ container.querySelector('.astryx-date-time-input-toggle-icon'),
1128
+ ).toHaveAttribute('data-state', 'expanded');
1129
+ });
1130
+ });
1131
+
1132
+ it('renders the clock-icon target on the leading time glyph', () => {
1133
+ const {container} = render(
1134
+ <DateTimeInput label="Meeting" onChange={() => {}} />,
1135
+ );
1136
+ const icon = container.querySelector(
1137
+ '.astryx-date-time-input-clock-icon',
1138
+ );
1139
+ expect(icon).not.toBeNull();
1140
+ expect(icon).toHaveClass('astryx-icon');
1141
+ });
1142
+
1143
+ it('leaves both leading glyphs byte-identical to a plain secondary/sm icon by default', () => {
1144
+ // The targets are purely additive: the stable target class and its
1145
+ // reflected state add nothing to the render until a theme targets them.
1146
+ // Guard that by diffing each glyph's StyleX classes against a standalone
1147
+ // secondary/sm icon, excluding only the additive target/state classes.
1148
+ const {container} = render(
1149
+ <DateTimeInput label="Meeting" onChange={() => {}} />,
1150
+ );
1151
+ const calendarIcon = container.querySelector(
1152
+ '.astryx-date-time-input-toggle-icon',
1153
+ ) as HTMLElement;
1154
+ const clockIcon = container.querySelector(
1155
+ '.astryx-date-time-input-clock-icon',
1156
+ ) as HTMLElement;
1157
+
1158
+ const {container: refContainer} = render(
1159
+ <Icon icon="calendar" size="sm" color="secondary" />,
1160
+ );
1161
+ const refIcon = refContainer.querySelector('.astryx-icon') as HTMLElement;
1162
+
1163
+ const themeTargetClasses = new Set([
1164
+ 'astryx-date-time-input-toggle-icon',
1165
+ 'astryx-date-time-input-clock-icon',
1166
+ 'collapsed',
1167
+ 'expanded',
1168
+ ]);
1169
+ const styleClasses = (el: HTMLElement) =>
1170
+ el.className
1171
+ .split(' ')
1172
+ .filter(c => !themeTargetClasses.has(c))
1173
+ .sort();
1174
+
1175
+ expect(styleClasses(calendarIcon)).toEqual(styleClasses(refIcon));
1176
+ expect(styleClasses(clockIcon)).toEqual(styleClasses(refIcon));
1177
+ });
1178
+
1179
+ it('exposes the icon targets so a theme reaches their size and per-state color', () => {
1180
+ // jsdom cannot resolve the @layer cascade, so the generated CSS is what
1181
+ // proves a same-element theme rule can reach these glyphs and win over
1182
+ // the icon's own base size/color.
1183
+ const theme = defineTheme({
1184
+ name: 'date-time-input-icon-targets-test',
1185
+ components: {
1186
+ 'date-time-input-toggle-icon': {
1187
+ base: {width: '14px', height: '14px', fontSize: '14px'},
1188
+ 'state:expanded': {color: 'var(--color-icon-primary)'},
1189
+ },
1190
+ 'date-time-input-clock-icon': {
1191
+ base: {width: '14px', height: '14px', fontSize: '14px'},
1192
+ },
1193
+ },
1194
+ });
1195
+ const css = generateThemeTestCSS(theme);
1196
+
1197
+ expect(css).toContain('.astryx-date-time-input-toggle-icon {');
1198
+ expect(css).toContain('.astryx-date-time-input-toggle-icon.expanded');
1199
+ expect(css).toContain('.astryx-date-time-input-clock-icon {');
1200
+ expect(css).toContain('width: 14px');
1201
+ expect(css).toContain('color: var(--color-icon-primary)');
1202
+ });
1066
1203
  });
1067
1204
  });
1068
1205
 
@@ -69,6 +69,7 @@ import {
69
69
  formatISOTime,
70
70
  isTimeInRange,
71
71
  adjustTime,
72
+ isImeKeyEvent,
72
73
  mergeProps,
73
74
  mergeRefs,
74
75
  isFocusDetached,
@@ -742,6 +743,13 @@ export function DateTimeInput({
742
743
 
743
744
  const handleDateKeyDown = useCallback(
744
745
  (e: React.KeyboardEvent) => {
746
+ // Guard the composing keydown (fires before compositionend): an IME uses
747
+ // Enter to commit the candidate and Escape to cancel it, so without this
748
+ // a CJK user committing a syllable with Enter would commit the pending
749
+ // date instead. See utils/ime.ts.
750
+ if (isImeKeyEvent(e.nativeEvent)) {
751
+ return;
752
+ }
745
753
  if (e.key === 'Escape' && popover.isOpen) {
746
754
  e.preventDefault();
747
755
  popover.hide();
@@ -835,6 +843,13 @@ export function DateTimeInput({
835
843
 
836
844
  const handleTimeKeyDown = useCallback(
837
845
  (e: KeyboardEvent<HTMLInputElement>) => {
846
+ // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
847
+ // window uses those same arrows to navigate candidates, so guard the
848
+ // composing keydown (fires before compositionend) to avoid stealing them
849
+ // mid-composition. See utils/ime.ts.
850
+ if (isImeKeyEvent(e.nativeEvent)) {
851
+ return;
852
+ }
838
853
  if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
839
854
  e.preventDefault();
840
855
 
@@ -960,7 +975,20 @@ export function DateTimeInput({
960
975
  styles.iconButton,
961
976
  isEffectivelyDisabled && styles.iconButtonDisabled,
962
977
  )}>
963
- <Icon icon="calendar" size="sm" color="secondary" />
978
+ <Icon
979
+ icon="calendar"
980
+ size="sm"
981
+ color="secondary"
982
+ // Stable theme target on the calendar toggle glyph, so a theme
983
+ // can restyle just this icon (color, size, hover) — and each
984
+ // open/closed state — via `defineTheme`, mirroring
985
+ // `date-input-toggle-icon`. Same-element rules in
986
+ // @layer astryx-theme win over the icon's own base color/size,
987
+ // which a segment-level target could not reach.
988
+ {...themeProps('date-time-input-toggle-icon', {
989
+ state: popover.isOpen ? 'expanded' : 'collapsed',
990
+ })}
991
+ />
964
992
  </button>
965
993
  <input
966
994
  ref={mergeRefs(ref, dateInputRef)}
@@ -1039,7 +1067,16 @@ export function DateTimeInput({
1039
1067
  ),
1040
1068
  )}>
1041
1069
  <div {...stylex.props(styles.icon)}>
1042
- <Icon icon="clock" size="sm" color="secondary" />
1070
+ <Icon
1071
+ icon="clock"
1072
+ size="sm"
1073
+ color="secondary"
1074
+ // Stable theme target on the leading clock glyph, so a theme can
1075
+ // restyle just this icon (color, size) via `defineTheme`. The
1076
+ // time segment has no toggle button — the clock is a static
1077
+ // leading affordance — so this carries no interactive state.
1078
+ {...themeProps('date-time-input-clock-icon')}
1079
+ />
1043
1080
  </div>
1044
1081
  <input
1045
1082
  ref={timeInputRef}
@@ -27,7 +27,8 @@ import {
27
27
  import type {BaseProps} from '../BaseProps';
28
28
  import * as stylex from '@stylexjs/stylex';
29
29
  import {useScrollLock} from '../hooks/useScrollLock';
30
- import {hasActiveFocusTrapEscape, isImeKeyEvent} from '../hooks/useFocusTrap';
30
+ import {hasActiveFocusTrapEscape} from '../hooks/useFocusTrap';
31
+ import {isImeKeyEvent} from '../utils/ime';
31
32
  import {
32
33
  colorVars,
33
34
  radiusVars,
@@ -34,6 +34,7 @@ export const docs = {
34
34
  className: 'astryx-input-status-icon',
35
35
  visualProps: ['size', 'status'],
36
36
  },
37
+ {className: 'astryx-input-clear-button'},
37
38
  {className: 'astryx-input-clear-icon'},
38
39
  ],
39
40
  vars: [
@@ -114,4 +114,29 @@ describe('InputClearButton', () => {
114
114
  expect(css).toContain('.astryx-input-clear-icon:hover {');
115
115
  expect(css).toContain('color: var(--color-icon-primary)');
116
116
  });
117
+
118
+ it('renders the astryx-input-clear-button target on the button wrapper', () => {
119
+ render(<InputClearButton label="Clear" onClick={() => {}} />);
120
+ const button = screen.getByRole('button', {name: 'Clear'});
121
+ expect(button).toHaveClass('astryx-input-clear-button');
122
+ });
123
+
124
+ it('exposes input-clear-button so a theme controls the button size and hover', () => {
125
+ const theme = defineTheme({
126
+ name: 'input-clear-button-test',
127
+ components: {
128
+ 'input-clear-button': {
129
+ base: {
130
+ height: '28px',
131
+ ':hover': {backgroundImage: 'none'},
132
+ },
133
+ },
134
+ },
135
+ });
136
+ const css = generateThemeTestCSS(theme);
137
+ expect(css).toContain('.astryx-input-clear-button {');
138
+ expect(css).toContain('height: 28px');
139
+ expect(css).toContain('.astryx-input-clear-button:hover {');
140
+ expect(css).toContain('background-image: none');
141
+ });
117
142
  });
@@ -11,7 +11,8 @@
11
11
  * TextInput, NumberInput, TimeInput, DateInput, DateTimeInput,
12
12
  * DateRangeInput, Selector, MultiSelector, Typeahead, Tokenizer, FileInput —
13
13
  * routes it through here, so the glyph is themed in one place via the
14
- * `astryx-input-clear-icon` target.
14
+ * `astryx-input-clear-icon` target and the button wrapper is themed via the
15
+ * `astryx-input-clear-button` target.
15
16
  */
16
17
 
17
18
  import type {ReactNode} from 'react';
@@ -48,11 +49,13 @@ export function InputClearButton({
48
49
  iconClassName,
49
50
  }: InputClearButtonProps): ReactNode {
50
51
  const {className: iconTargetClassName} = themeProps('input-clear-icon');
52
+ const {className: buttonTargetClassName} = themeProps('input-clear-button');
51
53
  return (
52
54
  <Button
53
55
  variant="ghost"
54
56
  size="sm"
55
57
  label={label}
58
+ className={buttonTargetClassName}
56
59
  icon={
57
60
  <Icon
58
61
  icon="close"
@@ -20,6 +20,12 @@ import {
20
20
  import userEvent from '@testing-library/user-event';
21
21
  import {FileInput} from './FileInput';
22
22
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
23
+ import {InternationalizationProvider} from '../i18n';
24
+
25
+ // The `=1` branch names the file; the `other` branch must not. Both come from
26
+ // this test, so neither can pass against a hardcoded English string.
27
+ const FILE_SELECTED = 'Un fichier choisi : {fileName}';
28
+ const FILES_SELECTED = '{count, number} fichiers choisis';
23
29
 
24
30
  afterEach(() => {
25
31
  __resetLiveRegionsForTest();
@@ -324,23 +330,36 @@ describe('FileInput', () => {
324
330
 
325
331
  describe('announcements', () => {
326
332
  it('announces a single file selection politely', async () => {
327
- render(<FileInput label="Upload" value={null} onChange={() => {}} />);
333
+ render(
334
+ <InternationalizationProvider
335
+ locale="fr"
336
+ overrides={{fr: {'@astryx.fileInput.fileSelected': FILE_SELECTED}}}>
337
+ <FileInput label="Upload" value={null} onChange={() => {}} />
338
+ </InternationalizationProvider>,
339
+ );
328
340
  fireEvent.change(fileInputEl(), {
329
341
  target: {files: [createFile('report.pdf', 100)]},
330
342
  });
331
343
  await waitFor(() => {
332
- expect(politeRegion()).toHaveTextContent('1 file selected: report.pdf');
344
+ // The single-file key, carrying the file name.
345
+ expect(politeRegion()?.textContent).toBe(
346
+ 'Un fichier choisi : report.pdf',
347
+ );
333
348
  });
334
349
  });
335
350
 
336
351
  it('announces a multi-file count politely', async () => {
337
352
  render(
338
- <FileInput
339
- label="Upload"
340
- value={null}
341
- onChange={() => {}}
342
- isMultiple
343
- />,
353
+ <InternationalizationProvider
354
+ locale="fr"
355
+ overrides={{fr: {'@astryx.fileInput.filesSelected': FILES_SELECTED}}}>
356
+ <FileInput
357
+ label="Upload"
358
+ value={null}
359
+ onChange={() => {}}
360
+ isMultiple
361
+ />
362
+ </InternationalizationProvider>,
344
363
  );
345
364
  const files = [
346
365
  createFile('a.txt', 100),
@@ -349,7 +368,32 @@ describe('FileInput', () => {
349
368
  ];
350
369
  fireEvent.change(fileInputEl(), {target: {files}});
351
370
  await waitFor(() => {
352
- expect(politeRegion()).toHaveTextContent('3 files selected');
371
+ // The multi-file key: a count, and no file name.
372
+ expect(politeRegion()?.textContent).toBe('3 fichiers choisis');
373
+ });
374
+ expect(politeRegion()?.textContent).not.toContain('a.txt');
375
+ });
376
+
377
+ it('speaks the selection from a provider catalog', async () => {
378
+ render(
379
+ <InternationalizationProvider
380
+ locale="fr"
381
+ messages={{
382
+ fr: {
383
+ '@astryx.fileInput.fileSelected': {defaultMessage: FILE_SELECTED},
384
+ },
385
+ }}>
386
+ <FileInput label="Upload" value={null} onChange={() => {}} />
387
+ </InternationalizationProvider>,
388
+ );
389
+ fireEvent.change(fileInputEl(), {
390
+ target: {files: [createFile('report.pdf', 100)]},
391
+ });
392
+ await waitFor(() => {
393
+ // Same key through the catalog path rather than `overrides`.
394
+ expect(politeRegion()?.textContent).toBe(
395
+ 'Un fichier choisi : report.pdf',
396
+ );
353
397
  });
354
398
  });
355
399