@astryxdesign/core 0.7.2 → 0.7.3-canary.02f5fe4

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 (237) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheet.js +8 -4
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  5. package/dist/Calendar/Calendar.js +3 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  8. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  9. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  10. package/dist/ClickableCard/ClickableCard.js +11 -9
  11. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  12. package/dist/CodeBlock/CodeBlock.js +11 -1
  13. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  14. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  15. package/dist/DateInput/DateInput.d.ts +4 -4
  16. package/dist/DateInput/DateInput.d.ts.map +1 -1
  17. package/dist/DateInput/DateInput.js +27 -20
  18. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  19. package/dist/DateInput/NativeDateField.js +14 -8
  20. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  21. package/dist/DateInput/TouchDateField.js +14 -10
  22. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  23. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  24. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  25. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  26. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  27. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  28. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  29. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  30. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  31. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  32. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  33. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  34. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  35. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  36. package/dist/Dialog/Dialog.d.ts.map +1 -1
  37. package/dist/Dialog/Dialog.js +9 -4
  38. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  40. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  41. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  42. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  43. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  44. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  45. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  46. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  47. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  48. package/dist/FileInput/FileInput.d.ts.map +1 -1
  49. package/dist/FileInput/FileInput.js +14 -12
  50. package/dist/Link/Link.d.ts +2 -3
  51. package/dist/Link/Link.d.ts.map +1 -1
  52. package/dist/Link/Link.js +11 -19
  53. package/dist/Markdown/Markdown.d.ts.map +1 -1
  54. package/dist/Markdown/Markdown.js +48 -15
  55. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  56. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  57. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  58. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  59. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  60. package/dist/MultiSelector/MultiSelector.js +25 -18
  61. package/dist/Pagination/Pagination.d.ts.map +1 -1
  62. package/dist/Pagination/Pagination.js +5 -2
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +8 -4
  65. package/dist/Selector/Selector.d.ts +4 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +23 -14
  68. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  69. package/dist/Selector/SelectorOption.js +1 -0
  70. package/dist/Slider/Slider.d.ts.map +1 -1
  71. package/dist/Slider/Slider.js +7 -14
  72. package/dist/Switch/Switch.d.ts.map +1 -1
  73. package/dist/Switch/Switch.js +25 -17
  74. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  75. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  76. package/dist/Switch/switch.markers.stylex.js +6 -0
  77. package/dist/TimeInput/TimeInput.d.ts +7 -6
  78. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  79. package/dist/TimeInput/TimeInput.js +37 -20
  80. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  81. package/dist/TimeInput/TouchTimeField.js +17 -6
  82. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  83. package/dist/Timestamp/Timestamp.js +41 -8
  84. package/dist/Token/Token.d.ts.map +1 -1
  85. package/dist/Token/Token.js +8 -3
  86. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  87. package/dist/Tooltip/Tooltip.js +36 -14
  88. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  89. package/dist/Tooltip/useTooltip.js +11 -2
  90. package/dist/TopNav/TopNav.d.ts.map +1 -1
  91. package/dist/TopNav/TopNav.js +2 -2
  92. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  93. package/dist/TopNav/TopNavHeading.js +1 -0
  94. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  95. package/dist/TreeList/TreeListItem.js +8 -3
  96. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  97. package/dist/Typeahead/Typeahead.js +14 -2
  98. package/dist/astryx.css +8 -2
  99. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  100. package/dist/hooks/useGridFocus.js +10 -1
  101. package/dist/hooks/useMenuPress.d.ts +11 -1
  102. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  103. package/dist/hooks/useMenuPress.js +43 -5
  104. package/dist/utils/disabledInteraction.d.ts +21 -0
  105. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  106. package/dist/utils/disabledInteraction.js +33 -0
  107. package/dist/utils/timeParser.d.ts +4 -0
  108. package/dist/utils/timeParser.d.ts.map +1 -1
  109. package/dist/utils/timeParser.js +9 -2
  110. package/package.json +4 -4
  111. package/scripts/agent-doc-state.mjs +58 -9
  112. package/src/Banner/Banner.spec.md +37 -32
  113. package/src/BottomSheet/BottomSheet.spec.md +34 -54
  114. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  115. package/src/BottomSheet/BottomSheet.tsx +8 -4
  116. package/src/BottomSheet/BottomSheetSwitcher.spec.md +125 -58
  117. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  118. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  119. package/src/Calendar/Calendar.test.tsx +68 -24
  120. package/src/Calendar/Calendar.tsx +2 -1
  121. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  122. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  123. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  124. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  125. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  126. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  127. package/src/ClickableCard/ClickableCard.tsx +9 -9
  128. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  129. package/src/CodeBlock/CodeBlock.tsx +11 -1
  130. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  131. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  132. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  133. package/src/DateInput/DateInput.doc.mjs +9 -8
  134. package/src/DateInput/DateInput.test.tsx +88 -13
  135. package/src/DateInput/DateInput.tsx +49 -21
  136. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  137. package/src/DateInput/NativeDateField.test.tsx +29 -3
  138. package/src/DateInput/NativeDateField.tsx +20 -7
  139. package/src/DateInput/TouchDateField.tsx +24 -9
  140. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  141. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  142. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  143. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  144. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  145. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  146. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  147. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  148. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  149. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  150. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  151. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  152. package/src/Dialog/Dialog.test.tsx +39 -0
  153. package/src/Dialog/Dialog.tsx +9 -4
  154. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  155. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  156. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  157. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  158. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
  159. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  160. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  161. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  162. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  163. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  164. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  165. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  166. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  167. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  168. package/src/FileInput/FileInput.test.tsx +30 -7
  169. package/src/FileInput/FileInput.tsx +21 -11
  170. package/src/Link/Link.test.tsx +21 -7
  171. package/src/Link/Link.tsx +16 -24
  172. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  173. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  174. package/src/Markdown/Markdown.tsx +89 -35
  175. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  176. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  177. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  178. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  179. package/src/MultiSelector/MultiSelector.tsx +53 -29
  180. package/src/Pagination/Pagination.test.tsx +5 -1
  181. package/src/Pagination/Pagination.tsx +3 -1
  182. package/src/Popover/Popover.test.tsx +15 -0
  183. package/src/Popover/Popover.tsx +19 -8
  184. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  185. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  186. package/src/Selector/Selector.doc.mjs +4 -4
  187. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  188. package/src/Selector/Selector.spec.md +11 -11
  189. package/src/Selector/Selector.test.tsx +64 -6
  190. package/src/Selector/Selector.tsx +51 -21
  191. package/src/Selector/SelectorOption.tsx +4 -0
  192. package/src/SideNav/SideNav.test.tsx +35 -0
  193. package/src/Slider/Slider.spec.md +11 -11
  194. package/src/Slider/Slider.test.tsx +3 -3
  195. package/src/Slider/Slider.tsx +8 -15
  196. package/src/Switch/Switch.test.tsx +85 -8
  197. package/src/Switch/Switch.tsx +26 -16
  198. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  199. package/src/Switch/switch.markers.stylex.ts +4 -0
  200. package/src/Table/Table.spec.md +2 -0
  201. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  202. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  203. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  204. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  205. package/src/TimeInput/Presentation.test.tsx +23 -0
  206. package/src/TimeInput/TimeInput.doc.mjs +13 -10
  207. package/src/TimeInput/TimeInput.test.tsx +139 -7
  208. package/src/TimeInput/TimeInput.tsx +53 -24
  209. package/src/TimeInput/TouchTimeField.tsx +27 -5
  210. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  211. package/src/Timestamp/Timestamp.test.tsx +168 -0
  212. package/src/Timestamp/Timestamp.tsx +62 -11
  213. package/src/Token/Token.test.tsx +26 -4
  214. package/src/Token/Token.tsx +6 -2
  215. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  216. package/src/Tooltip/Tooltip.test.tsx +51 -6
  217. package/src/Tooltip/Tooltip.tsx +41 -18
  218. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  219. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  220. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  221. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  222. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  223. package/src/Tooltip/useTooltip.test.tsx +23 -0
  224. package/src/Tooltip/useTooltip.tsx +22 -9
  225. package/src/TopNav/TopNav.tsx +11 -1
  226. package/src/TopNav/TopNavHeading.tsx +1 -0
  227. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  228. package/src/TreeList/TreeList.test.tsx +28 -0
  229. package/src/TreeList/TreeListItem.tsx +6 -2
  230. package/src/Typeahead/Typeahead.test.tsx +89 -0
  231. package/src/Typeahead/Typeahead.tsx +15 -2
  232. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  233. package/src/hooks/useGridFocus.ts +16 -1
  234. package/src/hooks/useMenuPress.ts +45 -7
  235. package/src/utils/disabledInteraction.ts +40 -0
  236. package/src/utils/timeParser.test.ts +11 -0
  237. package/src/utils/timeParser.ts +9 -2
