@astryxdesign/core 0.7.3-canary.f94e962 → 0.7.3

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 (133) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +8 -4
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  6. package/dist/Button/Button.d.ts.map +1 -1
  7. package/dist/Button/Button.js +15 -2
  8. package/dist/Calendar/Calendar.js +3 -2
  9. package/dist/Chat/ChatComposer.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposer.js +25 -2
  11. package/dist/Chat/ChatSystemMessage.d.ts +4 -0
  12. package/dist/Chat/ChatSystemMessage.d.ts.map +1 -1
  13. package/dist/Chat/ChatSystemMessage.js +4 -2
  14. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  15. package/dist/CodeBlock/CodeBlock.js +11 -1
  16. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  17. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  18. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  19. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  20. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  21. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  22. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  23. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  24. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  25. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  26. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  27. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  28. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  29. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  30. package/dist/Dialog/Dialog.d.ts.map +1 -1
  31. package/dist/Dialog/Dialog.js +9 -4
  32. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +9 -5
  33. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  34. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +2 -0
  35. package/dist/Markdown/Markdown.d.ts.map +1 -1
  36. package/dist/Markdown/Markdown.js +48 -15
  37. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  38. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  39. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  40. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  41. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  42. package/dist/MultiSelector/MultiSelector.js +25 -18
  43. package/dist/Pagination/Pagination.d.ts.map +1 -1
  44. package/dist/Pagination/Pagination.js +5 -2
  45. package/dist/Switch/Switch.d.ts.map +1 -1
  46. package/dist/Switch/Switch.js +13 -3
  47. package/dist/TimeInput/TimeInput.d.ts +5 -5
  48. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  49. package/dist/TimeInput/TimeInput.js +37 -20
  50. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  51. package/dist/TimeInput/TouchTimeField.js +17 -6
  52. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  53. package/dist/Timestamp/Timestamp.js +41 -8
  54. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  55. package/dist/TreeList/TreeListItem.js +8 -3
  56. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  57. package/dist/hooks/useGridFocus.js +10 -1
  58. package/package.json +4 -4
  59. package/scripts/agent-doc-state.mjs +24 -2
  60. package/src/BottomSheet/BottomSheet.spec.md +26 -53
  61. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  62. package/src/BottomSheet/BottomSheet.tsx +8 -4
  63. package/src/BottomSheet/BottomSheetSwitcher.spec.md +45 -48
  64. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  65. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  66. package/src/Button/Button.test.tsx +13 -2
  67. package/src/Button/Button.tsx +23 -2
  68. package/src/Button/__tests__/Button.a11y.states.ts +4 -3
  69. package/src/Calendar/Calendar.test.tsx +68 -24
  70. package/src/Calendar/Calendar.tsx +2 -1
  71. package/src/Carousel/Carousel.spec.md +146 -0
  72. package/src/Chat/ChatComposer.spec.md +26 -26
  73. package/src/Chat/ChatComposer.test.tsx +58 -2
  74. package/src/Chat/ChatComposer.tsx +25 -1
  75. package/src/Chat/ChatSystemMessage.doc.mjs +1 -1
  76. package/src/Chat/ChatSystemMessage.test.tsx +25 -3
  77. package/src/Chat/ChatSystemMessage.tsx +6 -4
  78. package/src/Chat/__tests__/ChatSystemMessage.a11y.chromium.spec.ts +8 -9
  79. package/src/CheckboxList/__tests__/CheckboxList.a11y.chromium.spec.ts +7 -6
  80. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  81. package/src/CodeBlock/CodeBlock.tsx +11 -1
  82. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  83. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  84. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  85. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  86. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  87. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  88. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  89. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  90. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  91. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  92. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  93. package/src/Dialog/Dialog.test.tsx +39 -0
  94. package/src/Dialog/Dialog.tsx +9 -4
  95. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
  96. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  97. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +62 -7
  98. package/src/DropdownMenu/DropdownMenuRadioGroup.spec.md +190 -0
  99. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +10 -4
  100. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +93 -0
  101. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  102. package/src/DropdownMenu/__tests__/DropdownMenuRadioGroupAudit.a11y.chromium.spec.ts +327 -0
  103. package/src/FieldStatus/FieldStatus.spec.md +4 -4
  104. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +0 -18
  105. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +3 -18
  106. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +1 -2
  107. package/src/Markdown/Markdown.helpers.perf.test.ts +25 -19
  108. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  109. package/src/Markdown/Markdown.tsx +89 -35
  110. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  111. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  112. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  113. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  114. package/src/MultiSelector/MultiSelector.tsx +53 -29
  115. package/src/Pagination/Pagination.test.tsx +5 -1
  116. package/src/Pagination/Pagination.tsx +3 -1
  117. package/src/Switch/Switch.test.tsx +56 -0
  118. package/src/Switch/Switch.tsx +13 -2
  119. package/src/Table/plugins/columnResize/useTableColumnResize.spec.md +11 -11
  120. package/src/Table/plugins/rowStatus/useTableRowStatus.spec.md +4 -3
  121. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  122. package/src/TimeInput/Presentation.test.tsx +23 -0
  123. package/src/TimeInput/TimeInput.doc.mjs +8 -7
  124. package/src/TimeInput/TimeInput.test.tsx +119 -7
  125. package/src/TimeInput/TimeInput.tsx +51 -23
  126. package/src/TimeInput/TouchTimeField.tsx +27 -5
  127. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  128. package/src/Timestamp/Timestamp.test.tsx +168 -0
  129. package/src/Timestamp/Timestamp.tsx +62 -11
  130. package/src/TreeList/TreeList.test.tsx +28 -0
  131. package/src/TreeList/TreeListItem.tsx +6 -2
  132. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  133. package/src/hooks/useGridFocus.ts +16 -1
