@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
@@ -4,8 +4,8 @@
4
4
 
5
5
  /**
6
6
  * @file ComplexSelector.tsx
7
- * @input Uses React, StyleX, Field, usePopover
8
- * @output Exports ComplexSelector component for custom selector surfaces
7
+ * @input Uses React, StyleX, Field, Icon slots, Layer positioning, and usePopover
8
+ * @output Exports a rich-selector shell with exact token-sized input and ghost triggers, plus an imperative open/close handle
9
9
  * @position Core implementation; consumed by index.ts
10
10
  *
11
11
  * SYNC: When modified, update:
@@ -13,12 +13,15 @@
13
13
  * - /packages/core/src/ComplexSelector/ComplexSelector.test.tsx
14
14
  * - /packages/core/src/ComplexSelector/index.ts
15
15
  * - /apps/storybook/stories/ComplexSelector.stories.tsx
16
+ * - /packages/cli/assets/templates/blocks/components/ComplexSelector/ (showcase blocks)
16
17
  */
17
18
 
18
19
  import React, {
19
20
  useCallback,
20
21
  useId,
22
+ useImperativeHandle,
21
23
  useOptimistic,
24
+ useRef,
22
25
  useTransition,
23
26
  type ReactNode,
24
27
  } from 'react';
@@ -26,22 +29,24 @@ import * as stylex from '@stylexjs/stylex';
26
29
  import type {StyleXStyles} from '@stylexjs/stylex';
27
30
  import type {BaseProps} from '../BaseProps';
28
31
  import {Field, inputWrapperStyles, type FieldStatusVariant} from '../Field';
29
- import {Icon} from '../Icon';
32
+ import {Icon, renderIconSlot, type IconType} from '../Icon';
30
33
  import {Spinner} from '../Spinner';
31
34
  import {useTranslator} from '../i18n';
32
35
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
36
+ import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
33
37
  import {usePopover} from '../Popover/usePopover';
34
38
  import {
35
39
  colorVars,
36
40
  durationVars,
37
41
  easeVars,
42
+ fontWeightVars,
38
43
  radiusVars,
39
44
  sizeVars,
40
45
  spacingVars,
41
46
  typographyVars,
42
47
  typeScaleVars,
43
48
  } from '../theme/tokens.stylex';
44
- import {mergeProps} from '../utils';
49
+ import {isRenderable, mergeProps} from '../utils';
45
50
  import {composeEventHandlers} from '../utils/composeEventHandlers';
46
51
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
47
52
  import type {SizeValue} from '../utils/types';
