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

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 (254) 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 +75 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +396 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +63 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +441 -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 +109 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +206 -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 +185 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +1038 -0
  30. package/dist/Calendar/Calendar.d.ts +17 -0
  31. package/dist/Calendar/Calendar.d.ts.map +1 -1
  32. package/dist/Calendar/Calendar.js +25 -1
  33. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  34. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  35. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  36. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  37. package/dist/Chat/ChatComposerInput.js +3 -4
  38. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  39. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  40. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  41. package/dist/ComplexSelector/ComplexSelector.js +18 -7
  42. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  43. package/dist/ContextMenu/ContextMenu.js +4 -2
  44. package/dist/DateInput/DateInput.d.ts.map +1 -1
  45. package/dist/DateInput/DateInput.js +15 -2
  46. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  47. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  48. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  49. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  50. package/dist/DateTimeInput/DateTimeInput.js +38 -3
  51. package/dist/Dialog/Dialog.d.ts.map +1 -1
  52. package/dist/Dialog/Dialog.js +2 -1
  53. package/dist/Field/FieldLabel.d.ts +2 -1
  54. package/dist/Field/FieldLabel.d.ts.map +1 -1
  55. package/dist/Field/FieldLabel.js +21 -3
  56. package/dist/Field/InputClearButton.d.ts +2 -1
  57. package/dist/Field/InputClearButton.d.ts.map +1 -1
  58. package/dist/Field/InputClearButton.js +6 -1
  59. package/dist/FormLayout/FormLayout.d.ts +24 -2
  60. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  61. package/dist/FormLayout/FormLayout.js +7 -2
  62. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  63. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  64. package/dist/FormLayout/FormLayoutContext.js +19 -4
  65. package/dist/FormLayout/index.d.ts +1 -1
  66. package/dist/FormLayout/index.d.ts.map +1 -1
  67. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  68. package/dist/Lightbox/Lightbox.js +9 -3
  69. package/dist/MobileNav/MobileNav.d.ts +12 -0
  70. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  71. package/dist/MobileNav/MobileNav.js +102 -30
  72. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  73. package/dist/MultiSelector/MultiSelector.js +27 -8
  74. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  75. package/dist/NumberInput/NumberInput.js +15 -2
  76. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  77. package/dist/RadioList/RadioList.d.ts.map +1 -1
  78. package/dist/RadioList/RadioList.js +12 -1
  79. package/dist/Selector/Selector.d.ts.map +1 -1
  80. package/dist/Selector/Selector.js +20 -4
  81. package/dist/StatusDot/StatusDot.d.ts +43 -3
  82. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  83. package/dist/StatusDot/StatusDot.js +42 -5
  84. package/dist/Switch/Switch.d.ts.map +1 -1
  85. package/dist/Switch/Switch.js +14 -1
  86. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  87. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  88. package/dist/TextArea/TextArea.d.ts.map +1 -1
  89. package/dist/TextArea/TextArea.js +6 -1
  90. package/dist/TextInput/TextInput.d.ts.map +1 -1
  91. package/dist/TextInput/TextInput.js +6 -1
  92. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  93. package/dist/TimeInput/TimeInput.js +14 -2
  94. package/dist/Tokenizer/Tokenizer.js +11 -5
  95. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  96. package/dist/Tooltip/useTooltip.js +4 -1
  97. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  98. package/dist/Typeahead/BaseTypeahead.js +5 -3
  99. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  100. package/dist/Typeahead/Typeahead.js +10 -1
  101. package/dist/astryx.css +21 -0
  102. package/dist/hooks/index.d.ts +8 -1
  103. package/dist/hooks/index.d.ts.map +1 -1
  104. package/dist/hooks/index.js +8 -1
  105. package/dist/hooks/useFocusTrap.d.ts +0 -11
  106. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  107. package/dist/hooks/useFocusTrap.js +1 -11
  108. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  109. package/dist/hooks/useInputStatusIcon.js +1 -0
  110. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  111. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  112. package/dist/hooks/useResolvedRequired.js +40 -0
  113. package/dist/index.d.ts +1 -0
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +1 -0
  116. package/dist/theme/defineTheme.d.ts +22 -4
  117. package/dist/theme/defineTheme.d.ts.map +1 -1
  118. package/dist/theme/expandColorScale.d.ts +17 -2
  119. package/dist/theme/expandColorScale.d.ts.map +1 -1
  120. package/dist/theme/expandColorScale.js +63 -33
  121. package/dist/utils/ime.d.ts +5 -0
  122. package/dist/utils/ime.d.ts.map +1 -0
  123. package/dist/utils/ime.js +63 -0
  124. package/dist/utils/index.d.ts +1 -0
  125. package/dist/utils/index.d.ts.map +1 -1
  126. package/dist/utils/index.js +2 -1
  127. package/dist/utils/plainDate.d.ts +6 -0
  128. package/dist/utils/plainDate.d.ts.map +1 -1
  129. package/dist/utils/plainDate.js +12 -0
  130. package/locales/af-ZA.json +958 -0
  131. package/locales/ar-SA.json +998 -0
  132. package/locales/ca-ES.json +974 -0
  133. package/locales/cs-CZ.json +990 -0
  134. package/locales/da-DK.json +966 -0
  135. package/locales/de-DE.json +966 -0
  136. package/locales/el-GR.json +990 -0
  137. package/locales/en.json +52 -0
  138. package/locales/es-ES.json +978 -0
  139. package/locales/fi-FI.json +990 -0
  140. package/locales/fr-FR.json +928 -0
  141. package/locales/he-IL.json +990 -0
  142. package/locales/hu-HU.json +990 -0
  143. package/locales/it-IT.json +978 -0
  144. package/locales/ja-JP.json +998 -0
  145. package/locales/ko-KR.json +990 -0
  146. package/locales/nl-NL.json +954 -0
  147. package/locales/no-NO.json +978 -0
  148. package/locales/pl-PL.json +982 -0
  149. package/locales/pseudo.json +39 -0
  150. package/locales/pt-BR.json +982 -0
  151. package/locales/pt-PT.json +982 -0
  152. package/locales/ro-RO.json +982 -0
  153. package/locales/ru-RU.json +990 -0
  154. package/locales/sr-SP.json +990 -0
  155. package/locales/sv-SE.json +986 -0
  156. package/locales/tr-TR.json +990 -0
  157. package/locales/uk-UA.json +990 -0
  158. package/locales/vi-VN.json +986 -0
  159. package/locales/zh-CN.json +998 -0
  160. package/locales/zh-TW.json +998 -0
  161. package/package.json +9 -8
  162. package/src/Banner/Banner.doc.mjs +3 -3
  163. package/src/Banner/Banner.test.tsx +40 -0
  164. package/src/Banner/Banner.tsx +27 -2
  165. package/src/BottomSheet/BottomSheet.doc.mjs +299 -0
  166. package/src/BottomSheet/BottomSheet.test.tsx +2265 -0
  167. package/src/BottomSheet/BottomSheet.tsx +559 -0
  168. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  169. package/src/BottomSheet/BottomSheetPanel.tsx +601 -0
  170. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  171. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  172. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  173. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  174. package/src/BottomSheet/index.ts +19 -0
  175. package/src/BottomSheet/snapOffsets.test.ts +254 -0
  176. package/src/BottomSheet/snapOffsets.ts +245 -0
  177. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  178. package/src/BottomSheet/useSheetGestures.test.ts +1112 -0
  179. package/src/BottomSheet/useSheetGestures.ts +1503 -0
  180. package/src/Calendar/Calendar.doc.mjs +16 -0
  181. package/src/Calendar/Calendar.test.tsx +96 -0
  182. package/src/Calendar/Calendar.tsx +55 -0
  183. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  184. package/src/Chat/ChatComposerInput.tsx +3 -4
  185. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  186. package/src/ComplexSelector/ComplexSelector.tsx +15 -6
  187. package/src/ContextMenu/ContextMenu.tsx +4 -2
  188. package/src/DateInput/DateInput.test.tsx +21 -0
  189. package/src/DateInput/DateInput.tsx +12 -2
  190. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  191. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  192. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  193. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  194. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  195. package/src/DateTimeInput/DateTimeInput.tsx +43 -4
  196. package/src/Dialog/Dialog.tsx +2 -1
  197. package/src/Field/Field.doc.mjs +1 -0
  198. package/src/Field/FieldLabel.tsx +20 -4
  199. package/src/Field/InputClearButton.test.tsx +25 -0
  200. package/src/Field/InputClearButton.tsx +4 -1
  201. package/src/FileInput/FileInput.test.tsx +53 -9
  202. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  203. package/src/FormLayout/FormLayout.test.tsx +181 -4
  204. package/src/FormLayout/FormLayout.tsx +33 -2
  205. package/src/FormLayout/FormLayoutContext.ts +22 -7
  206. package/src/FormLayout/index.ts +1 -1
  207. package/src/Lightbox/Lightbox.test.tsx +49 -28
  208. package/src/Lightbox/Lightbox.tsx +7 -3
  209. package/src/MobileNav/MobileNav.tsx +128 -37
  210. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  211. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  212. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  213. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  214. package/src/MultiSelector/MultiSelector.tsx +25 -9
  215. package/src/NumberInput/NumberInput.test.tsx +18 -0
  216. package/src/NumberInput/NumberInput.tsx +12 -2
  217. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  218. package/src/RadioList/RadioList.tsx +9 -1
  219. package/src/Selector/Selector.test.tsx +85 -26
  220. package/src/Selector/Selector.tsx +16 -5
  221. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  222. package/src/StatusDot/StatusDot.test.tsx +115 -8
  223. package/src/StatusDot/StatusDot.tsx +74 -9
  224. package/src/Switch/Switch.test.tsx +16 -0
  225. package/src/Switch/Switch.tsx +13 -0
  226. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  227. package/src/TextArea/TextArea.tsx +3 -1
  228. package/src/TextInput/TextInput.tsx +3 -1
  229. package/src/TimeInput/TimeInput.test.tsx +23 -0
  230. package/src/TimeInput/TimeInput.tsx +11 -1
  231. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  232. package/src/Tokenizer/Tokenizer.tsx +5 -5
  233. package/src/Tooltip/useTooltip.tsx +4 -1
  234. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  235. package/src/Typeahead/Typeahead.test.tsx +116 -7
  236. package/src/Typeahead/Typeahead.tsx +10 -1
  237. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  238. package/src/__tests__/stubMatchMedia.ts +95 -0
  239. package/src/hooks/index.ts +9 -5
  240. package/src/hooks/useFocusTrap.ts +1 -14
  241. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  242. package/src/hooks/useInputStatusIcon.tsx +7 -0
  243. package/src/hooks/useResolvedRequired.ts +42 -0
  244. package/src/index.ts +1 -0
  245. package/src/theme/defineTheme.ts +22 -4
  246. package/src/theme/expandColorScale.test.ts +138 -0
  247. package/src/theme/expandColorScale.ts +89 -44
  248. package/src/utils/ime.test.ts +32 -0
  249. package/src/utils/ime.ts +69 -0
  250. package/src/utils/index.ts +2 -0
  251. package/src/utils/plainDate.test.ts +50 -0
  252. package/src/utils/plainDate.ts +12 -0
  253. package/dist/astryx.umd.js +0 -147
  254. package/dist/astryx.umd.js.map +0 -7
