@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
@@ -97,6 +97,18 @@ export type {
97
97
  } from '../Field';
98
98
 
99
99
  const styles = stylex.create({
100
+ // The shared disabled style drops the focus ring; restore it while a
101
+ // control inside the field holds focus (spec:AST-085 FR6). Defaults repeat it.
102
+ disabledFocusRing: {
103
+ borderColor: {
104
+ default: colorVars['--color-border-emphasized'],
105
+ ':focus-within': colorVars['--color-accent'],
106
+ },
107
+ boxShadow: {
108
+ default: 'none',
109
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
110
+ },
111
+ },
100
112
  trigger: {
101
113
  display: 'block',
102
114
  flex: 1,
@@ -309,12 +321,12 @@ export interface DateRangeInputProps extends Omit<
309
321
  /**
310
322
  * Explains why the input is disabled. When set together with
311
323
  * `isDisabled`, the input shows a tooltip with this text on hover and
312
- * keyboard focus, and the trigger stays focusable (via `aria-disabled`)
313
- * so the reason is discoverable by keyboard and assistive technology.
314
- * Activation stays blocked.
324
+ * keyboard focus, and the trigger is described by it, so the reason is
325
+ * discoverable by keyboard and assistive technology. Activation stays
326
+ * blocked.
315
327
  *
316
- * Use this instead of wrapping a disabled input in `Tooltip` — disabled
317
- * controls don't emit the pointer events an external tooltip needs.
328
+ * Use this instead of wrapping a disabled input in `Tooltip`, so the
329
+ * reason is tied to the field.
318
330
  *
319
331
  * @example
320
332
  * ```
@@ -546,12 +558,12 @@ export function DateRangeInput({
546
558
  const isBusy = isLoading || optimisticValue !== value;
547
559
  const isEffectivelyDisabled = isDisabled || isBusy;
548
560
 
549
- // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
550
- // tooltip listeners attach to the trigger container (which already exists)
551
- // and the trigger button stays perceivable via aria-disabled instead of the
552
- // disabled attribute. Activation is blocked by the isEffectivelyDisabled
553
- // guard in handleToggle. Only the persistent isDisabled state (not the
554
- // transient busy state) surfaces a reason.
561
+ // A disabled or busy field never renders native `disabled` (spec:AST-085
562
+ // FR1): its buttons stay focusable via aria-disabled, and activation is
563
+ // blocked by the isEffectivelyDisabled guard in handleToggle. The
564
+ // disabled-reason tooltip's listeners attach to the trigger container. Only
565
+ // the persistent isDisabled state (not the transient busy state) surfaces a
566
+ // reason.
555
567
  const showsDisabledMessage = isDisabled && !!disabledMessage;
556
568
  const disabledMessageTooltip = useTooltip({
557
569
  placement: 'above',
@@ -695,6 +707,7 @@ export function DateRangeInput({
695
707
  inputWrapperStyles.base,
696
708
  sizeStyles[size],
697
709
  isEffectivelyDisabled && inputWrapperStyles.disabled,
710
+ isEffectivelyDisabled && styles.disabledFocusRing,
698
711
  status && inputStatusBorderStyles[status.type],
699
712
  status &&
700
713
  !isEffectivelyDisabled &&
@@ -708,7 +721,7 @@ export function DateRangeInput({
708
721
  <button
709
722
  type="button"
710
723
  onClick={handleToggle}
711
- disabled={isEffectivelyDisabled}
724
+ aria-disabled={isEffectivelyDisabled || undefined}
712
725
  aria-label={
713
726
  popover.isOpen
714
727
  ? t('@astryx.dateInput.toggleCalendarClose')
@@ -734,12 +747,14 @@ export function DateRangeInput({
734
747
  ref={triggerButtonRef}
735
748
  id={id}
736
749
  type="button"
737
- onClick={chainEventHandlers(handleToggle, onClickProp)}
738
- // With a disabledMessage the trigger keeps focusability via
739
- // aria-disabled so the reason is focus-discoverable; activation is
740
- // still blocked by the isEffectivelyDisabled guard in handleToggle.
741
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
742
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
750
+ // The browser still dispatches a click on an aria-disabled button,
751
+ // so a caller's onClick is skipped too (spec:AST-085 FR2).
752
+ onClick={
753
+ isEffectivelyDisabled
754
+ ? undefined
755
+ : chainEventHandlers(handleToggle, onClickProp)
756
+ }
757
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
743
758
  // A caller aria-label replaces the trigger's name; a caller
744
759
  // aria-labelledby is read after it (label and selected range).
745
760
  aria-label={ariaLabelProp ?? triggerAriaLabel}
@@ -807,8 +822,13 @@ export function DateRangeInput({
807
822
  // marked with aria-current (not aria-selected, a listbox
808
823
  // concept that contradicted the Tab interaction) (forms-5).
809
824
  aria-current={isActive ? 'true' : undefined}
810
- disabled={isPresetDisabled}
811
- onClick={() => handleRangeSelect(presetRange)}
825
+ // Focusable while disabled (AR3, spec:AST-085 FR1).
826
+ aria-disabled={isPresetDisabled || undefined}
827
+ onClick={() => {
828
+ if (!isPresetDisabled) {
829
+ handleRangeSelect(presetRange);
830
+ }
831
+ }}
812
832
  {...mergeProps(
813
833
  themeProps('date-range-input-preset', {
814
834
  selected: isActive ? 'selected' : null,
@@ -51,14 +51,15 @@ export const docs = {
51
51
  {
52
52
  name: 'isDisabled',
53
53
  type: 'boolean',
54
- description: 'Disable the input and picker.',
54
+ description:
55
+ 'Disable the input and picker. The fields stay focusable and expose `aria-disabled`; typing and opening are blocked.',
55
56
  default: 'false',
56
57
  },
57
58
  {
58
59
  name: 'disabledMessage',
59
60
  type: 'string',
60
61
  description:
61
- '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 DateTimeInput in Tooltip. Disabled controls swallow the hover events an external Tooltip needs.',
62
+ 'Explains why the input is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and describes the fields (activation stays blocked). Use this instead of wrapping a disabled DateTimeInput in Tooltip.',
62
63
  },
63
64
  {
64
65
  name: 'value',
@@ -289,7 +290,7 @@ export const docs = {
289
290
  {
290
291
  guidance: false,
291
292
  description:
292
- 'Wrap a disabled DateTimeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
293
+ 'Wrap a disabled DateTimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
293
294
  },
294
295
  ],
295
296
  anatomy: [
@@ -412,7 +413,7 @@ export const docsZh = {
412
413
  {
413
414
  guidance: false,
414
415
  description:
415
- 'Wrap a disabled DateTimeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
416
+ 'Wrap a disabled DateTimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
416
417
  },
417
418
  ],
418
419
  },
@@ -444,14 +445,14 @@ export const docsZh = {
444
445
  {
445
446
  name: 'isDisabled',
446
447
  type: 'boolean',
447
- description: '禁用输入框和选择器。',
448
+ description: '禁用输入框和选择器。字段仍可聚焦并暴露 `aria-disabled`,但不能输入或打开选择器。',
448
449
  default: 'false',
449
450
  },
450
451
  {
451
452
  name: 'disabledMessage',
452
453
  type: 'string',
453
454
  description:
454
- '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并通过 aria-disabled 保持日期和时间字段可聚焦(仍阻止输入和激活)。请使用此属性,而不是用 Tooltip 包裹已禁用的 DateTimeInput。',
455
+ '说明输入框为何被禁用。与 isDisabled 一起使用时,在悬停/键盘聚焦时显示提示,并描述日期和时间字段(仍阻止输入和激活)。请使用此属性,而不是用 Tooltip 包裹已禁用的 DateTimeInput。',
455
456
  },
456
457
  {
457
458
  name: 'value',
@@ -667,7 +668,7 @@ export const docsDense = {
667
668
  {
668
669
  guidance: false,
669
670
  description:
670
- 'Wrap a disabled DateTimeInput in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.',
671
+ 'Wrap a disabled DateTimeInput in Tooltip to explain why it is disabled. Use the disabledMessage prop instead, which ties the reason to the field.',
671
672
  },
672
673
  ],
673
674
  },
@@ -679,7 +680,7 @@ export const docsDense = {
679
680
  isRequired: 'mark field required',
680
681
  isDisabled: 'disable input+picker',
681
682
  disabledMessage:
682
- 'reason shown in a tooltip on hover/focus when disabled; keeps fields focusable via aria-disabled',
683
+ 'reason shown in a tooltip on hover/focus when disabled; describes the fields',
683
684
  value: 'selected datetime ISO 8601',
684
685
  onChange: 'callback on datetime change',
685
686
  changeAction: 'async action after onChange; drives optimistic UI',
@@ -14,6 +14,7 @@ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
14
14
  import {render, screen, fireEvent, waitFor} from '@testing-library/react';
15
15
  import userEvent from '@testing-library/user-event';
16
16
  import * as stylex from '@stylexjs/stylex';
17
+ import {colorVars} from '../theme/tokens.stylex';
17
18
  import {getButton, queryButton} from '../__tests__/fastRoleQueries';
18
19
  import {DateTimeInput} from './DateTimeInput';
19
20
  import type {ISODateTimeString} from './DateTimeInput';
@@ -288,10 +289,52 @@ describe('DateTimeInput', () => {
288
289
  expect(screen.getByRole('combobox')).not.toHaveAttribute('aria-required');
289
290
  });
290
291
 
291
- it('sets disabled on both inputs when isDisabled is true', () => {
292
+ it('keeps both disabled inputs focusable without native disabled (AST-085 FR1)', () => {
292
293
  render(<DateTimeInput label="Meeting" isDisabled onChange={() => {}} />);
293
- expect(screen.getByRole('combobox')).toBeDisabled();
294
- expect(screen.getByLabelText('Meeting time')).toBeDisabled();
294
+ for (const input of [
295
+ screen.getByRole('combobox'),
296
+ screen.getByLabelText('Meeting time'),
297
+ ]) {
298
+ expect(input).not.toHaveAttribute('disabled');
299
+ expect(input).toHaveAttribute('aria-disabled', 'true');
300
+ expect(input).toHaveAttribute('readonly');
301
+ input.focus();
302
+ expect(input).toHaveFocus();
303
+ }
304
+ });
305
+
306
+ it('restores the focus ring on a focused disabled field (AST-085 FR6)', () => {
307
+ // The same declarations as the field's ring style compile to the same
308
+ // atomic classes; the shared disabled style alone drops them.
309
+ const ringClasses = stylex
310
+ .props(
311
+ stylex.create({
312
+ ring: {
313
+ borderColor: {
314
+ default: colorVars['--color-border-emphasized'],
315
+ ':focus-within': colorVars['--color-accent'],
316
+ },
317
+ boxShadow: {
318
+ default: 'none',
319
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
320
+ },
321
+ },
322
+ }).ring,
323
+ )
324
+ .className!.split(' ')
325
+ .filter(name => !name.includes('__'));
326
+ render(<DateTimeInput label="Meeting" isDisabled onChange={() => {}} />);
327
+ for (const input of [
328
+ screen.getByRole('combobox'),
329
+ screen.getByLabelText('Meeting time'),
330
+ ]) {
331
+ const wrapper = input.closest(
332
+ '.astryx-date-time-input-date-segment, .astryx-date-time-input-time-segment',
333
+ )!;
334
+ for (const name of ringClasses) {
335
+ expect(wrapper).toHaveClass(name);
336
+ }
337
+ }
295
338
  });
296
339
 
297
340
  it('is not disabled by default', () => {
@@ -366,14 +409,25 @@ describe('DateTimeInput', () => {
366
409
  it('calendar button is disabled when isDisabled is true', () => {
367
410
  render(<DateTimeInput label="Meeting" isDisabled onChange={() => {}} />);
368
411
  const button = getButton('Open calendar');
369
- expect(button).toBeDisabled();
412
+ expect(button).not.toHaveAttribute('disabled');
413
+ expect(button).toHaveAttribute('aria-disabled', 'true');
414
+ fireEvent.click(button);
415
+ expect(screen.getByRole('combobox')).toHaveAttribute(
416
+ 'aria-expanded',
417
+ 'false',
418
+ );
370
419
  });
371
420
 
372
421
  it('disables inputs and button when isLoading is true', () => {
373
422
  render(<DateTimeInput label="Meeting" isLoading onChange={() => {}} />);
374
- expect(screen.getByRole('combobox')).toBeDisabled();
375
- expect(screen.getByLabelText('Meeting time')).toBeDisabled();
376
- expect(getButton('Open calendar')).toBeDisabled();
423
+ for (const el of [
424
+ screen.getByRole('combobox'),
425
+ screen.getByLabelText('Meeting time'),
426
+ getButton('Open calendar'),
427
+ ]) {
428
+ expect(el).not.toHaveAttribute('disabled');
429
+ expect(el).toHaveAttribute('aria-disabled', 'true');
430
+ }
377
431
  });
378
432
 
379
433
  it('sets aria-busy when isLoading is true', () => {
@@ -872,6 +926,12 @@ describe('DateTimeInput', () => {
872
926
  );
873
927
 
874
928
  const tooltip = screen.getByRole('tooltip', h);
929
+ // The calendar button keeps its enabled tab stop (spec:AST-085 FR1).
930
+ await user.tab();
931
+ expect(getButton('Open calendar')).toHaveFocus();
932
+ expect(
933
+ getButton('Open calendar').getAttribute('aria-describedby'),
934
+ ).toContain(tooltip.id);
875
935
  await user.tab();
876
936
  expect(screen.getByRole('combobox')).toHaveFocus();
877
937
  await waitFor(() => {
@@ -948,11 +1008,48 @@ describe('DateTimeInput', () => {
948
1008
  expect(onChange).not.toHaveBeenCalled();
949
1009
  });
950
1010
 
951
- it('remains natively disabled when disabled without a reason', () => {
952
- render(<DateTimeInput label="When" onChange={() => {}} isDisabled />);
1011
+ it('blocks typing, opening and a caller onClick when disabled without a reason (AST-085 FR2)', async () => {
1012
+ const user = userEvent.setup();
1013
+ const onChange = vi.fn();
1014
+ const onClick = vi.fn();
1015
+ render(
1016
+ <DateTimeInput
1017
+ label="When"
1018
+ onChange={onChange}
1019
+ onClick={onClick}
1020
+ isDisabled
1021
+ />,
1022
+ );
1023
+ const dateInput = screen.getByRole('combobox');
1024
+ await user.click(dateInput);
1025
+ await user.type(dateInput, '2026-03-15');
1026
+ await user.keyboard('{ArrowDown}');
1027
+ expect(dateInput).toHaveValue('');
1028
+ expect(dateInput).toHaveAttribute('aria-expanded', 'false');
1029
+ expect(onChange).not.toHaveBeenCalled();
1030
+ expect(onClick).not.toHaveBeenCalled();
1031
+ });
1032
+
1033
+ it('drops a date draft instead of committing it when the field becomes disabled (AST-085 FR2)', async () => {
1034
+ const user = userEvent.setup();
1035
+ const onChange = vi.fn();
1036
+ const value = '2026-03-10T09:00' as ISODateTimeString;
1037
+ const {rerender} = render(
1038
+ <DateTimeInput label="When" value={value} onChange={onChange} />,
1039
+ );
953
1040
  const dateInput = screen.getByRole('combobox');
954
- expect(dateInput).toBeDisabled();
955
- expect(dateInput).not.toHaveAttribute('aria-disabled');
1041
+ await user.click(dateInput);
1042
+ await user.clear(dateInput);
1043
+ rerender(
1044
+ <DateTimeInput
1045
+ label="When"
1046
+ value={value}
1047
+ onChange={onChange}
1048
+ isDisabled
1049
+ />,
1050
+ );
1051
+ await user.tab();
1052
+ expect(onChange).not.toHaveBeenCalled();
956
1053
  });
957
1054
 
958
1055
  it('does not swap in the time format-hint placeholder on focus while disabled', () => {
@@ -1030,6 +1127,24 @@ describe('DateTimeInput', () => {
1030
1127
  });
1031
1128
  });
1032
1129
 
1130
+ it('announces no step from a disabled time field (AST-085 FR2)', async () => {
1131
+ render(
1132
+ <DateTimeInput
1133
+ label="Meeting"
1134
+ value={'2026-03-15T14:30' as ISODateTimeString}
1135
+ onChange={() => {}}
1136
+ isDisabled
1137
+ />,
1138
+ );
1139
+
1140
+ fireEvent.keyDown(screen.getByLabelText('Meeting time'), {
1141
+ key: 'ArrowUp',
1142
+ });
1143
+
1144
+ await new Promise(resolve => setTimeout(resolve, 50));
1145
+ expect(politeRegion()?.textContent ?? '').not.toContain('2:31 PM');
1146
+ });
1147
+
1033
1148
  it('politely announces the new time after ArrowDown stepping', async () => {
1034
1149
  render(
1035
1150
  <DateTimeInput
@@ -146,6 +146,18 @@ export type {
146
146
  const HORIZONTAL_SEGMENT_BASIS = 196;
147
147
 
148
148
  const styles = stylex.create({
149
+ // The shared disabled style drops the focus ring; restore it while a
150
+ // control inside the field holds focus (spec:AST-085 FR6). Defaults repeat it.
151
+ disabledFocusRing: {
152
+ borderColor: {
153
+ default: colorVars['--color-border-emphasized'],
154
+ ':focus-within': colorVars['--color-accent'],
155
+ },
156
+ boxShadow: {
157
+ default: 'none',
158
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
159
+ },
160
+ },
149
161
  row: {
150
162
  display: 'flex',
151
163
  flexWrap: 'wrap',
@@ -332,12 +344,12 @@ export interface DateTimeInputProps extends Omit<
332
344
  /**
333
345
  * Explains why the input is disabled. When set together with
334
346
  * `isDisabled`, the input shows a tooltip with this text on hover and
335
- * keyboard focus, and the date and time fields stay focusable (via
336
- * `aria-disabled`) so the reason is discoverable by keyboard and assistive
337
- * technology. Typing and calendar activation stay blocked.
347
+ * keyboard focus, and the date and time fields are described by it, so the
348
+ * reason is discoverable by keyboard and assistive technology. Typing and
349
+ * calendar activation stay blocked.
338
350
  *
339
- * Use this instead of wrapping a disabled input in `Tooltip` — disabled
340
- * controls don't emit the pointer events an external tooltip needs.
351
+ * Use this instead of wrapping a disabled input in `Tooltip`, so the
352
+ * reason is tied to the field.
341
353
  *
342
354
  * @example
343
355
  * ```
@@ -1032,7 +1044,7 @@ function PointerDateTimeField({
1032
1044
 
1033
1045
  const handleDateInputChange = useCallback(
1034
1046
  (e: React.ChangeEvent<HTMLInputElement>) => {
1035
- // With a disabledMessage the input drops `disabled` for focusability, so
1047
+ // The input is never natively disabled (AST-085 FR1), so
1036
1048
  // guard value mutation explicitly (readOnly also blocks typing).
1037
1049
  if (isEffectivelyDisabled) {
1038
1050
  return;
@@ -1338,7 +1350,7 @@ function PointerDateTimeField({
1338
1350
  // --- Time handlers ---
1339
1351
  const handleTimeInputChange = useCallback(
1340
1352
  (e: React.ChangeEvent<HTMLInputElement>) => {
1341
- // With a disabledMessage the input drops `disabled` for focusability, so
1353
+ // The input is never natively disabled (AST-085 FR1), so
1342
1354
  // guard value mutation explicitly (readOnly also blocks typing).
1343
1355
  if (isEffectivelyDisabled) {
1344
1356
  return;
@@ -1452,7 +1464,8 @@ function PointerDateTimeField({
1452
1464
  // window uses those same arrows to navigate candidates, so guard the
1453
1465
  // composing keydown (fires before compositionend) to avoid stealing them
1454
1466
  // mid-composition. See utils/ime.ts.
1455
- if (isImeKeyEvent(e.nativeEvent)) {
1467
+ // A disabled or busy field steps nothing, so it announces nothing (FR2).
1468
+ if (isImeKeyEvent(e.nativeEvent) || isEffectivelyDisabled) {
1456
1469
  return;
1457
1470
  }
1458
1471
 
@@ -1664,6 +1677,7 @@ function PointerDateTimeField({
1664
1677
  sizeStyles[size],
1665
1678
  styles.dateWrapper,
1666
1679
  isEffectivelyDisabled && inputWrapperStyles.disabled,
1680
+ isEffectivelyDisabled && styles.disabledFocusRing,
1667
1681
  status && inputStatusBorderStyles[status.type],
1668
1682
  status &&
1669
1683
  !isEffectivelyDisabled &&
@@ -1682,7 +1696,7 @@ function PointerDateTimeField({
1682
1696
  max={calendarMax}
1683
1697
  isDateDisabled={isDateDisabled}
1684
1698
  isEffectivelyDisabled={isEffectivelyDisabled}
1685
- hasDisabledMessage={showsDisabledMessage}
1699
+ isFocusableDisabled
1686
1700
  isEffectivelyRequired={isEffectivelyRequired}
1687
1701
  isBusy={isBusy}
1688
1702
  statusType={status?.type}
@@ -1691,7 +1705,7 @@ function PointerDateTimeField({
1691
1705
  ...controlProps,
1692
1706
  'aria-labelledby': controlLabelledBy,
1693
1707
  onBlur: onBlurProp,
1694
- onClick: onClickProp,
1708
+ onClick: isEffectivelyDisabled ? undefined : onClickProp,
1695
1709
  onKeyDown: onKeyDownProp,
1696
1710
  }}
1697
1711
  />
@@ -1700,7 +1714,13 @@ function PointerDateTimeField({
1700
1714
  <button
1701
1715
  type="button"
1702
1716
  onClick={handleCalendarToggle}
1703
- disabled={isEffectivelyDisabled}
1717
+ aria-disabled={isEffectivelyDisabled || undefined}
1718
+ // A tab stop while disabled too, so it carries the reason (FR5).
1719
+ aria-describedby={
1720
+ showsDisabledMessage
1721
+ ? disabledMessageTooltip.describedBy
1722
+ : undefined
1723
+ }
1704
1724
  aria-label={
1705
1725
  popover.isOpen
1706
1726
  ? t('@astryx.dateInput.toggleCalendarClose')
@@ -1735,17 +1755,16 @@ function PointerDateTimeField({
1735
1755
  value={dateDisplayValue}
1736
1756
  onChange={handleDateInputChange}
1737
1757
  onBlur={chainEventHandlers(handleDateBlur, onBlurProp)}
1738
- onClick={chainEventHandlers(handleDateInputClick, onClickProp)}
1758
+ onClick={
1759
+ isEffectivelyDisabled
1760
+ ? undefined
1761
+ : chainEventHandlers(handleDateInputClick, onClickProp)
1762
+ }
1739
1763
  onKeyDown={chainEventHandlers(handleDateKeyDown, onKeyDownProp)}
1740
1764
  aria-labelledby={controlLabelledBy}
1741
1765
  placeholder={placeholder}
1742
- // With a disabledMessage the input keeps focusability via
1743
- // aria-disabled so the reason is focus-discoverable; typing is
1744
- // blocked with readOnly and the mutation guards, and calendar
1745
- // activation is blocked by the isEffectivelyDisabled guards.
1746
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
1747
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
1748
- readOnly={showsDisabledMessage || undefined}
1766
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
1767
+ readOnly={isEffectivelyDisabled || undefined}
1749
1768
  aria-describedby={ariaDescribedBy}
1750
1769
  aria-required={isEffectivelyRequired ? 'true' : undefined}
1751
1770
  aria-invalid={
@@ -1807,6 +1826,7 @@ function PointerDateTimeField({
1807
1826
  sizeStyles[size],
1808
1827
  styles.timeWrapper,
1809
1828
  isEffectivelyDisabled && inputWrapperStyles.disabled,
1829
+ isEffectivelyDisabled && styles.disabledFocusRing,
1810
1830
  status && inputStatusBorderStyles[status.type],
1811
1831
  status &&
1812
1832
  !isEffectivelyDisabled &&
@@ -1830,7 +1850,7 @@ function PointerDateTimeField({
1830
1850
  max={timeMax}
1831
1851
  hourFormat={hourFormat}
1832
1852
  isEffectivelyDisabled={isEffectivelyDisabled}
1833
- hasDisabledMessage={showsDisabledMessage}
1853
+ isFocusableDisabled
1834
1854
  isEffectivelyRequired={isEffectivelyRequired}
1835
1855
  isBusy={isBusy}
1836
1856
  statusType={status?.type}
@@ -1882,12 +1902,8 @@ function PointerDateTimeField({
1882
1902
  ? timeOptionId(activeTimeIndex)
1883
1903
  : undefined
1884
1904
  }
1885
- // With a disabledMessage the input keeps focusability via
1886
- // aria-disabled so the reason is focus-discoverable; typing is
1887
- // blocked with readOnly and the mutation guards.
1888
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
1889
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
1890
- readOnly={showsDisabledMessage || undefined}
1905
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
1906
+ readOnly={isEffectivelyDisabled || undefined}
1891
1907
  aria-label={resolvedTimeLabel}
1892
1908
  aria-describedby={ariaDescribedBy}
1893
1909
  aria-required={isEffectivelyRequired ? 'true' : undefined}
@@ -987,6 +987,30 @@ describe('DateTimeInput touch surface', () => {
987
987
  });
988
988
  });
989
989
 
990
+ describe('DateTimeInput touch surface: disabled without a reason', () => {
991
+ it('stays focusable and inert, and skips a caller onClick (AST-085 FR1, FR2)', () => {
992
+ const onClick = vi.fn();
993
+ render(
994
+ <DateTimeInput
995
+ label="Meeting"
996
+ isDisabled
997
+ onClick={onClick}
998
+ onChange={() => {}}
999
+ />,
1000
+ );
1001
+ for (const field of [dateField(), timeField()]) {
1002
+ expect(field).not.toHaveAttribute('disabled');
1003
+ expect(field).toHaveAttribute('aria-disabled', 'true');
1004
+ field.focus();
1005
+ expect(field).toHaveFocus();
1006
+ fireEvent.click(field);
1007
+ fireEvent.keyDown(field, {key: 'Enter'});
1008
+ expect(field).toHaveAttribute('aria-expanded', 'false');
1009
+ }
1010
+ expect(onClick).not.toHaveBeenCalled();
1011
+ });
1012
+ });
1013
+
990
1014
  describe('DateTimeInput touch surface: no stale commit', () => {
991
1015
  // Records what each commit painted, so a value that is corrected one render
992
1016
  // later (by an effect) shows up as a stale intermediate frame.
@@ -60,7 +60,8 @@ export type NativeDateSegmentProps = {
60
60
  max?: ISODateString;
61
61
  isDateDisabled: (date: ReturnType<typeof plainDateFromISO>) => boolean;
62
62
  isEffectivelyDisabled: boolean;
63
- hasDisabledMessage: boolean;
63
+ /** While disabled, stay focusable: aria-disabled and readOnly, not `disabled`. */
64
+ isFocusableDisabled: boolean;
64
65
  isEffectivelyRequired: boolean;
65
66
  isBusy: boolean;
66
67
  statusType?: InputStatusType;
@@ -87,7 +88,7 @@ export function NativeDateSegment({
87
88
  max,
88
89
  isDateDisabled,
89
90
  isEffectivelyDisabled,
90
- hasDisabledMessage,
91
+ isFocusableDisabled,
91
92
  isEffectivelyRequired,
92
93
  isBusy,
93
94
  statusType,
@@ -262,7 +263,7 @@ export function NativeDateSegment({
262
263
  <button
263
264
  type="button"
264
265
  onClick={openPicker}
265
- disabled={isEffectivelyDisabled}
266
+ aria-disabled={isEffectivelyDisabled || undefined}
266
267
  aria-label={t('@astryx.dateInput.openCalendar')}
267
268
  tabIndex={-1}
268
269
  {...stylex.props(
@@ -290,9 +291,11 @@ export function NativeDateSegment({
290
291
  onBlur={chainEventHandlers(handleBlur, onBlurProp)}
291
292
  min={min}
292
293
  max={max}
293
- disabled={isEffectivelyDisabled && !hasDisabledMessage}
294
- aria-disabled={hasDisabledMessage ? 'true' : undefined}
295
- readOnly={hasDisabledMessage || undefined}
294
+ disabled={isEffectivelyDisabled && !isFocusableDisabled}
295
+ aria-disabled={
296
+ isEffectivelyDisabled && isFocusableDisabled ? 'true' : undefined
297
+ }
298
+ readOnly={(isEffectivelyDisabled && isFocusableDisabled) || undefined}
296
299
  aria-describedby={ariaDescribedBy}
297
300
  aria-required={isEffectivelyRequired ? 'true' : undefined}
298
301
  aria-invalid={
@@ -161,6 +161,37 @@ describe('DateTimeInput nativePicker', () => {
161
161
  expect(getNativeTimeInput()).toBeInTheDocument();
162
162
  });
163
163
 
164
+ it('keeps disabled native controls focusable and inert (AST-085 FR1, FR2)', () => {
165
+ stubPointer(true);
166
+ const onClick = vi.fn();
167
+ render(
168
+ <DateTimeInput
169
+ label="Meeting"
170
+ isDisabled
171
+ onClick={onClick}
172
+ onChange={() => {}}
173
+ />,
174
+ );
175
+ for (const input of [getNativeDateInput(), getNativeTimeInput()]) {
176
+ expect(input).not.toHaveAttribute('disabled');
177
+ expect(input).toHaveAttribute('aria-disabled', 'true');
178
+ expect(input).toHaveAttribute('readonly');
179
+ input.focus();
180
+ expect(input).toHaveFocus();
181
+ }
182
+ fireEvent.click(getNativeDateInput());
183
+ expect(onClick).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('leaves enabled native controls editable (AST-043)', () => {
187
+ stubPointer(true);
188
+ render(<DateTimeInput label="Meeting" onChange={() => {}} />);
189
+ for (const input of [getNativeDateInput(), getNativeTimeInput()]) {
190
+ expect(input).not.toHaveAttribute('readonly');
191
+ expect(input).not.toHaveAttribute('aria-disabled');
192
+ }
193
+ });
194
+
164
195
  it('drops in-page popup ARIA because the OS owns both pickers', () => {
165
196
  stubPointer(true);
166
197
  render(<DateTimeInput label="Meeting" onChange={() => {}} />);