@@ -71,7 +71,7 @@ export const docs = {
71
71
  {
72
72
  guidance: false,
73
73
  description:
74
- 'Wrap a disabled TimeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
74
+ 'Wrap a disabled TimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
75
75
  },
76
76
  ],
77
77
  anatomy: [
@@ -144,14 +144,15 @@ export const docs = {
144
144
  {
145
145
  name: 'isDisabled',
146
146
  type: 'boolean',
147
- description: 'Disables the input and suppresses interactions.',
147
+ description:
148
+ 'Disables the input and suppresses interactions. The input stays focusable and exposes `aria-disabled`.',
148
149
  default: 'false',
149
150
  },
150
151
  {
151
152
  name: 'disabledMessage',
152
153
  type: 'string',
153
154
  description:
154
- 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the field focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled TimeInput in Tooltip. Disabled controls swallow the hover events an external Tooltip needs.',
155
+ 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and describes the input (activation stays blocked). Use this instead of wrapping a disabled TimeInput in Tooltip.',
155
156
  },
156
157
  {
157
158
  name: 'value',
@@ -329,14 +330,14 @@ export const docsZh = {
329
330
  {
330
331
  name: 'isDisabled',
331
332
  type: 'boolean',
332
- description: '禁用输入框并抑制交互。',
333
+ description: '禁用输入框并抑制交互。输入框仍可聚焦并暴露 `aria-disabled`。',
333
334
  default: 'false',
334
335
  },
335
336
  {
336
337
  name: 'disabledMessage',
337
338
  type: 'string',
338
339
  description:
339
- '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并通过 aria-disabled 保持输入框可聚焦(仍阻止输入和调整)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TimeInput。',
340
+ '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并描述输入框(仍阻止输入和调整)。请使用此属性,而不是用 Tooltip 包裹已禁用的 TimeInput。',
340
341
  },
341
342
  {
342
343
  name: 'value',
@@ -510,7 +511,7 @@ export const docsZh = {
510
511
  {
511
512
  guidance: false,
512
513
  description:
513
- 'Wrap a disabled TimeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
514
+ 'Wrap a disabled TimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
514
515
  },
515
516
  ],
516
517
  anatomy: [
@@ -612,7 +613,7 @@ export const docsDense = {
612
613
  'Marks required+sets aria-required. Mutually exclusive w/ isOptional.',
613
614
  isDisabled: 'Disables input, suppresses interactions.',
614
615
  disabledMessage:
615
- 'Reason shown in a tooltip on hover/focus when disabled; keeps input focusable via aria-disabled.',
616
+ 'Reason shown in a tooltip on hover/focus when disabled; describes the input.',
616
617
  value: 'Controlled time in ISO format (HH:MM or HH:MM:SS).',
617
618
  onChange: 'Fired on time change. Receives undefined when cleared.',
618
619
  changeAction:
@@ -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';
@@ -141,9 +143,41 @@ describe('TimeInput', () => {
141
143
  );
142
144
  });
143
145
 
144
- it('disables input when isDisabled is true', () => {
146
+ it('keeps a disabled input focusable without native disabled (AST-085 FR1)', () => {
145
147
  render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
146
- expect(screen.getByRole('textbox')).toBeDisabled();
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('__'));
176
+ render(<TimeInput label="Time" isDisabled onChange={() => {}} />);
177
+ const wrapper = screen.getByRole('textbox').closest('.astryx-time-input')!;
178
+ for (const name of ringClasses) {
179
+ expect(wrapper).toHaveClass(name);
180
+ }
147
181
  });
148
182
 
149
183
  it('shows clear button when hasClear is true and value exists', () => {
@@ -645,11 +679,89 @@ describe('TimeInput', () => {
645
679
  expect(onChange).not.toHaveBeenCalled();
646
680
  });
647
681
 
648
- it('remains natively disabled when disabled without a reason', () => {
649
- 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
+ );
650
756
  const input = screen.getByLabelText('Time');
651
- expect(input).toBeDisabled();
652
- 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();
653
765
  });
654
766
 
655
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
  * ```
@@ -479,10 +491,10 @@ function TimeField({
479
491
  }
480
492
  }, [announce, inputGroup, status?.message, status?.type]);
481
493
 
482
- // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
483
- // tooltip listeners attach to the input container (which already exists) and
484
- // the input stays perceivable via aria-disabled instead of the disabled
485
- // 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.
486
498
  const showsDisabledMessage = isDisabled && !!disabledMessage;
487
499
  const disabledMessageTooltip = useTooltip({
488
500
  placement: 'above',
@@ -577,8 +589,8 @@ function TimeField({
577
589
  // Handle input text change - update immediately if valid
578
590
  const handleInputChange = useCallback(
579
591
  (e: React.ChangeEvent<HTMLInputElement>) => {
580
- // With a disabledMessage the input drops `disabled` for focusability, so
581
- // 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).
582
594
  if (isDisabled) {
583
595
  return;
584
596
  }
@@ -613,6 +625,11 @@ function TimeField({
613
625
  if (pendingInput === null) {
614
626
  return;
615
627
  }
628
+ // Disabled: keep the value, drop the draft (spec:AST-085 FR2).
629
+ if (isDisabled) {
630
+ setPendingInput(null);
631
+ return;
632
+ }
616
633
 
617
634
  if (!pendingInput.trim()) {
618
635
  // Empty input clears the value
@@ -633,10 +650,17 @@ function TimeField({
633
650
  // Clear pending input - display will revert to formatted value
634
651
  setPendingInput(null);
635
652
  },
636
- [pendingInput, value, fireChange, hasSeconds, min, max],
653
+ [pendingInput, isDisabled, value, fireChange, hasSeconds, min, max],
637
654
  );
638
655
 
639
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
+
640
664
  const handleInputKeyDown = useCallback(
641
665
  (e: KeyboardEvent<HTMLInputElement>) => {
642
666
  // ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
@@ -646,8 +670,7 @@ function TimeField({
646
670
  if (isImeKeyEvent(e.nativeEvent)) {
647
671
  return;
648
672
  }
649
- // Arrow-key adjustment mutates the value; block it while showing a
650
- // disabled reason (the input keeps focusability via aria-disabled).
673
+ // Arrow-key adjustment mutates the value; block it while disabled.
651
674
  if (isDisabled) {
652
675
  return;
653
676
  }
@@ -744,6 +767,7 @@ function TimeField({
744
767
  inputWrapperStyles.base,
745
768
  sizeStyles[size],
746
769
  isDisabled && inputWrapperStyles.disabled,
770
+ isDisabled && styles.disabledFocusRing,
747
771
  status && inputStatusBorderStyles[status.type],
748
772
  status && !isDisabled && inputStatusHoverShadowStyles[status.type],
749
773
  status && inputStatusFocusWithinStyles[status.type],
@@ -778,7 +802,7 @@ function TimeField({
778
802
  max={max}
779
803
  hourFormat={hourFormat}
780
804
  isEffectivelyDisabled={isDisabled}
781
- hasDisabledMessage={showsDisabledMessage}
805
+ isFocusableDisabled
782
806
  isEffectivelyRequired={isEffectivelyRequired}
783
807
  isBusy={isBusy}
784
808
  statusType={status?.type}
@@ -787,7 +811,8 @@ function TimeField({
787
811
  ...controlProps,
788
812
  onFocus: onFocusProp,
789
813
  onBlur: onBlurProp,
790
- onKeyDown: onKeyDownProp,
814
+ onKeyDown: chainEventHandlers(preventDisabledEnter, onKeyDownProp),
815
+ onClick: isDisabled ? undefined : controlProps.onClick,
791
816
  }}
792
817
  />
793
818
  ) : (
@@ -804,14 +829,17 @@ function TimeField({
804
829
  onChange={handleInputChange}
805
830
  onFocus={chainEventHandlers(handleFocus, onFocusProp)}
806
831
  onBlur={chainEventHandlers(handleBlur, onBlurProp)}
807
- onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
832
+ onKeyDown={chainEventHandlers(
833
+ preventDisabledEnter,
834
+ handleInputKeyDown,
835
+ onKeyDownProp,
836
+ )}
808
837
  placeholder={displayPlaceholder}
809
- // With a disabledMessage the input keeps focusability via
810
- // aria-disabled so the reason is focus-discoverable; typing and
811
- // arrow-key adjustment are blocked with readOnly and the guards.
812
- disabled={isDisabled && !showsDisabledMessage}
813
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
814
- 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}
815
843
  autoFocus={hasAutoFocus}
816
844
  data-autofocus={hasAutoFocus || undefined}
817
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
+ });