@@ -9,7 +9,7 @@
9
9
  * SYNC: When DateRangeInput.tsx changes, update tests to match new behavior
10
10
  */
11
11
 
12
- import {describe, it, expect, vi, beforeEach} from 'vitest';
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
13
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
15
 
@@ -712,3 +712,83 @@ describe('DateRangeInput disabled theme state', () => {
712
712
  expect(root).not.toHaveAttribute('data-disabled');
713
713
  });
714
714
  });
715
+
716
+ describe('DateRangeInput range-span forwarding', () => {
717
+ // Pin "today" so the popover opens on a known month and the day buttons we
718
+ // query are guaranteed to render.
719
+ beforeEach(() => {
720
+ vi.useFakeTimers();
721
+ vi.setSystemTime(new Date('2026-01-05T12:00:00Z'));
722
+ });
723
+ afterEach(() => {
724
+ vi.useRealTimers();
725
+ });
726
+
727
+ // The calendar renders in the top layer; jsdom keeps day buttons in the DOM
728
+ // but role queries skip them, so reach them by their machine-readable
729
+ // data-date (ISO) attribute — the same approach Calendar's own tests use.
730
+ const dayButton = (iso: string): HTMLButtonElement | null =>
731
+ document.querySelector<HTMLButtonElement>(`button[data-date="${iso}"]`);
732
+
733
+ it('forwards maxRangeSpan so the window caps after a start is picked', () => {
734
+ render(
735
+ <DateRangeInput
736
+ label="Reporting period"
737
+ value={null}
738
+ onChange={() => {}}
739
+ maxRangeSpan={7}
740
+ />,
741
+ );
742
+
743
+ fireEvent.click(getButton('Open calendar'));
744
+
745
+ // Before a start is picked, a far-off day is selectable.
746
+ expect(dayButton('2026-01-20')).not.toBeDisabled();
747
+
748
+ fireEvent.click(dayButton('2026-01-10') as HTMLButtonElement);
749
+
750
+ // A 7-day window spans start ± 6 days: Jan 16 is the edge, Jan 17 is out.
751
+ expect(dayButton('2026-01-16')).not.toBeDisabled();
752
+ expect(dayButton('2026-01-17')).toBeDisabled();
753
+ });
754
+
755
+ it('disables a preset whose range violates the span cap', () => {
756
+ const presets = [
757
+ {
758
+ label: 'Last 3 days',
759
+ getRange: (): DateRange => ({
760
+ start: '2026-01-08',
761
+ end: '2026-01-10',
762
+ }),
763
+ },
764
+ {
765
+ label: 'Last 30 days',
766
+ getRange: (): DateRange => ({
767
+ start: '2025-12-12',
768
+ end: '2026-01-10',
769
+ }),
770
+ },
771
+ ];
772
+ const handleChange = vi.fn();
773
+ render(
774
+ <DateRangeInput
775
+ label="Reporting period"
776
+ value={null}
777
+ onChange={handleChange}
778
+ maxRangeSpan={7}
779
+ presets={presets}
780
+ />,
781
+ );
782
+
783
+ fireEvent.click(getButton('Open calendar'));
784
+
785
+ // The 3-day preset fits the 7-day cap; the 30-day preset can't be committed.
786
+ const withinCap = getButton('Last 3 days');
787
+ const overCap = getButton('Last 30 days');
788
+ expect(withinCap).not.toBeDisabled();
789
+ expect(overCap).toBeDisabled();
790
+
791
+ fireEvent.click(overCap);
792
+ expect(handleChange).not.toHaveBeenCalled();
793
+ });
794
+ });
@@ -22,6 +22,7 @@ import {
22
22
  plainDateFromISO,
23
23
  plainDateToday,
24
24
  plainDateFormat,
25
+ plainDateDiffDays,
25
26
  DATE_FORMAT_SHORT,
26
27
  DATE_FORMAT_SHORT_WITH_YEAR,
27
28
  } from '../utils/plainDate';