@@ -99,6 +104,39 @@ const styles = stylex.create({
99
104
  placeholder: {
100
105
  color: colorVars['--color-text-secondary'],
101
106
  },
107
+ triggerGhost: {
108
+ width: 'auto',
109
+ borderWidth: 0,
110
+ backgroundColor: 'transparent',
111
+ backgroundImage: {
112
+ default: null,
113
+ ':hover': {
114
+ '@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`,
115
+ },
116
+ ':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`,
117
+ },
118
+ boxShadow: {
119
+ default: 'none',
120
+ ':hover:not(:focus-within)': {
121
+ '@media (hover: hover)': 'none',
122
+ },
123
+ ':focus-within': 'none',
124
+ },
125
+ fontWeight: fontWeightVars['--font-weight-medium'],
126
+ transitionProperty:
127
+ 'background-image, background-color, color, opacity, transform',
128
+ transform: {
129
+ default: 'scale(1)',
130
+ ':active': 'scale(0.98)',
131
+ },
132
+ },
133
+ triggerGhostDisabled: {
134
+ backgroundImage: 'none',
135
+ transform: {
136
+ default: 'none',
137
+ ':active': 'none',
138
+ },
139
+ },
102
140
  // Only what Icon does not already provide: `sm` gives the 16px box and
103
141
  // `color="secondary"` the color, but the glyph still must not shrink inside
104
142
  // the flex trigger.
@@ -129,19 +167,21 @@ const styles = stylex.create({
129
167
  padding: spacingVars['--spacing-3'],
130
168
  },
131
169
  sm: {
132
- minHeight: sizeVars['--size-element-sm'],
170
+ height: sizeVars['--size-element-sm'],
133
171
  },
134
172
  md: {
135
- minHeight: sizeVars['--size-element-md'],
173
+ height: sizeVars['--size-element-md'],
136
174
  },
137
175
  lg: {
138
- minHeight: sizeVars['--size-element-lg'],
176
+ height: sizeVars['--size-element-lg'],
139
177
  },
140
178
  disabled: {
141
179
  cursor: 'not-allowed',
142
180
  },
143
181
  });
144
182
 
183
+ export type ComplexSelectorVariant = 'input' | 'ghost';
184
+
145
185
  export type ComplexSelectorSize = 'sm' | 'md' | 'lg';
146
186
 
147
187
  export interface ComplexSelectorRenderState {
@@ -155,6 +195,25 @@ export interface ComplexSelectorRenderState {
155
195
  contentId: string;
156
196
  }
157
197
 
198
+ /**
199
+ * Imperative control surface for ComplexSelector, accessed via the `handleRef`
200
+ * prop. Methods drive the same popover machinery as the built-in trigger, so
201
+ * they respect focus restoration, light dismiss, and Escape. Prefer these
202
+ * callbacks over mirroring open state in the parent — the selector owns its
203
+ * visibility, and imperative calls avoid the focus-management pitfalls of
204
+ * syncing an external `isOpen` prop.
205
+ */
206
+ export interface ComplexSelectorHandle {
207
+ /** Open the selector surface. No-op when disabled or already open. */
208
+ open(): void;
209
+ /** Close the selector surface. Restores focus to the trigger. */
210
+ close(): void;
211
+ /** Toggle the selector surface open or closed. */
212
+ toggle(): void;
213
+ /** Whether the selector surface is currently open. Reads live state. */
214
+ isOpen(): boolean;
215
+ }
216
+
158
217
  export interface ComplexSelectorStatus {
159
218
  type: 'warning' | 'error' | 'success';
160
219
  message?: string;
@@ -203,10 +262,22 @@ export interface ComplexSelectorProps<Value> extends Omit<
203
262
  labelTooltip?: string;
204
263
  /** Trigger and field size. */
205
264
  size?: ComplexSelectorSize;
265
+ /** Visual trigger style. Ghost matches toolbar buttons. */
266
+ variant?: ComplexSelectorVariant;
267
+ /** Icon displayed at the start of the trigger. */
268
+ startIcon?: ReactNode | IconType;
206
269
  /** Width of the field. */
207
270
  width?: SizeValue;
208
271
  /** Popup placement. */
209
- placement?: 'above' | 'below' | 'start' | 'end';
272
+ placement?: LayerPlacement;
273
+ /** Popup alignment along the placement axis. */
274
+ alignment?: LayerAlignment;
275
+ /**
276
+ * Imperative handle for programmatic open/close control. Exposes open,
277
+ * close, toggle, and the isOpen query. Use this instead of mirroring open
278
+ * state in the parent — the selector owns its visibility.
279
+ */
280
+ handleRef?: React.Ref<ComplexSelectorHandle>;
210
281
  /** StyleX styles for the popup content container. */
211
282
  contentXstyle?: StyleXStyles;
212
283
  /** Test ID for the trigger container. */
@@ -258,8 +329,12 @@ export function ComplexSelector<Value>({
258
329
  statusVariant = 'attached',
259
330
  labelTooltip,
260
331
  size = 'md',
332
+ variant = 'input',
333
+ startIcon,
261
334
  width,
262
335
  placement = 'below',
336
+ alignment = 'start',
337
+ handleRef,
263
338
  contentXstyle,
264
339
  xstyle,
265
340
  className,
@@ -270,6 +345,10 @@ export function ComplexSelector<Value>({
270
345
  }: ComplexSelectorProps<Value>) {
271
346
  const t = useTranslator();
272
347
  const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
348
+ const effectiveStatusVariant =
349
+ variant === 'ghost' && statusVariant === 'attached'
350
+ ? 'detached'
351
+ : statusVariant;
273
352
 
274
353
  const triggerId = useId();
275
354
  const labelId = useId();
@@ -284,24 +363,67 @@ export function ComplexSelector<Value>({
284
363
  .filter((id): id is string => id != null)
285
364
  .join(' ') || undefined;
286
365
 
366
+ const triggerRef = useRef<HTMLButtonElement>(null);
367
+ const lastHideTimeRef = useRef(0);
368
+
287
369
  const [isPending, startTransition] = useTransition();
288
370
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
289
371
  const isBusy = isLoading || isPending;
290
372
 
373
+ const handlePopoverHide = useCallback(() => {
374
+ lastHideTimeRef.current = Date.now();
375
+ triggerRef.current?.focus();
376
+ }, []);
377
+
291
378
  const popover = usePopover({
292
379
  dialogLabel: label,
293
380
  hasCloseButton: false,
294
381
  hasAutoFocus: true,
295
- // The popup's theme target belongs on the SURFACE — the element painting
296
- // background, radius and elevation — which `usePopover` owns. Rendered on
297
- // the content box below it, a theme's background or radius rule paints the
298
- // wrong box.
299
382
  surfaceTarget: 'complex-selector-popup',
300
- onHide: () => {
301
- document.getElementById(triggerId)?.focus();
302
- },
383
+ onHide: handlePopoverHide,
303
384
  });
304
385
 
386
+ const isOpen = popover.isOpen;
387
+
388
+ const handleTriggerClick = useCallback(() => {
389
+ if (isDisabled || Date.now() - lastHideTimeRef.current < 50) {
390
+ return;
391
+ }
392
+ if (popover.isOpen) {
393
+ popover.hide();
394
+ } else {
395
+ popover.show();
396
+ }
397
+ }, [isDisabled, popover]);
398
+
399
+ const close = useCallback(() => {
400
+ popover.hide();
401
+ }, [popover]);
402
+
403
+ useImperativeHandle(
404
+ handleRef,
405
+ () => ({
406
+ open: () => {
407
+ if (!isDisabled) {
408
+ popover.show();
409
+ }
410
+ },
411
+ close: () => popover.hide(),
412
+ toggle: () => {
413
+ if (isDisabled) {
414
+ return;
415
+ }
416
+ if (popover.isOpen) {
417
+ popover.hide();
418
+ } else {
419
+ popover.show();
420
+ }
421
+ },
422
+ isOpen: () => popover.isOpen,
423
+ }),
424
+ [isDisabled, popover],
425
+ );
426
+
305
427
  const commitValue = useCallback(
306
428
  (nextValue: Value) => {
307
429
  onChange?.(nextValue);
@@ -317,10 +439,15 @@ export function ComplexSelector<Value>({
317
439
 
318
440
  const triggerContent = triggerLabel ?? placeholder;
319
441
 
442
+ const startIconSlot = renderIconSlot(startIcon, {
443
+ size: 'sm',
444
+ color: 'secondary',
445
+ });
446
+
320
447
  const content = (
321
448
  <div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
322
- {children(optimisticValue, commitValue, popover.hide, {
323
- isOpen: popover.isOpen,
449
+ {children(optimisticValue, commitValue, close, {
450
+ isOpen,
324
451
  isBusy,
325
452
  triggerId,
326
453
  contentId,
@@ -334,13 +461,10 @@ export function ComplexSelector<Value>({
334
461
  ref={popover.triggerRef}
335
462
  data-testid={testId}
336
463
  {...props}
337
- onClick={composeEventHandlers(onClickProp, () => {
338
- if (!isDisabled) {
339
- popover.toggle();
340
- }
341
- })}
464
+ onClick={composeEventHandlers(onClickProp, handleTriggerClick)}
342
465
  {...mergeProps(
343
466
  themeProps('complex-selector', {
467
+ variant,
344
468
  size,
345
469
  status: status?.type ?? null,
346
470
  }),
@@ -353,7 +477,9 @@ export function ComplexSelector<Value>({
353
477
  // matched a mouse click on the trigger and drew the outline for
354
478
  // pointer users too. `focusWithin` here is `:has(:focus-visible)`.
355
479
  focusOutlineStyles.focusWithin,
480
+ variant === 'ghost' && styles.triggerGhost,
356
481
  isDisabled && inputWrapperStyles.disabled,
482
+ variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
357
483
  isDisabled && styles.disabled,
358
484
  triggerLabel == null && styles.placeholder,
359
485
  xstyle,
@@ -361,11 +487,13 @@ export function ComplexSelector<Value>({
361
487
  className,
362
488
  style,
363
489
  )}>
490
+ {isRenderable(startIconSlot) && startIconSlot}
364
491
  <button
492
+ ref={triggerRef}
365
493
  id={triggerId}
366
494
  type="button"
367
495
  aria-haspopup="dialog"
368
- aria-expanded={popover.isOpen}
496
+ aria-expanded={isOpen}
369
497
  aria-controls={contentId}
370
498
  aria-describedby={ariaDescribedBy}
371
499
  aria-labelledby={labelId}
@@ -374,7 +502,7 @@ export function ComplexSelector<Value>({
374
502
  aria-busy={isBusy || undefined}
375
503
  disabled={isDisabled}
376
504
  onKeyDown={event => {
377
- if (event.key === 'ArrowDown' && !popover.isOpen && !isDisabled) {
505
+ if (event.key === 'ArrowDown' && !isOpen && !isDisabled) {
378
506
  event.preventDefault();
379
507
  popover.show();
380
508
  }
@@ -394,17 +522,17 @@ export function ComplexSelector<Value>({
394
522
  xstyle={[
395
523
  styles.triggerIcon,
396
524
  styles.triggerIconRotation,
397
- popover.isOpen && styles.triggerIconOpen,
525
+ isOpen && styles.triggerIconOpen,
398
526
  ]}
399
527
  {...themeProps('complex-selector-indicator-icon', {
400
- state: popover.isOpen ? 'expanded' : 'collapsed',
528
+ state: isOpen ? 'expanded' : 'collapsed',
401
529
  })}
402
530
  />
403
531
  </div>
404
532
 
405
533
  {popover.render(content, {
406
534
  placement,
407
- alignment: 'start',
535
+ alignment,
408
536
  offset: spacingVars['--spacing-1'],
409
537
  xstyle: [styles.popover, layerAnimations[placement]],
410
538
  })}
@@ -431,7 +559,7 @@ export function ComplexSelector<Value>({
431
559
  }
432
560
  : undefined
433
561
  }
434
- statusVariant={statusVariant}
562
+ statusVariant={effectiveStatusVariant}
435
563
  labelTooltip={labelTooltip}
436
564
  width={width}>
437
565
  {selectorContent}
@@ -4,14 +4,16 @@
4
4
 
5
5
  /**
6
6
  * @file index.ts
7
- * @output Exports ComplexSelector and types
7
+ * @output Exports ComplexSelector and its trigger-size contract types
8
8
  * @position Public API entry point
9
9
  */
10
10
 
11
11
  export {
12
12
  ComplexSelector,
13
+ type ComplexSelectorHandle,
13
14
  type ComplexSelectorProps,
14
15
  type ComplexSelectorRenderState,
15
16
  type ComplexSelectorSize,
16
17
  type ComplexSelectorStatus,
18
+ type ComplexSelectorVariant,
17
19
  } from './ComplexSelector';
@@ -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