@@ -10,8 +10,10 @@
10
10
  */
11
11
 
12
12
  import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
- import {render, screen, fireEvent, waitFor} from '@testing-library/react';
13
+ import {act, render, screen, fireEvent, waitFor} from '@testing-library/react';
14
14
  import userEvent from '@testing-library/user-event';
15
+ import * as stylex from '@stylexjs/stylex';
16
+ import {colorVars} from '../theme/tokens.stylex';
15
17
  import {TimeInput} from './TimeInput';
16
18
  import {InputGroup, InputGroupText} from '../InputGroup';
17
19
  import {InternationalizationProvider} from '../i18n';
@@ -65,6 +67,26 @@ describe('TimeInput', () => {
65
67
  expect(onChange).toHaveBeenCalledWith('14:31');
66
68
  });
67
69
 
70
+ it('does not step into a fractional minute when increment is fractional', () => {
71
+ const onChange = vi.fn();
72
+ render(
73
+ <TimeInput
74
+ label="Time"
75
+ value={'14:30' as ISOTimeString}
76
+ increment={0.5}
77
+ onChange={onChange}
78
+ />,
79
+ );
80
+ const input = screen.getByLabelText('Time');
81
+
82
+ // `increment` is documented as minutes and typed `number`. A fractional one
83
+ // used to reach formatISOTime unrounded, which pads "30.5" to two
84
+ // characters and leaves it, so the arrow key handed the consumer a value
85
+ // that is no longer a time at all.
86
+ fireEvent.keyDown(input, {key: 'ArrowUp'});
87
+ expect(onChange).toHaveBeenCalledWith('14:30');
88
+ });
89
+
68
90
  it('displays formatted time in 12h format', () => {
69
91
  render(
70
92
  <TimeInput
@@ -121,9 +143,41 @@ describe('TimeInput', () => {
121
143
  );
122
144
  });
123
145
 
124
- it('disables input when isDisabled is true', () => {
146
+ it('keeps a disabled input focusable without native disabled (AST-085 FR1)', () => {
147
+ render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
148
+ const input = screen.getByRole('textbox');
149
+ expect(input).not.toHaveAttribute('disabled');
150
+ expect(input).toHaveAttribute('aria-disabled', 'true');
151
+ expect(input).toHaveAttribute('readonly');
152
+ input.focus();
153
+ expect(input).toHaveFocus();
154
+ });
155
+
156
+ it('restores the focus ring on a focused disabled field (AST-085 FR6)', () => {
157
+ // The same declarations as the field's ring style compile to the same
158
+ // atomic classes; the shared disabled style alone drops them.
159
+ const ringClasses = stylex
160
+ .props(
161
+ stylex.create({
162
+ ring: {
163
+ borderColor: {
164
+ default: colorVars['--color-border-emphasized'],
165
+ ':focus-within': colorVars['--color-accent'],
166
+ },
167
+ boxShadow: {
168
+ default: 'none',
169
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
170
+ },
171
+ },
172
+ }).ring,
173
+ )
174
+ .className!.split(' ')
175
+ .filter(name => !name.includes('__'));
125
176
  render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
126
- expect(screen.getByRole('textbox')).toBeDisabled();
177
+ const wrapper = screen.getByRole('textbox').closest('.astryx-time-input')!;
178
+ for (const name of ringClasses) {
179
+ expect(wrapper).toHaveClass(name);
180
+ }
127
181
  });
128
182
 
129
183
  it('shows clear button when hasClear is true and value exists', () => {
@@ -625,11 +679,89 @@ describe('TimeInput', () => {
625
679
  expect(onChange).not.toHaveBeenCalled();
626
680
  });
627
681
 
628
- it('remains natively disabled when disabled without a reason', () => {
629
- render(<TimeInput label="Time" isDisabled />);
682
+ it('blocks typing, arrows and a caller onClick when disabled without a reason (AST-085 FR2)', async () => {
683
+ const user = userEvent.setup();
684
+ const onChange = vi.fn();
685
+ const onClick = vi.fn();
686
+ render(
687
+ <TimeInput
688
+ label="Time"
689
+ value={'09:00' as ISOTimeString}
690
+ onChange={onChange}
691
+ onClick={onClick}
692
+ isDisabled
693
+ />,
694
+ );
695
+ const input = screen.getByLabelText('Time');
696
+ await user.click(input);
697
+ await user.type(input, '1');
698
+ await user.keyboard('{ArrowUp}');
699
+ await user.tab();
700
+ expect(onChange).not.toHaveBeenCalled();
701
+ expect(onClick).not.toHaveBeenCalled();
702
+ });
703
+
704
+ it.each([
705
+ ['text-input', false, {isDisabled: true}],
706
+ ['native', true, {isDisabled: true}],
707
+ ['bottom-sheet', true, {isDisabled: true}],
708
+ ['bottom-sheet', true, {isLoading: true}],
709
+ ] as const)(
710
+ 'does not submit a surrounding form on Enter: %s (coarse %s) %o (AST-085 FR2)',
711
+ async (presentation, isCoarse, state) => {
712
+ vi.stubGlobal('matchMedia', (query: string) => ({
713
+ matches: /pointer:\s*coarse/.test(query)
714
+ ? isCoarse
715
+ : /pointer:\s*fine/.test(query) && !isCoarse,
716
+ media: query,
717
+ addEventListener: () => {},
718
+ removeEventListener: () => {},
719
+ addListener: () => {},
720
+ removeListener: () => {},
721
+ }));
722
+ const user = userEvent.setup();
723
+ const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
724
+ render(
725
+ <form onSubmit={onSubmit}>
726
+ <TimeInput
727
+ label="Time"
728
+ presentation={presentation}
729
+ value={'09:00' as ISOTimeString}
730
+ onChange={() => {}}
731
+ {...state}
732
+ />
733
+ <button type="submit">Save</button>
734
+ </form>,
735
+ );
736
+ try {
737
+ const input = document.querySelector<HTMLInputElement>(
738
+ '.astryx-time-input input:not([type=hidden])',
739
+ )!;
740
+ act(() => input.focus());
741
+ await user.keyboard('{Enter}');
742
+ expect(onSubmit).not.toHaveBeenCalled();
743
+ } finally {
744
+ vi.unstubAllGlobals();
745
+ }
746
+ },
747
+ );
748
+
749
+ it('drops a draft instead of committing it when the field becomes disabled (AST-085 FR2)', async () => {
750
+ const user = userEvent.setup();
751
+ const onChange = vi.fn();
752
+ const value = '09:00' as ISOTimeString;
753
+ const {rerender} = render(
754
+ <TimeInput label="Time" value={value} onChange={onChange} />,
755
+ );
630
756
  const input = screen.getByLabelText('Time');
631
- expect(input).toBeDisabled();
632
- expect(input).not.toHaveAttribute('aria-disabled');
757
+ await user.click(input);
758
+ await user.clear(input);
759
+ onChange.mockClear();
760
+ rerender(
761
+ <TimeInput label="Time" value={value} onChange={onChange} isDisabled />,
762
+ );
763
+ await user.tab();
764
+ expect(onChange).not.toHaveBeenCalled();
633
765
  });
634
766
 
635
767
  it('does not swap in the format-hint placeholder on focus while disabled', () => {
@@ -93,6 +93,18 @@ import {useDevWarning} from '../hooks/useDevWarning';
93
93
  const TOUCH_POINTER_QUERY = '(pointer: coarse)';
94
94
 
95
95
  const styles = stylex.create({
96
+ // The shared disabled style drops the focus ring; restore it while a
97
+ // control inside the field holds focus (spec:AST-085 FR6). Defaults repeat it.
98
+ disabledFocusRing: {
99
+ borderColor: {
100
+ default: colorVars['--color-border-emphasized'],
101
+ ':focus-within': colorVars['--color-accent'],
102
+ },
103
+ boxShadow: {
104
+ default: 'none',
105
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
106
+ },
107
+ },
96
108
  icon: {
97
109
  display: 'flex',
98
110
  alignItems: 'center',
@@ -207,12 +219,12 @@ export interface TimeInputProps extends Omit<
207
219
  /**
208
220
  * Explains why the input is disabled. When set together with
209
221
  * `isDisabled`, the input shows a tooltip with this text on hover and
210
- * keyboard focus, and the field stays focusable (via `aria-disabled`)
211
- * so the reason is discoverable by keyboard and assistive technology.
212
- * Typing and arrow-key adjustment stay blocked.
222
+ * keyboard focus, and the input is described by it, so the reason is
223
+ * discoverable by keyboard and assistive technology. Typing and arrow-key
224
+ * adjustment stay blocked.
213
225
  *
214
- * Use this instead of wrapping a disabled input in `Tooltip` — disabled
215
- * controls don't emit the pointer events an external tooltip needs.
226
+ * Use this instead of wrapping a disabled input in `Tooltip`, so the
227
+ * reason is tied to the field.
216
228
  *
217
229
  * @example
218
230
  * ```
@@ -286,7 +298,8 @@ export interface TimeInputProps extends Omit<
286
298
  hourFormat?: TimeInputHourFormat;
287
299
 
288
300
  /**
289
- * Increment in minutes when using arrow keys.
301
+ * Increment in minutes when using arrow keys. Steps are whole minutes: a
302
+ * fractional value leaves the time unchanged.
290
303
  * @default 1
291
304
  */
292
305
  increment?: number;
@@ -478,10 +491,10 @@ function TimeField({
478
491
  }
479
492
  }, [announce, inputGroup, status?.message, status?.type]);
480
493
 
481
- // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
482
- // tooltip listeners attach to the input container (which already exists) and
483
- // the input stays perceivable via aria-disabled instead of the disabled
484
- // attribute. Typing is blocked with readOnly and value mutation guards.
494
+ // A disabled field never renders native `disabled` (spec:AST-085 FR1): the
495
+ // input stays focusable via aria-disabled, and readOnly plus the value
496
+ // mutation guards block typing. The disabled-reason tooltip's listeners
497
+ // attach to the input container.
485
498
  const showsDisabledMessage = isDisabled && !!disabledMessage;
486
499
  const disabledMessageTooltip = useTooltip({
487
500
  placement: 'above',
@@ -576,8 +589,8 @@ function TimeField({
576
589
  // Handle input text change - update immediately if valid
577
590
  const handleInputChange = useCallback(
578
591
  (e: React.ChangeEvent<HTMLInputElement>) => {
579
- // With a disabledMessage the input drops `disabled` for focusability, so
580
- // guard value mutation explicitly (readOnly also blocks typing).
592
+ // The input is never natively disabled (spec:AST-085 FR1), so guard
593
+ // value mutation explicitly (readOnly also blocks typing).
581
594
  if (isDisabled) {
582
595
  return;
583
596
  }
@@ -612,6 +625,11 @@ function TimeField({
612
625
  if (pendingInput === null) {
613
626
  return;
614
627
  }
628
+ // Disabled: keep the value, drop the draft (spec:AST-085 FR2).
629
+ if (isDisabled) {
630
+ setPendingInput(null);
631
+ return;
632
+ }
615
633
 
616
634
  if (!pendingInput.trim()) {
617
635
  // Empty input clears the value
@@ -632,10 +650,17 @@ function TimeField({
632
650
  // Clear pending input - display will revert to formatted value
633
651
  setPendingInput(null);
634
652
  },
635
- [pendingInput, value, fireChange, hasSeconds, min, max],
653
+ [pendingInput, isDisabled, value, fireChange, hasSeconds, min, max],
636
654
  );
637
655
 
638
656
  // Handle keyboard navigation on input
657
+ // Enter in a focused disabled field would submit a surrounding form (FR2).
658
+ const preventDisabledEnter = (e: KeyboardEvent<HTMLInputElement>) => {
659
+ if (isDisabled && e.key === 'Enter') {
660
+ e.preventDefault();
661
+ }
662
+ };
663
+
639
664
  const handleInputKeyDown = useCallback(
640
665
  (e: KeyboardEvent<HTMLInputElement>) => {
641
666
  // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
@@ -645,8 +670,7 @@ function TimeField({
645
670
  if (isImeKeyEvent(e.nativeEvent)) {
646
671
  return;
647
672
  }
648
- // Arrow-key adjustment mutates the value; block it while showing a
649
- // disabled reason (the input keeps focusability via aria-disabled).
673
+ // Arrow-key adjustment mutates the value; block it while disabled.
650
674
  if (isDisabled) {
651
675
  return;
652
676
  }
@@ -743,6 +767,7 @@ function TimeField({
743
767
  inputWrapperStyles.base,
744
768
  sizeStyles[size],
745
769
  isDisabled && inputWrapperStyles.disabled,
770
+ isDisabled && styles.disabledFocusRing,
746
771
  status && inputStatusBorderStyles[status.type],
747
772
  status && !isDisabled && inputStatusHoverShadowStyles[status.type],
748
773
  status && inputStatusFocusWithinStyles[status.type],
@@ -777,7 +802,7 @@ function TimeField({
777
802
  max={max}
778
803
  hourFormat={hourFormat}
779
804
  isEffectivelyDisabled={isDisabled}
780
- hasDisabledMessage={showsDisabledMessage}
805
+ isFocusableDisabled
781
806
  isEffectivelyRequired={isEffectivelyRequired}
782
807
  isBusy={isBusy}
783
808
  statusType={status?.type}
@@ -786,7 +811,8 @@ function TimeField({
786
811
  ...controlProps,
787
812
  onFocus: onFocusProp,
788
813
  onBlur: onBlurProp,
789
- onKeyDown: onKeyDownProp,
814
+ onKeyDown: chainEventHandlers(preventDisabledEnter, onKeyDownProp),
815
+ onClick: isDisabled ? undefined : controlProps.onClick,
790
816
  }}
791
817
  />
792
818
  ) : (
@@ -803,14 +829,17 @@ function TimeField({
803
829
  onChange={handleInputChange}
804
830
  onFocus={chainEventHandlers(handleFocus, onFocusProp)}
805
831
  onBlur={chainEventHandlers(handleBlur, onBlurProp)}
806
- onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
832
+ onKeyDown={chainEventHandlers(
833
+ preventDisabledEnter,
834
+ handleInputKeyDown,
835
+ onKeyDownProp,
836
+ )}
807
837
  placeholder={displayPlaceholder}
808
- // With a disabledMessage the input keeps focusability via
809
- // aria-disabled so the reason is focus-discoverable; typing and
810
- // arrow-key adjustment are blocked with readOnly and the guards.
811
- disabled={isDisabled && !showsDisabledMessage}
812
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
813
- readOnly={showsDisabledMessage || undefined}
838
+ aria-disabled={isDisabled ? 'true' : undefined}
839
+ readOnly={isDisabled || undefined}
840
+ // The browser still dispatches a click on an aria-disabled input,
841
+ // so a caller's onClick is skipped too (spec:AST-085 FR2).
842
+ onClick={isDisabled ? undefined : controlProps.onClick}
814
843
  autoFocus={hasAutoFocus}
815
844
  data-autofocus={hasAutoFocus || undefined}
816
845
  aria-describedby={ariaDescribedBy}
@@ -24,6 +24,7 @@ import {
24
24
  useRef,
25
25
  useState,
26
26
  useTransition,
27
+ type KeyboardEvent,
27
28
  } from 'react';
28
29
  import * as stylex from '@stylexjs/stylex';
29
30
  import {BottomSheet} from '../BottomSheet';
@@ -45,7 +46,7 @@ import {useMergedRefs} from '../hooks/useMergedRefs';
45
46
  import {useResolvedRequired} from '../hooks/useResolvedRequired';
46
47
  import {Icon} from '../Icon';
47
48
  import {useTranslator} from '../i18n';
48
- import {sizeVars, spacingVars} from '../theme/tokens.stylex';
49
+ import {colorVars, sizeVars, spacingVars} from '../theme/tokens.stylex';
49
50
  import {useSize} from '../SizeContext/SizeContext';
50
51
  import {Spinner} from '../Spinner';
51
52
  import {useTooltip} from '../Tooltip';
@@ -74,6 +75,17 @@ const sizeStyles = stylex.create({
74
75
  });
75
76
 
76
77
  const styles = stylex.create({
78
+ // Same as TimeInput's: the focus ring for a focused disabled field.
79
+ disabledFocusRing: {
80
+ borderColor: {
81
+ default: colorVars['--color-border-emphasized'],
82
+ ':focus-within': colorVars['--color-accent'],
83
+ },
84
+ boxShadow: {
85
+ default: 'none',
86
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
87
+ },
88
+ },
77
89
  iconButton: {
78
90
  display: 'flex',
79
91
  alignItems: 'center',
@@ -447,6 +459,7 @@ export function TouchTimeField({
447
459
  inputWrapperStyles.base,
448
460
  sizeStyles[size],
449
461
  isDisabled && inputWrapperStyles.disabled,
462
+ isDisabled && styles.disabledFocusRing,
450
463
  status && inputStatusBorderStyles[status.type],
451
464
  status && !isDisabled && inputStatusHoverShadowStyles[status.type],
452
465
  status && inputStatusFocusWithinStyles[status.type],
@@ -459,7 +472,7 @@ export function TouchTimeField({
459
472
  <button
460
473
  type="button"
461
474
  onClick={openSheet}
462
- disabled={isEffectivelyDisabled}
475
+ aria-disabled={isEffectivelyDisabled || undefined}
463
476
  aria-label={t('@astryx.timeInput.openPicker', {label})}
464
477
  tabIndex={-1}
465
478
  {...stylex.props(focusOutlineStyles.focusVisible, styles.iconButton)}>
@@ -475,10 +488,19 @@ export function TouchTimeField({
475
488
  readOnly
476
489
  inputMode="none"
477
490
  onChange={() => {}}
478
- onClick={chainEventHandlers(openSheet, onClickProp)}
491
+ onClick={
492
+ isEffectivelyDisabled
493
+ ? undefined
494
+ : chainEventHandlers(openSheet, onClickProp)
495
+ }
496
+ // Enter would submit a surrounding form while disabled (FR2).
497
+ onKeyDown={chainEventHandlers((e: KeyboardEvent<HTMLInputElement>) => {
498
+ if (isEffectivelyDisabled && e.key === 'Enter') {
499
+ e.preventDefault();
500
+ }
501
+ }, controlProps.onKeyDown)}
479
502
  placeholder={placeholder}
480
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
481
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
503
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
482
504
  aria-describedby={ariaDescribedBy}
483
505
  aria-required={isEffectivelyRequired ? 'true' : undefined}
484
506
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
@@ -28,7 +28,7 @@ export const docs = {
28
28
  name: 'format',
29
29
  type: "'relative' | 'relative_short' | 'auto' | 'date' | 'date_long' | 'date_weekday' | 'date_time' | 'time' | 'system_date' | 'system_date_time' | 'system_time' | 'unix_seconds'",
30
30
  description:
31
- "Display format. 'relative' uses the locale's native long relative-time wording ('2 hours ago' in English), 'relative_short' uses its narrow pattern ('2h ago' in English) for compact surfaces, 'date' shows 'Mar 21, 2025', 'date_long' shows 'March 21, 2025', 'date_weekday' shows 'Wed, Mar 21, 2025', 'date_time' shows 'Mar 21, 2025, 2:51 PM', 'time' shows '2:51 PM', 'system_*' variants use ISO-style formatting, 'unix_seconds' shows the Unix time in whole seconds since the epoch (an absolute, zone-independent value), 'auto' switches from relative to date_time based on recency.",
31
+ "Display format. 'relative' uses the locale's native long relative-time wording ('2 hours ago' in English), 'relative_short' uses its narrow pattern ('2h ago' in English) for compact surfaces, 'date' shows 'Mar 21, 2025', 'date_long' shows 'March 21, 2025', 'date_weekday' shows 'Wed, Mar 21, 2025', 'date_time' shows 'Mar 21, 2025, 2:51 PM', 'time' shows '2:51 PM', 'system_*' variants use ISO-style formatting, 'unix_seconds' shows the Unix time in whole seconds since the epoch (an absolute, zone-independent value), 'auto' switches from relative to date_time based on recency. Absolute formats never read the current time. 'relative', 'relative_short' and 'auto' read it on the client only: a server render or static prerender shows the full date and time in UTC, labelled, and the client switches to the format's normal output after hydration.",
32
32
  default: "'auto'",
33
33
  },
34
34
  {
@@ -11,6 +11,9 @@ import {
11
11
  } from 'vitest';
12
12
  import {render, screen, act, waitFor, fireEvent} from '@testing-library/react';
13
13
  import userEvent from '@testing-library/user-event';
14
+ import {Profiler} from 'react';
15
+ import {hydrateRoot, type Root} from 'react-dom/client';
16
+ import {renderToString} from 'react-dom/server';
14
17
  import {Timestamp} from './Timestamp';
15
18
  import {formatInstant} from './formatInstant';
16
19
  import {formatTooltipLines} from './tooltipEntries';
@@ -1466,3 +1469,168 @@ describe('Timestamp pass-through props', () => {
1466
1469
  });
1467
1470
  });
1468
1471
  });
1472
+
1473
+ /**
1474
+ * Counts `new Date()` calls with no argument (reads of the current time) made
1475
+ * while `run` executes.
1476
+ */
1477
+ function countClockReads(run: () => void): number {
1478
+ const ActiveDate = globalThis.Date;
1479
+ let reads = 0;
1480
+ class CountingDate extends ActiveDate {
1481
+ constructor(...args: [] | [string | number | Date]) {
1482
+ if (args.length === 0) {
1483
+ reads += 1;
1484
+ }
1485
+ super(...(args as [string | number | Date]));
1486
+ }
1487
+ }
1488
+ globalThis.Date = CountingDate as DateConstructor;
1489
+ try {
1490
+ run();
1491
+ } finally {
1492
+ globalThis.Date = ActiveDate;
1493
+ }
1494
+ return reads;
1495
+ }
1496
+
1497
+ describe('Timestamp server rendering', () => {
1498
+ beforeEach(() => {
1499
+ vi.useFakeTimers();
1500
+ vi.setSystemTime(new Date('2026-03-25T12:00:00Z'));
1501
+ });
1502
+
1503
+ afterEach(() => {
1504
+ vi.useRealTimers();
1505
+ });
1506
+
1507
+ it.each(['date', 'date_time', 'time', 'unix_seconds'] as const)(
1508
+ 'never reads the current time for the absolute %s format',
1509
+ format => {
1510
+ const element = (
1511
+ <Timestamp value="2026-03-25T10:00:00Z" format={format} />
1512
+ );
1513
+ expect(countClockReads(() => renderToString(element))).toBe(0);
1514
+ expect(countClockReads(() => render(element))).toBe(0);
1515
+ },
1516
+ );
1517
+
1518
+ it.each(['relative', 'relative_short', 'auto'] as const)(
1519
+ 'renders the %s format as the full date and time in UTC on the server without reading the clock',
1520
+ format => {
1521
+ const value = '2026-03-25T10:00:00Z';
1522
+ let html = '';
1523
+ expect(
1524
+ countClockReads(() => {
1525
+ html = renderToString(<Timestamp value={value} format={format} />);
1526
+ }),
1527
+ ).toBe(0);
1528
+ const host = document.createElement('div');
1529
+ host.innerHTML = html;
1530
+ expect(host.textContent).toBe(
1531
+ formatInstant(new Date(value), 'date_time', 'en-US', {
1532
+ timeZone: 'UTC',
1533
+ isTimezoneShown: true,
1534
+ }),
1535
+ );
1536
+ },
1537
+ );
1538
+
1539
+ it('never commits the UTC placeholder when a client Timestamp switches to a relative format', () => {
1540
+ const value = Date.now() / 1000 - 7200;
1541
+ const committed: string[] = [];
1542
+ function Spy({format}: {format: 'date' | 'relative'}) {
1543
+ return (
1544
+ <Profiler
1545
+ id="ts"
1546
+ onRender={() => {
1547
+ committed.push(document.querySelector('time')?.textContent ?? '');
1548
+ }}>
1549
+ <Timestamp value={value} format={format} hasTooltip={false} />
1550
+ </Profiler>
1551
+ );
1552
+ }
1553
+ const {rerender} = render(<Spy format="date" />);
1554
+ committed.length = 0;
1555
+ rerender(<Spy format="relative" />);
1556
+ expect(screen.getByText('2 hours ago')).toBeInTheDocument();
1557
+ expect(committed.some(text => text.includes('UTC'))).toBe(false);
1558
+ });
1559
+
1560
+ describe('across time zones', () => {
1561
+ const originalTZ = process.env.TZ;
1562
+
1563
+ afterEach(() => {
1564
+ if (originalTZ === undefined) {
1565
+ delete process.env.TZ;
1566
+ } else {
1567
+ process.env.TZ = originalTZ;
1568
+ }
1569
+ });
1570
+
1571
+ it('writes the same server markup whatever the server time zone', () => {
1572
+ const element = (
1573
+ <Timestamp value="2026-03-25T10:00:00Z" format="relative" />
1574
+ );
1575
+ process.env.TZ = 'UTC';
1576
+ const utc = renderToString(element);
1577
+ process.env.TZ = 'Asia/Tokyo';
1578
+ expect(renderToString(element)).toBe(utc);
1579
+ });
1580
+
1581
+ it('hydrates server markup from another time zone without a mismatch', () => {
1582
+ const value = Date.now() / 1000 - 7200;
1583
+ const element = (
1584
+ <Timestamp value={value} format="relative" hasTooltip={false} />
1585
+ );
1586
+ process.env.TZ = 'UTC';
1587
+ const host = document.createElement('div');
1588
+ document.body.appendChild(host);
1589
+ host.innerHTML = renderToString(element);
1590
+
1591
+ process.env.TZ = 'America/Los_Angeles';
1592
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
1593
+ let root: Root | undefined;
1594
+ try {
1595
+ act(() => {
1596
+ root = hydrateRoot(host, element, {
1597
+ onRecoverableError: error => {
1598
+ throw error;
1599
+ },
1600
+ });
1601
+ });
1602
+ expect(host.textContent).toBe('2 hours ago');
1603
+ expect(errors).not.toHaveBeenCalled();
1604
+ } finally {
1605
+ errors.mockRestore();
1606
+ act(() => root?.unmount());
1607
+ host.remove();
1608
+ }
1609
+ });
1610
+ });
1611
+
1612
+ it('hydrates the server markup without a mismatch, then shows the relative phrase', () => {
1613
+ const value = Date.now() / 1000 - 7200;
1614
+ const element = (
1615
+ <Timestamp value={value} format="relative" hasTooltip={false} />
1616
+ );
1617
+ const host = document.createElement('div');
1618
+ document.body.appendChild(host);
1619
+ host.innerHTML = renderToString(element);
1620
+ expect(host.textContent).not.toBe('2 hours ago');
1621
+
1622
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
1623
+ let root: Root | undefined;
1624
+ try {
1625
+ act(() => {
1626
+ root = hydrateRoot(host, element);
1627
+ });
1628
+ expect(host.textContent).toBe('2 hours ago');
1629
+ expect(errors).not.toHaveBeenCalled();
1630
+ } finally {
1631
+ errors.mockRestore();
1632
+ act(() => root?.unmount());
1633
+ host.remove();
1634
+ }
1635
+ });
1636
+ });