@@ -60,6 +61,7 @@ import type {BaseProps} from '../BaseProps';
60
61
  import type {SizeValue} from '../utils/types';
61
62
  import {useSize} from '../SizeContext/SizeContext';
62
63
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
64
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
63
65
  import {themeProps} from '../utils/themeProps';
64
66
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
65
67
  import {stableClassName} from '../naming';
@@ -161,6 +163,11 @@ const styles = stylex.create({
161
163
  backgroundColor: colorVars['--color-accent-muted'],
162
164
  color: colorVars['--color-accent'],
163
165
  },
166
+ presetButtonDisabled: {
167
+ color: colorVars['--color-text-disabled'],
168
+ cursor: 'not-allowed',
169
+ backgroundColor: 'transparent',
170
+ },
164
171
  });
165
172
 
166
173
  const sizeStyles = stylex.create({
@@ -201,6 +208,34 @@ function isRangeEqual(a: DateRange | null, b: DateRange | null): boolean {
201
208
  return a.start === b.start && a.end === b.end;
202
209
  }
203
210
 
211
+ // A preset that would land outside the span bounds is disabled rather than
212
+ // allowed to override them: the cap is authoritative, so an out-of-window
213
+ // preset stays visible (discoverable) but non-committable, mirroring how the
214
+ // calendar disables out-of-window days. Spans count both endpoints.
215
+ function isRangeWithinSpan(
216
+ range: DateRange,
217
+ maxRangeSpan: number | undefined,
218
+ minRangeSpan: number | undefined,
219
+ ): boolean {
220
+ if (maxRangeSpan == null && minRangeSpan == null) {
221
+ return true;
222
+ }
223
+ const span =
224
+ Math.abs(
225
+ plainDateDiffDays(
226
+ plainDateFromISO(range.start),
227
+ plainDateFromISO(range.end),
228
+ ),
229
+ ) + 1;
230
+ if (maxRangeSpan != null && span > maxRangeSpan) {
231
+ return false;
232
+ }
233
+ if (minRangeSpan != null && span < minRangeSpan) {
234
+ return false;
235
+ }
236
+ return true;
237
+ }
238
+
204
239
  export interface DateRangeInputProps extends Omit<
205
240
  BaseProps,
206
241
  'onChange' | 'defaultValue'
@@ -303,6 +338,30 @@ export interface DateRangeInputProps extends Omit<
303
338
  */
304
339
  dateConstraints?: ReadonlyArray<(date: Date) => boolean>;
305
340
 
341
+ /**
342
+ * Maximum number of days the selected range may span, counting both
343
+ * endpoints — `maxRangeSpan={7}` allows a 7-day window (start + 6 days).
344
+ * Once a start date is picked, days beyond this distance from it are
345
+ * disabled, so the user can't stretch the range past the cap. Use for
346
+ * rolling windows like "at most a week from the chosen day"; for fixed
347
+ * calendar bounds use `min`/`max`.
348
+ *
349
+ * This constrains selection only — it never rewrites a `value` that is
350
+ * already wider than the cap. Surface such a value with `status` if you
351
+ * need to flag it. A `preset` whose range violates the cap is disabled
352
+ * (shown but not committable) rather than allowed to override it.
353
+ */
354
+ maxRangeSpan?: number;
355
+
356
+ /**
357
+ * Minimum number of days the selected range must span, counting both
358
+ * endpoints — `minRangeSpan={2}` forbids a single-day range. Once a start
359
+ * date is picked, days closer than this to it are disabled — except the
360
+ * start itself, which stays selectable as the active anchor. Defaults to 1
361
+ * (a same-day start and end is allowed).
362
+ */
363
+ minRangeSpan?: number;
364
+
306
365
  /**
307
366
  * Preset date ranges shown as quick-select options beside the calendar.
308
367
  */
@@ -396,6 +455,8 @@ export function DateRangeInput({
396
455
  min,
397
456
  max,
398
457
  dateConstraints,
458
+ maxRangeSpan,
459
+ minRangeSpan,
399
460
  presets,
400
461
  hasClear = true,
401
462
  placeholder: placeholderFromProps,
@@ -413,6 +474,7 @@ export function DateRangeInput({
413
474
  ...rest
414
475
  }: DateRangeInputProps) {
415
476
  const t = useTranslator();
477
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
416
478
  const placeholder =
417
479
  placeholderFromProps ?? t('@astryx.dateRangeInput.placeholder');
418
480
  const size = useSize(sizeProp, 'md');
@@ -609,7 +671,7 @@ export function DateRangeInput({
609
671
  aria-disabled={showsDisabledMessage ? 'true' : undefined}
610
672
  aria-label={triggerAriaLabel}
611
673
  aria-describedby={ariaDescribedBy}
612
- aria-required={isRequired === true ? 'true' : undefined}
674
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
613
675
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
614
676
  aria-busy={isBusy || undefined}
615
677
  aria-expanded={popover.isOpen}
@@ -642,6 +704,11 @@ export function DateRangeInput({
642
704
  {presets.map(preset => {
643
705
  const presetRange = preset.getRange();
644
706
  const isActive = isRangeEqual(value, presetRange);
707
+ const isPresetDisabled = !isRangeWithinSpan(
708
+ presetRange,
709
+ maxRangeSpan,
710
+ minRangeSpan,
711
+ );
645
712
  return (
646
713
  <button
647
714
  key={preset.label}
@@ -652,11 +719,13 @@ export function DateRangeInput({
652
719
  // marked with aria-current (not aria-selected, a listbox
653
720
  // concept that contradicted the Tab interaction) (forms-5).
654
721
  aria-current={isActive ? 'true' : undefined}
722
+ disabled={isPresetDisabled}
655
723
  onClick={() => handlePresetClick(preset)}
656
724
  {...stylex.props(
657
725
  focusOutlineStyles.focusVisible,
658
726
  styles.presetButton,
659
727
  isActive && styles.presetButtonActive,
728
+ isPresetDisabled && styles.presetButtonDisabled,
660
729
  )}>
661
730
  {preset.label}
662
731
  </button>
@@ -671,6 +740,8 @@ export function DateRangeInput({
671
740
  min={min}
672
741
  max={max}
673
742
  dateConstraints={dateConstraints}
743
+ maxRangeSpan={maxRangeSpan}
744
+ minRangeSpan={minRangeSpan}
674
745
  numberOfMonths={numberOfMonths}
675
746
  weekStartsOn={weekStartsOn}
676
747
  />
@@ -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,
@@ -83,6 +84,7 @@ import {
83
84
  import type {BaseProps} from '../BaseProps';
84
85
  import type {SizeValue} from '../utils/types';
85
86
  import {useAnnounce} from '../hooks/useAnnounce';
87
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
86
88
  import {useSize} from '../SizeContext/SizeContext';
87
89
  import {themeProps} from '../utils/themeProps';
88
90
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
@@ -447,6 +449,7 @@ export function DateTimeInput({
447
449
  ...rest
448
450
  }: DateTimeInputProps) {
449
451
  const t = useTranslator();
452
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
450
453
  // Speaks arrow-key stepping results through the persistent live regions:
451
454
  // stepping programmatically rewrites a plain textbox's value, which screen
452
455
  // readers do not announce on their own (WCAG 4.1.2).
@@ -742,6 +745,13 @@ export function DateTimeInput({
742
745
 
743
746
  const handleDateKeyDown = useCallback(
744
747
  (e: React.KeyboardEvent) => {
748
+ // Guard the composing keydown (fires before compositionend): an IME uses
749
+ // Enter to commit the candidate and Escape to cancel it, so without this
750
+ // a CJK user committing a syllable with Enter would commit the pending
751
+ // date instead. See utils/ime.ts.
752
+ if (isImeKeyEvent(e.nativeEvent)) {
753
+ return;
754
+ }
745
755
  if (e.key === 'Escape' && popover.isOpen) {
746
756
  e.preventDefault();
747
757
  popover.hide();
@@ -835,6 +845,13 @@ export function DateTimeInput({
835
845
 
836
846
  const handleTimeKeyDown = useCallback(
837
847
  (e: KeyboardEvent<HTMLInputElement>) => {
848
+ // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
849
+ // window uses those same arrows to navigate candidates, so guard the
850
+ // composing keydown (fires before compositionend) to avoid stealing them
851
+ // mid-composition. See utils/ime.ts.
852
+ if (isImeKeyEvent(e.nativeEvent)) {
853
+ return;
854
+ }
838
855
  if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
839
856
  e.preventDefault();
840
857
 
@@ -960,7 +977,20 @@ export function DateTimeInput({
960
977
  styles.iconButton,
961
978
  isEffectivelyDisabled && styles.iconButtonDisabled,
962
979
  )}>
963
- <Icon icon="calendar" size="sm" color="secondary" />
980
+ <Icon
981
+ icon="calendar"
982
+ size="sm"
983
+ color="secondary"
984
+ // Stable theme target on the calendar toggle glyph, so a theme
985
+ // can restyle just this icon (color, size, hover) — and each
986
+ // open/closed state — via `defineTheme`, mirroring
987
+ // `date-input-toggle-icon`. Same-element rules in
988
+ // @layer astryx-theme win over the icon's own base color/size,
989
+ // which a segment-level target could not reach.
990
+ {...themeProps('date-time-input-toggle-icon', {
991
+ state: popover.isOpen ? 'expanded' : 'collapsed',
992
+ })}
993
+ />
964
994
  </button>
965
995
  <input
966
996
  ref={mergeRefs(ref, dateInputRef)}
@@ -981,7 +1011,7 @@ export function DateTimeInput({
981
1011
  aria-disabled={showsDisabledMessage ? 'true' : undefined}
982
1012
  readOnly={showsDisabledMessage || undefined}
983
1013
  aria-describedby={ariaDescribedBy}
984
- aria-required={isRequired === true ? 'true' : undefined}
1014
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
985
1015
  aria-invalid={
986
1016
  status?.type === 'error' || !isDateInputValid ? 'true' : undefined
987
1017
  }
@@ -1039,7 +1069,16 @@ export function DateTimeInput({
1039
1069
  ),
1040
1070
  )}>
1041
1071
  <div {...stylex.props(styles.icon)}>
1042
- <Icon icon="clock" size="sm" color="secondary" />
1072
+ <Icon
1073
+ icon="clock"
1074
+ size="sm"
1075
+ color="secondary"
1076
+ // Stable theme target on the leading clock glyph, so a theme can
1077
+ // restyle just this icon (color, size) via `defineTheme`. The
1078
+ // time segment has no toggle button — the clock is a static
1079
+ // leading affordance — so this carries no interactive state.
1080
+ {...themeProps('date-time-input-clock-icon')}
1081
+ />
1043
1082
  </div>
1044
1083
  <input
1045
1084
  ref={timeInputRef}
@@ -1061,7 +1100,7 @@ export function DateTimeInput({
1061
1100
  timeLabel ?? t('@astryx.dateTimeInput.timeSuffix', {label})
1062
1101
  }
1063
1102
  aria-describedby={ariaDescribedBy}
1064
- aria-required={isRequired === true ? 'true' : undefined}
1103
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
1065
1104
  aria-invalid={
1066
1105
  status?.type === 'error' || !isTimeInputValid ? 'true' : undefined
1067
1106
  }
@@ -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: [
@@ -4,18 +4,19 @@
4
4
 
5
5
  /**
6
6
  * @file FieldLabel.tsx
7
- * @input Uses React, Icon, IconType, useTranslator
7
+ * @input Uses React, Icon, IconType, useTranslator, FormLayoutContext
8
8
  * @output Exports FieldLabel component, FieldLabelProps
9
9
  * @position Core label implementation; used by Field, CheckboxInput, Switch
10
10
  *
11
11
  * SYNC: When modified, update these files to stay in sync:
12
12
  * - /packages/core/src/Field/Field.doc.mjs (props table, features, implementation notes)
13
13
  * - /packages/core/src/Field/index.ts (exports if types change)
14
+ * - /packages/core/src/FormLayout/FormLayoutContext.ts (defaultOptionality drives the indicator)
14
15
  * - /packages/cli/assets/templates/blocks/components/Field/ (showcase blocks)
15
16
  * - /packages/core/locales/en.json (@astryx.field.required / @astryx.field.optional)
16
17
  */
17
18
 
18
- import {useMemo, useRef, type ReactNode, type RefObject} from 'react';
19
+ import {use, useMemo, useRef, type ReactNode, type RefObject} from 'react';
19
20
  import * as stylex from '@stylexjs/stylex';
20
21
  import type {BaseProps} from '../BaseProps';
21
22
  import {mergeProps} from '../utils';
@@ -32,6 +33,7 @@ import {Tooltip} from '../Tooltip';
32
33
  import {useTranslator} from '../i18n';
33
34
  import {themeProps} from '../utils/themeProps';
34
35
  import {useInputContainer} from '../hooks';
36
+ import {FormLayoutContext} from '../FormLayout/FormLayoutContext';
35
37
 
36
38
  const styles = stylex.create({
37
39
  label: {
@@ -188,9 +190,23 @@ export function FieldLabel({
188
190
  ...rest
189
191
  }: FieldLabelProps) {
190
192
  const t = useTranslator();
191
- const statusText = isOptional
193
+ const {defaultOptionality} = use(FormLayoutContext);
194
+
195
+ // A form-level `defaultOptionality` means "only the exception is marked": a
196
+ // field that merely restates the form's default shows no indicator, and only
197
+ // a deviation from it does. This is the *visible indicator* only; the
198
+ // matching `aria-required` is resolved on each control (see
199
+ // useResolvedRequired) so the unmarked majority is still announced.
200
+ //
201
+ // defaultOptionality isRequired isOptional
202
+ // 'optional' → required indicator → (matches default, hidden)
203
+ // 'required' → (matches, hidden) → optional indicator
204
+ // unset → required indicator → optional indicator
205
+ const showRequired = isRequired && defaultOptionality !== 'required';
206
+ const showOptional = isOptional && defaultOptionality !== 'optional';
207
+ const statusText = showOptional
192
208
  ? t('@astryx.field.optional')
193
- : isRequired
209
+ : showRequired
194
210
  ? t('@astryx.field.required')
195
211
  : null;
196
212
 
@@ -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"