@astryxdesign/core 0.7.2 → 0.7.3-canary.0bf23bd

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 (183) 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/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  6. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  7. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  8. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  9. package/dist/ClickableCard/ClickableCard.js +11 -9
  10. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  11. package/dist/CodeBlock/CodeBlock.js +11 -1
  12. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  13. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  14. package/dist/DateInput/DateInput.d.ts +4 -4
  15. package/dist/DateInput/DateInput.d.ts.map +1 -1
  16. package/dist/DateInput/DateInput.js +27 -20
  17. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  18. package/dist/DateInput/NativeDateField.js +14 -8
  19. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  20. package/dist/DateInput/TouchDateField.js +14 -10
  21. package/dist/Dialog/Dialog.d.ts.map +1 -1
  22. package/dist/Dialog/Dialog.js +9 -4
  23. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  26. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  31. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  32. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  33. package/dist/FileInput/FileInput.d.ts.map +1 -1
  34. package/dist/FileInput/FileInput.js +14 -12
  35. package/dist/Link/Link.d.ts +2 -3
  36. package/dist/Link/Link.d.ts.map +1 -1
  37. package/dist/Link/Link.js +11 -19
  38. package/dist/Markdown/Markdown.d.ts.map +1 -1
  39. package/dist/Markdown/Markdown.js +48 -15
  40. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  41. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  42. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  43. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  44. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  45. package/dist/MultiSelector/MultiSelector.js +25 -18
  46. package/dist/Popover/Popover.d.ts.map +1 -1
  47. package/dist/Popover/Popover.js +8 -4
  48. package/dist/Selector/Selector.d.ts +4 -4
  49. package/dist/Selector/Selector.d.ts.map +1 -1
  50. package/dist/Selector/Selector.js +23 -14
  51. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  52. package/dist/Selector/SelectorOption.js +1 -0
  53. package/dist/Slider/Slider.d.ts.map +1 -1
  54. package/dist/Slider/Slider.js +7 -14
  55. package/dist/Switch/Switch.d.ts.map +1 -1
  56. package/dist/Switch/Switch.js +25 -17
  57. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  58. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  59. package/dist/Switch/switch.markers.stylex.js +6 -0
  60. package/dist/TimeInput/TimeInput.d.ts +2 -1
  61. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  62. package/dist/Token/Token.d.ts.map +1 -1
  63. package/dist/Token/Token.js +8 -3
  64. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  65. package/dist/Tooltip/Tooltip.js +36 -14
  66. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  67. package/dist/Tooltip/useTooltip.js +11 -2
  68. package/dist/TopNav/TopNav.d.ts.map +1 -1
  69. package/dist/TopNav/TopNav.js +2 -2
  70. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  71. package/dist/TopNav/TopNavHeading.js +1 -0
  72. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  73. package/dist/Typeahead/Typeahead.js +14 -2
  74. package/dist/astryx.css +8 -2
  75. package/dist/hooks/useMenuPress.d.ts +11 -1
  76. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  77. package/dist/hooks/useMenuPress.js +43 -5
  78. package/dist/utils/disabledInteraction.d.ts +21 -0
  79. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  80. package/dist/utils/disabledInteraction.js +33 -0
  81. package/dist/utils/timeParser.d.ts +4 -0
  82. package/dist/utils/timeParser.d.ts.map +1 -1
  83. package/dist/utils/timeParser.js +9 -2
  84. package/package.json +4 -4
  85. package/scripts/agent-doc-state.mjs +35 -8
  86. package/src/Banner/Banner.spec.md +37 -32
  87. package/src/BottomSheet/BottomSheet.spec.md +13 -6
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +8 -4
  90. package/src/BottomSheet/BottomSheetSwitcher.spec.md +121 -51
  91. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  93. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  94. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  95. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  96. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  97. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  98. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  99. package/src/ClickableCard/ClickableCard.tsx +9 -9
  100. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  101. package/src/CodeBlock/CodeBlock.tsx +11 -1
  102. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  103. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  104. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  105. package/src/DateInput/DateInput.doc.mjs +9 -8
  106. package/src/DateInput/DateInput.test.tsx +88 -13
  107. package/src/DateInput/DateInput.tsx +49 -21
  108. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  109. package/src/DateInput/NativeDateField.test.tsx +29 -3
  110. package/src/DateInput/NativeDateField.tsx +20 -7
  111. package/src/DateInput/TouchDateField.tsx +24 -9
  112. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  113. package/src/Dialog/Dialog.test.tsx +39 -0
  114. package/src/Dialog/Dialog.tsx +9 -4
  115. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  116. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  117. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  118. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  119. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  120. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  121. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  122. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  123. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  124. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  125. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  126. package/src/FileInput/FileInput.test.tsx +30 -7
  127. package/src/FileInput/FileInput.tsx +21 -11
  128. package/src/Link/Link.test.tsx +21 -7
  129. package/src/Link/Link.tsx +16 -24
  130. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  131. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  132. package/src/Markdown/Markdown.tsx +89 -35
  133. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  134. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  135. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  136. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  137. package/src/MultiSelector/MultiSelector.tsx +53 -29
  138. package/src/Popover/Popover.test.tsx +15 -0
  139. package/src/Popover/Popover.tsx +19 -8
  140. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  141. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  142. package/src/Selector/Selector.doc.mjs +4 -4
  143. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  144. package/src/Selector/Selector.spec.md +11 -11
  145. package/src/Selector/Selector.test.tsx +64 -6
  146. package/src/Selector/Selector.tsx +51 -21
  147. package/src/Selector/SelectorOption.tsx +4 -0
  148. package/src/SideNav/SideNav.test.tsx +35 -0
  149. package/src/Slider/Slider.spec.md +11 -11
  150. package/src/Slider/Slider.test.tsx +3 -3
  151. package/src/Slider/Slider.tsx +8 -15
  152. package/src/Switch/Switch.test.tsx +85 -8
  153. package/src/Switch/Switch.tsx +26 -16
  154. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  155. package/src/Switch/switch.markers.stylex.ts +4 -0
  156. package/src/Table/Table.spec.md +2 -0
  157. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  158. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  159. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  160. package/src/TimeInput/TimeInput.doc.mjs +5 -3
  161. package/src/TimeInput/TimeInput.test.tsx +20 -0
  162. package/src/TimeInput/TimeInput.tsx +2 -1
  163. package/src/Token/Token.test.tsx +26 -4
  164. package/src/Token/Token.tsx +6 -2
  165. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  166. package/src/Tooltip/Tooltip.test.tsx +51 -6
  167. package/src/Tooltip/Tooltip.tsx +41 -18
  168. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  169. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  170. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  171. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  172. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  173. package/src/Tooltip/useTooltip.test.tsx +23 -0
  174. package/src/Tooltip/useTooltip.tsx +22 -9
  175. package/src/TopNav/TopNav.tsx +11 -1
  176. package/src/TopNav/TopNavHeading.tsx +1 -0
  177. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  178. package/src/Typeahead/Typeahead.test.tsx +89 -0
  179. package/src/Typeahead/Typeahead.tsx +15 -2
  180. package/src/hooks/useMenuPress.ts +45 -7
  181. package/src/utils/disabledInteraction.ts +40 -0
  182. package/src/utils/timeParser.test.ts +11 -0
  183. package/src/utils/timeParser.ts +9 -2
@@ -79,6 +79,18 @@ import {
79
79
  import {useDevWarning} from '../hooks/useDevWarning';
80
80
 
81
81
  const styles = stylex.create({
82
+ // The shared disabled style drops the focus ring; restore it while this
83
+ // field's own input holds focus (spec:AST-085 FR6). Defaults repeat it.
84
+ disabledFocusRing: {
85
+ borderColor: {
86
+ default: colorVars['--color-border-emphasized'],
87
+ ':has(input:focus)': colorVars['--color-accent'],
88
+ },
89
+ boxShadow: {
90
+ default: 'none',
91
+ ':has(input:focus)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
92
+ },
93
+ },
82
94
  iconButton: {
83
95
  display: 'flex',
84
96
  alignItems: 'center',
@@ -241,13 +253,13 @@ export interface DateInputProps extends Omit<
241
253
 
242
254
  /**
243
255
  * Explains why the input is disabled. When set together with
244
- * `isDisabled`, the input shows a tooltip with this text on hover and
245
- * keyboard focus, and the field stays focusable (via `aria-disabled`)
256
+ * `isDisabled`, the field shows a tooltip with this text on hover and
257
+ * keyboard focus, and the field's focusable controls are described by it,
246
258
  * so the reason is discoverable by keyboard and assistive technology.
247
259
  * Typing and calendar activation stay blocked.
248
260
  *
249
- * Use this instead of wrapping a disabled input in `Tooltip` — disabled
250
- * controls don't emit the pointer events an external tooltip needs.
261
+ * Use this instead of wrapping a disabled input in `Tooltip`, so the
262
+ * reason is tied to the field.
251
263
  *
252
264
  * @example
253
265
  * ```
@@ -537,11 +549,11 @@ function PointerDateField({
537
549
  const isBusy = isLoading || optimisticValue !== value;
538
550
  const isEffectivelyDisabled = isDisabled || isBusy;
539
551
 
540
- // Disabled-reason tooltip. Disabled controls swallow pointer events, so the
541
- // tooltip listeners attach to the input container (which already exists) and
542
- // the text input stays perceivable via aria-disabled instead of the disabled
543
- // attribute. Typing is blocked with readOnly and value mutation guards;
544
- // calendar activation is blocked by the isEffectivelyDisabled guards. Only
552
+ // A disabled or busy field never renders native `disabled` (spec:AST-085
553
+ // FR1): the input and calendar button stay focusable via aria-disabled.
554
+ // Typing is blocked with readOnly and value mutation guards; calendar
555
+ // activation is blocked by the isEffectivelyDisabled guards. The
556
+ // disabled-reason tooltip's listeners attach to the input container. Only
545
557
  // the persistent isDisabled state (not the transient busy state) surfaces a
546
558
  // reason.
547
559
  const showsDisabledMessage = isDisabled && !!disabledMessage;
@@ -703,8 +715,8 @@ function PointerDateField({
703
715
  // Handle input text change - update immediately if valid and allowed
704
716
  const handleInputChange = useCallback(
705
717
  (e: React.ChangeEvent<HTMLInputElement>) => {
706
- // With a disabledMessage the input drops `disabled` for focusability, so
707
- // guard value mutation explicitly (readOnly also blocks typing).
718
+ // The input is never natively disabled (spec:AST-085 FR1), so guard
719
+ // value mutation explicitly (readOnly also blocks typing).
708
720
  if (isEffectivelyDisabled) {
709
721
  return;
710
722
  }
@@ -733,6 +745,11 @@ function PointerDateField({
733
745
  if (pendingInput === null) {
734
746
  return;
735
747
  }
748
+ // Disabled or busy: keep the value, drop the draft (spec:AST-085 FR2).
749
+ if (isEffectivelyDisabled) {
750
+ setPendingInput(null);
751
+ return;
752
+ }
736
753
 
737
754
  if (!pendingInput.trim()) {
738
755
  if (value !== undefined) {
@@ -750,7 +767,14 @@ function PointerDateField({
750
767
  }
751
768
  }
752
769
  setPendingInput(null);
753
- }, [pendingInput, value, fireChange, isDateDisabled, locale]);
770
+ }, [
771
+ pendingInput,
772
+ isEffectivelyDisabled,
773
+ value,
774
+ fireChange,
775
+ isDateDisabled,
776
+ locale,
777
+ ]);
754
778
 
755
779
  // Handle blur - validate, check constraints, and clear pending input
756
780
  const handleBlur = useCallback(() => {
@@ -808,6 +832,7 @@ function PointerDateField({
808
832
  inputWrapperStyles.base,
809
833
  sizeStyles[size],
810
834
  isEffectivelyDisabled && inputWrapperStyles.disabled,
835
+ isEffectivelyDisabled && styles.disabledFocusRing,
811
836
  status && inputStatusBorderStyles[status.type],
812
837
  status &&
813
838
  !isEffectivelyDisabled &&
@@ -823,7 +848,11 @@ function PointerDateField({
823
848
  <button
824
849
  type="button"
825
850
  onClick={handleToggle}
826
- disabled={isEffectivelyDisabled}
851
+ aria-disabled={isEffectivelyDisabled || undefined}
852
+ // A tab stop while disabled too, so it carries the reason (FR5).
853
+ aria-describedby={
854
+ showsDisabledMessage ? disabledMessageTooltip.describedBy : undefined
855
+ }
827
856
  aria-label={
828
857
  popover.isOpen
829
858
  ? t('@astryx.dateInput.toggleCalendarClose')
@@ -858,16 +887,15 @@ function PointerDateField({
858
887
  value={displayValue}
859
888
  onChange={handleInputChange}
860
889
  onBlur={chainEventHandlers(handleBlur, onBlurProp)}
861
- onClick={chainEventHandlers(handleInputClick, onClickProp)}
890
+ onClick={
891
+ isEffectivelyDisabled
892
+ ? undefined
893
+ : chainEventHandlers(handleInputClick, onClickProp)
894
+ }
862
895
  onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
863
896
  placeholder={placeholder}
864
- // With a disabledMessage the input keeps focusability via
865
- // aria-disabled so the reason is focus-discoverable; typing is
866
- // blocked with readOnly and the mutation guards, and calendar
867
- // activation is blocked by the isEffectivelyDisabled guards.
868
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
869
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
870
- readOnly={showsDisabledMessage || undefined}
897
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
898
+ readOnly={isEffectivelyDisabled || undefined}
871
899
  aria-labelledby={
872
900
  ariaLabelledByProp != null
873
901
  ? joinAriaIDs(ariaLabelledBy ?? fieldLabelID, ariaLabelledByProp)
@@ -554,11 +554,32 @@ describe('DateInput — field parity', () => {
554
554
  onChange={() => {}}
555
555
  />,
556
556
  );
557
- expect(field()).toBeDisabled();
557
+ // Focusable, never natively disabled (AST-085 FR1), and still inert.
558
+ expect(field()).not.toHaveAttribute('disabled');
559
+ expect(field()).toHaveAttribute('aria-disabled', 'true');
560
+ field().focus();
561
+ expect(field()).toHaveFocus();
558
562
  fireEvent.click(field());
563
+ fireEvent.keyDown(field(), {key: 'Enter'});
564
+ expect(field()).toHaveAttribute('aria-expanded', 'false');
559
565
  expect(screen.queryByRole('grid')).not.toBeInTheDocument();
560
566
  });
561
567
 
568
+ it('does not call a caller onClick while disabled (AST-085 FR2)', () => {
569
+ const onClick = vi.fn();
570
+ render(
571
+ <DateInput
572
+ nativePicker="never"
573
+ label="Ship date"
574
+ isDisabled
575
+ onClick={onClick}
576
+ onChange={() => {}}
577
+ />,
578
+ );
579
+ fireEvent.click(field());
580
+ expect(onClick).not.toHaveBeenCalled();
581
+ });
582
+
562
583
  it('stays focusable and explains itself when disabled with a reason', () => {
563
584
  render(
564
585
  <DateInput
@@ -601,12 +601,38 @@ describe('DateInput nativePicker', () => {
601
601
  await act(async () => resolveAction());
602
602
  });
603
603
 
604
- it('disables the control and its toggle when isDisabled', () => {
604
+ it('keeps a disabled control focusable and its toggle inert (AST-085 FR1, FR2)', () => {
605
605
  stubPointer(true);
606
606
  render(<DateInput label="Date" isDisabled onChange={() => {}} />);
607
607
 
608
- expect(getInput()).toBeDisabled();
609
- expect(screen.getByLabelText('Open calendar')).toBeDisabled();
608
+ const input = getInput();
609
+ expect(input).not.toHaveAttribute('disabled');
610
+ expect(input).toHaveAttribute('aria-disabled', 'true');
611
+ expect(input).toHaveAttribute('readonly');
612
+ input.focus();
613
+ expect(input).toHaveFocus();
614
+ const toggle = screen.getByLabelText('Open calendar');
615
+ expect(toggle).not.toHaveAttribute('disabled');
616
+ expect(toggle).toHaveAttribute('aria-disabled', 'true');
617
+ const showPicker = vi.fn();
618
+ Object.defineProperty(input, 'showPicker', {value: showPicker});
619
+ fireEvent.click(toggle);
620
+ expect(showPicker).not.toHaveBeenCalled();
621
+ });
622
+
623
+ it('does not call a caller onClick while disabled (AST-085 FR2)', () => {
624
+ stubPointer(true);
625
+ const onClick = vi.fn();
626
+ render(
627
+ <DateInput
628
+ label="Date"
629
+ isDisabled
630
+ onClick={onClick}
631
+ onChange={() => {}}
632
+ />,
633
+ );
634
+ fireEvent.click(getInput());
635
+ expect(onClick).not.toHaveBeenCalled();
610
636
  });
611
637
 
612
638
  it('ignores a change while disabled', () => {
@@ -82,6 +82,17 @@ import {
82
82
  import {chainEventHandlers} from '../utils/chainEventHandlers';
83
83
 
84
84
  const styles = stylex.create({
85
+ // Same as DateInput's: the focus ring for a focused disabled field.
86
+ disabledFocusRing: {
87
+ borderColor: {
88
+ default: colorVars['--color-border-emphasized'],
89
+ ':has(input:focus)': colorVars['--color-accent'],
90
+ },
91
+ boxShadow: {
92
+ default: 'none',
93
+ ':has(input:focus)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
94
+ },
95
+ },
85
96
  wrapper: {
86
97
  gap: spacingVars['--spacing-2'],
87
98
  },
@@ -323,9 +334,10 @@ export function NativeDateField({
323
334
  // platform picker from its input.
324
335
  const isEffectivelyDisabled = isDisabled || isLoading;
325
336
 
326
- // Disabled-reason tooltip, same contract as the other two surfaces: a
327
- // disabled control swallows pointer events, so the listeners attach to the
328
- // wrapper and the input stays focusable via aria-disabled.
337
+ // Same contract as the other two surfaces: a disabled field never renders
338
+ // native `disabled` (spec:AST-085 FR1); the input stays focusable via
339
+ // aria-disabled and readOnly. The disabled-reason tooltip's listeners
340
+ // attach to the wrapper.
329
341
  const showsDisabledMessage = isDisabled && !!disabledMessage;
330
342
  const disabledMessageTooltip = useTooltip({
331
343
  placement: 'above',
@@ -582,6 +594,7 @@ export function NativeDateField({
582
594
  sizeStyles[size],
583
595
  styles.wrapper,
584
596
  isEffectivelyDisabled && inputWrapperStyles.disabled,
597
+ isEffectivelyDisabled && styles.disabledFocusRing,
585
598
  status && inputStatusBorderStyles[status.type],
586
599
  status &&
587
600
  !isEffectivelyDisabled &&
@@ -597,7 +610,7 @@ export function NativeDateField({
597
610
  <button
598
611
  type="button"
599
612
  onClick={openPicker}
600
- disabled={isEffectivelyDisabled}
613
+ aria-disabled={isEffectivelyDisabled || undefined}
601
614
  aria-label={t('@astryx.dateInput.openCalendar')}
602
615
  tabIndex={-1}
603
616
  {...stylex.props(
@@ -615,6 +628,7 @@ export function NativeDateField({
615
628
  <span {...stylex.props(styles.slot)}>
616
629
  <input
617
630
  {...controlProps}
631
+ onClick={isEffectivelyDisabled ? undefined : controlProps.onClick}
618
632
  ref={mergedInputRef}
619
633
  id={id}
620
634
  type="date"
@@ -626,9 +640,8 @@ export function NativeDateField({
626
640
  onBlur={chainEventHandlers(handleBlur, onBlurProp)}
627
641
  min={min}
628
642
  max={max}
629
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
630
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
631
- readOnly={showsDisabledMessage || undefined}
643
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
644
+ readOnly={isEffectivelyDisabled || undefined}
632
645
  aria-labelledby={
633
646
  ariaLabelledByProp != null
634
647
  ? joinAriaIDs(ariaLabelledBy ?? fieldLabelID, ariaLabelledByProp)
@@ -166,6 +166,17 @@ const sizeStyles = stylex.create({
166
166
  });
167
167
 
168
168
  const styles = stylex.create({
169
+ // Same as DateInput's: the focus ring for a focused disabled field.
170
+ disabledFocusRing: {
171
+ borderColor: {
172
+ default: colorVars['--color-border-emphasized'],
173
+ ':has(input:focus)': colorVars['--color-accent'],
174
+ },
175
+ boxShadow: {
176
+ default: 'none',
177
+ ':has(input:focus)': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
178
+ },
179
+ },
169
180
  // ---- the closed field ----
170
181
  wrapper: {
171
182
  gap: spacingVars['--spacing-2'],
@@ -620,11 +631,11 @@ export function TouchDateField({
620
631
  const isBusy = isLoading || optimisticValue !== value;
621
632
  const isEffectivelyDisabled = isDisabled || isBusy;
622
633
 
623
- // Disabled-reason tooltip, same contract as DateInput: a disabled control
624
- // swallows pointer events, so the listeners attach to the wrapper and the
625
- // input stays focusable via aria-disabled rather than the disabled
626
- // attribute. Only the persistent disabled state surfaces a reason, never
627
- // the transient busy one.
634
+ // Same contract as DateInput: a disabled or busy field never renders native
635
+ // `disabled` (spec:AST-085 FR1); the input stays focusable via
636
+ // aria-disabled. The disabled-reason tooltip's listeners attach to the
637
+ // wrapper. Only the persistent disabled state surfaces a reason, never the
638
+ // transient busy one.
628
639
  const showsDisabledMessage = isDisabled && !!disabledMessage;
629
640
  const disabledMessageTooltip = useTooltip({
630
641
  placement: 'above',
@@ -1213,6 +1224,7 @@ export function TouchDateField({
1213
1224
  sizeStyles[size],
1214
1225
  styles.wrapper,
1215
1226
  isEffectivelyDisabled && inputWrapperStyles.disabled,
1227
+ isEffectivelyDisabled && styles.disabledFocusRing,
1216
1228
  status && inputStatusBorderStyles[status.type],
1217
1229
  status &&
1218
1230
  !isEffectivelyDisabled &&
@@ -1228,7 +1240,7 @@ export function TouchDateField({
1228
1240
  <button
1229
1241
  type="button"
1230
1242
  onClick={openSheet}
1231
- disabled={isEffectivelyDisabled}
1243
+ aria-disabled={isEffectivelyDisabled || undefined}
1232
1244
  aria-label={t('@astryx.dateInput.openCalendar')}
1233
1245
  tabIndex={-1}
1234
1246
  {...stylex.props(
@@ -1259,11 +1271,14 @@ export function TouchDateField({
1259
1271
  readOnly
1260
1272
  inputMode="none"
1261
1273
  onChange={() => {}}
1262
- onClick={chainEventHandlers(openSheet, onClickProp)}
1274
+ onClick={
1275
+ isEffectivelyDisabled
1276
+ ? undefined
1277
+ : chainEventHandlers(openSheet, onClickProp)
1278
+ }
1263
1279
  onKeyDown={chainEventHandlers(handleInputKeyDown, onKeyDownProp)}
1264
1280
  placeholder={placeholder}
1265
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
1266
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
1281
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
1267
1282
  aria-labelledby={
1268
1283
  ariaLabelledByProp != null
1269
1284
  ? joinAriaIDs(ariaLabelledBy ?? fieldLabelID, ariaLabelledByProp)
@@ -90,12 +90,12 @@ preset-list anatomy and its additive theming surface.
90
90
 
91
91
  ### Representative states
92
92
 
93
- | State | Required invariant | Allowed variation |
94
- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
95
- | No presets | No preset group or preset-button target renders. | Calendar configuration |
96
- | Presets, no match | Every button carries the preset target with no selected state. | Preset count and labels |
97
- | Applied preset | The matching button carries `aria-current="true"` and the target's selected state. | Selected range |
98
- | Disabled preset | A preset that violates an endpoint date constraint or a range-span constraint is natively disabled and carries the target's disabled state. | Constraint source |
93
+ | State | Required invariant | Allowed variation |
94
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
95
+ | No presets | No preset group or preset-button target renders. | Calendar configuration |
96
+ | Presets, no match | Every button carries the preset target with no selected state. | Preset count and labels |
97
+ | Applied preset | The matching button carries `aria-current="true"` and the target's selected state. | Selected range |
98
+ | Disabled preset | A preset that violates an endpoint date constraint or a range-span constraint is disabled per `spec:AST-085` and carries the target's disabled state. | Constraint source |
99
99
 
100
100
  ### Transformation and precedence order
101
101
 
@@ -120,8 +120,8 @@ No new performance or resource constraint is introduced.
120
120
  - **AR2 — Current state.** The applied preset remains exposed with
121
121
  `aria-current="true"`; theme state reflection is additive.
122
122
  - **AR3 — Disabled state.** A preset that violates an endpoint date constraint or
123
- a range-span constraint remains natively disabled; theme state reflection does
124
- not replace that behavior.
123
+ a range-span constraint is disabled and stays focusable (`spec:AST-085`); theme
124
+ state reflection does not replace that behavior.
125
125
 
126
126
  ## Design relationships
127
127
 
@@ -172,11 +172,11 @@ No new performance or resource constraint is introduced.
172
172
 
173
173
  ## Verification map
174
174
 
175
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
176
- | ------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------ |
177
- | FR1, FR2, AR1–AR3 | `DateRangeInput.test.tsx` | no match, applied preset, endpoint-invalid preset, span-invalid preset | Semantics or state attributes disappear | `audit:DateRangeInput/presets` |
178
- | FR3, FR4 | `DateRangeInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | group target, selected button, disabled button | Target class/state is missing, undocumented, or placed on the wrong element | `audit:DateRangeInput/theming` |
179
- | Theming anatomy map | `scripts/check-knowledge.mjs` | all documented anatomy | Target ownership or anatomy names drift | `audit:DateRangeInput/theming` |
175
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
176
+ | ------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------ |
177
+ | FR1, FR2, AR1–AR3 | `DateRangeInput.test.tsx`; disabled-preset focusability (AR3) pending conformance | no match, applied preset, endpoint-invalid preset, span-invalid preset | Semantics or state attributes disappear | `audit:DateRangeInput/presets` |
178
+ | FR3, FR4 | `DateRangeInput.test.tsx`, `themingTargets.test.ts`, generated probe theme | group target, selected button, disabled button | Target class/state is missing, undocumented, or placed on the wrong element | `audit:DateRangeInput/theming` |
179
+ | Theming anatomy map | `scripts/check-knowledge.mjs` | all documented anatomy | Target ownership or anatomy names drift | `audit:DateRangeInput/theming` |
180
180
 
181
181
  ## Decision log
182
182
 
@@ -286,6 +286,45 @@ describe('Dialog', () => {
286
286
  });
287
287
  });
288
288
 
289
+ describe('a cancel from inside the dialog', () => {
290
+ // A file input fires a bubbling `cancel` at itself when its picker is
291
+ // dismissed (Escape, or Cancel in the chooser). It reaches the <dialog>
292
+ // like a close request but is not one.
293
+ function DialogWithFilePicker({onOpenChange}: {onOpenChange: () => void}) {
294
+ return (
295
+ <Dialog isOpen onOpenChange={onOpenChange} aria-label="Upload">
296
+ <input type="file" aria-label="Picture" />
297
+ </Dialog>
298
+ );
299
+ }
300
+
301
+ it('stays open when a file picker inside it is dismissed', () => {
302
+ const onOpenChange = vi.fn();
303
+ render(<DialogWithFilePicker onOpenChange={onOpenChange} />);
304
+
305
+ const fromPicker = new Event('cancel', {bubbles: true, cancelable: true});
306
+ screen.getByLabelText('Picture').dispatchEvent(fromPicker);
307
+
308
+ expect(onOpenChange).not.toHaveBeenCalled();
309
+ expect(fromPicker.defaultPrevented).toBe(false);
310
+ });
311
+
312
+ it('still closes on its own close request', () => {
313
+ const onOpenChange = vi.fn();
314
+ render(<DialogWithFilePicker onOpenChange={onOpenChange} />);
315
+
316
+ screen
317
+ .getByLabelText('Picture')
318
+ .dispatchEvent(new Event('cancel', {bubbles: true, cancelable: true}));
319
+ screen
320
+ .getByRole('dialog')
321
+ .dispatchEvent(new Event('cancel', {cancelable: true}));
322
+
323
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
324
+ expect(onOpenChange).toHaveBeenCalledWith(false);
325
+ });
326
+ });
327
+
289
328
  describe('IME composition', () => {
290
329
  // A CJK user presses Escape to cancel a half-formed character several times
291
330
  // a sentence. jsdom models neither composition nor the close watcher, so
@@ -643,11 +643,16 @@ export function Dialog({
643
643
  // Escape presses the stack owns never arrive here — it preventDefault()s
644
644
  // those, which suppresses the close watcher.
645
645
  //
646
- // Always preventDefault so the browser cannot close a controlled <dialog>
647
- // behind React's back, then answer the request with the stack's own rules:
648
- // only the top-most layer dismisses, a `required` dialog swallows it, and
649
- // nothing dismisses while an IME composition is in progress.
646
+ // A `cancel` that bubbles up from inside is not a close request for this
647
+ // dialog: a file input fires one when its picker is dismissed. It is left
648
+ // alone. Otherwise always preventDefault so the browser cannot close a
649
+ // controlled <dialog> behind React's back, then answer the request with the
650
+ // stack's own rules: only the top-most layer dismisses, a `required` dialog
651
+ // swallows it, and nothing dismisses while an IME composition is in progress.
650
652
  const handleCancel = (event: React.SyntheticEvent<HTMLDialogElement>) => {
653
+ if (event.target !== event.currentTarget) {
654
+ return;
655
+ }
651
656
  event.preventDefault();
652
657
  if (!shouldDismissOnCloseRequest()) {
653
658
  return;
@@ -86,7 +86,7 @@ Consumer migration instructions belong in consumer docs and release notes.
86
86
  ## Public concepts
87
87
 
88
88
  Consumer props, item shapes, subcomponents, and presentation policy remain
89
- documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Three component-local concepts are added, by DEC-2, DEC-3 and DEC-8; each
89
+ documented in `DropdownMenu.doc.mjs` and the subcomponent docs. Four component-local concepts are added, by DEC-2, DEC-3, DEC-8 and proposed DEC-13; each
90
90
  keeps its released default.
91
91
 
92
92
  | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
@@ -94,6 +94,7 @@ keeps its released default.
94
94
  | Menu height cap | `menuMaxHeight`: a number of pixels | Lifts the 300px cap for a menu that must fit its rows; the viewport still bounds it. | Pointer presentation | `300px` | `component:DropdownMenu` | stable | Ignored by the touch sheet. |
95
95
  | Trigger source | `button` (Button props) or `renderTrigger` (render prop) | Which control the menu hangs off. `trigger` receives `DropdownMenuTriggerProps` — the press model, keyboard opens, toggle click and ARIA wiring — and names the menu. | Both presentations | `button` | `component:DropdownMenu` | stable | Both given: `trigger` wins and a dev warning fires. |
96
96
  | Header and footer | `header`, `footer`: any React node | Content above and below the rows, beside the menu inside the same popover or sheet — never inside the `role="menu"` element. With either, the pointer popover is a modal dialog around the menu. | Both presentations | none | `component:DropdownMenu` | stable | A false or empty value renders no region. |
97
+ | Sub-menu regions | `header`, `footer` on a sub-menu: any React node | Content above and below a sub-menu's rows, beside its menu inside its own surface; with either, that surface is a modal dialog named by the row (proposed FR20). | Popover presentation | none | `component:DropdownMenu` | proposed | A false or empty value renders no region. |
97
98
 
98
99
  ## Behavioral and layout contract
99
100
 
@@ -119,6 +120,7 @@ keeps its released default.
119
120
  | FR15 | With `header` or `footer`, the pointer popover is a `role="dialog"` with `aria-modal="true"`, named like the menu, holding the header, then the `role="menu"` element, then the footer; the menu element holds only its rows. The trigger says `aria-haspopup="dialog"` and its `aria-controls` names the popover that holds the dialog. A region keeps its own keys and presses: neither the menu's key handling nor its press model claims an event that begins there. The menu is one Tab stop between the regions. Tab and Shift+Tab move through the header's controls, the menu, then the footer's controls, and wrap at either end; a control that is hidden, or under a hidden ancestor within its region, is not a stop. From a row, or a row of an open sub-menu, Tab and Shift+Tab close every open flyout and continue from the menu's stop. With no other stop, focus stays in the menu. Escape closes from anywhere in the popover, and focus returns to the trigger. The bottom sheet, already a dialog, shows the header above the actions and the footer below them. Without either prop, the menu renders and behaves exactly as it does without them: no dialog, `aria-haspopup="menu"`, and Tab closes the menu. | DEC-8, DEC-10, docs, and tests | Proposed; awaiting owner approval |
120
121
  | FR16 | `DropdownMenuCheckboxItem` MUST expose one `menuitemcheckbox` row whose `aria-checked` equals its controlled value; enabled activation reports `!value`, stays open by default, and composes consumer click and pointer-move handlers with owned activation and hover focus. Disabled activation calls no consumer activation handler. | Released subcomponent API; `architecture:public-component-api/INV5–INV8`; `DropdownMenuSelectable.test.tsx` | Verified current behavior; consumer handlers are preserved |
121
122
  | FR17 | Disabled menu rows SHOULD remain keyboard-discoverable while activation stays blocked. Current shared `useListFocus` deliberately skips every `aria-disabled` row, including checkbox items. | WAI-ARIA Menu pattern; current shared navigation source/tests | Objective mismatch; shared mechanism remains undecided in OQ1 |
123
+ | FR20 | With `header` or `footer` on a sub-menu (`DropdownMenuSubMenu`), its surface is a modal dialog named by its row (AR7), holding the header, the sub-menu's `role="menu"` (rows only), then the footer. It sits in a host `DropdownMenu` renders beside its menu, never inside a menu; under a root with no such host (`ContextMenu`) the sub-menu shows its rows alone. On a fine pointer the dialog is the flyout; on a compact touch display, the drilled-in view, which replaces the root's menu and regions until popped. A region keeps its own keys and presses. The rows are one Tab stop: Tab and Shift+Tab walk the header's controls, the rows, then the footer's, and wrap; from a deeper flyout, that flyout closes and the walk goes on from the rows. An open lands as for any sub-menu (FR11, FR12), not on a region. Escape in the dialog, outside an IME composition and unless a control there handled it, returns focus to the row and leaves the root open. A pointer leaving the flyout keeps it open until focus leaves, and it closes with the flyout holding its row. Without either prop nothing changes. | Proposed DEC-13; FR15; AR5 | Proposed; verified in jsdom and Chromium, pending owner review |
122
124
 
123
125
  ### Allowed variation
124
126
 
@@ -200,13 +202,20 @@ visibility helper.
200
202
  trigger keeps `aria-expanded`; its `aria-haspopup` says `dialog`, and its
201
203
  `aria-controls` names the popover that holds the dialog rather than the
202
204
  menu, as Popover's own trigger does.
203
-
204
205
  - **AR6 — Checkbox rows remain one control.** The row owns
205
206
  `menuitemcheckbox`, its accessible name, checked state, focus, and activation;
206
207
  Indicator's checkbox marker is decorative. Disabled activation is inert.
207
208
  Current shared navigation skips disabled rows instead of making them
208
209
  discoverable as the WAI-ARIA Menu pattern recommends; FR17 and OQ1 retain that
209
210
  gap without inventing a component-local focus path.
211
+ - **AR7 (proposed) — A sub-menu with a header or footer adopts the same APG
212
+ Dialog (Modal) parts for its own surface** (`spec:AST-020/FR15`):
213
+ `role="dialog"`, `aria-modal="true"`, and an accessible name, its row's;
214
+ focus moves into the dialog on open, to the sub-menu's first row or its
215
+ menu as FR11 and FR12 already state; Tab and Shift+Tab cycle within it
216
+ (FR20); Escape closes it and focus returns to the row. The sub-menu's menu
217
+ inside keeps AR3. The row keeps `aria-expanded`; its `aria-haspopup` says
218
+ `dialog` and its `aria-controls` names the dialog.
210
219
 
211
220
  ## Design relationships
212
221
 
@@ -324,6 +333,7 @@ than adding a DropdownMenu-owned heading target.
324
333
  | FR15 | `DropdownMenu.test.tsx` header and footer suite; `__tests__/MenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium | dialog role, modality, name and trigger wiring; regions outside the menu element; typing in a header field; Tab walk header → menu → footer with wrap at both ends; Tab and Shift+Tab from an open sub-menu; hidden and hidden-wrapper controls; Escape from the header; a finger tap on a footer button; bottom sheet order; no regions | A region rendered inside the menu element, a popover with regions that is not a named modal dialog, a trigger still saying `aria-haspopup="menu"` with regions, a header key taken by typeahead, a Tab that leaves the dialog or closes it, focus stranded outside the dialog, or a footer tap swallowed by the press model fails. |
325
334
  | FR16 | `DropdownMenuSelectable.test.tsx`, source inspection, scoped `pr-a11y` over the `a11y/Checkbox pattern` stories, and owned Storybook states | checked, unchecked, disabled, caller click/pointer handlers, marker size and ownership | A dropped consumer handler, wrong checked state, nested semantic checkbox, disabled activation, or menu that closes by default fails. | `audit:DropdownMenuCheckboxItem/behavior` |
326
335
  | FR17, AR6 | Shared navigation source and DropdownMenu keyboard tests | disabled checkbox row first, middle, and last | Current tests prove the shared hook skips the row; no keyboard-discoverability proof exists until OQ1 is settled. | `audit:DropdownMenuCheckboxItem/accessibility` |
336
+ | FR20, AR7 | `DropdownMenuSubMenu.test.tsx` "header and footer" suite; `ContextMenu.test.tsx` rows-alone case; `__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts` in real Chromium (fine pointer, coarse pointer) | flyout and phone view; dialog role, name, row wiring; regions outside menus; Tab walk and wrap from a field, row, deeper flyout; field keys, presses; Escape, IME and handled Escapes; keyboard open; pointer leaving, focus in then out; parent closing a nested flyout; view tracking its regions; ContextMenu rows alone | A region inside a menu element, a surface with regions that is not a named modal dialog, a row still saying `aria-haspopup="menu"`, a field key taken by the menu, a Tab that leaves or closes the dialog, a flyout closed under focus by a pointer leaving, a flyout outliving its parent, or the root menu beside a view, fails. | `audit:DropdownMenu/behavior` |
327
337
 
328
338
  ## Decision log
329
339
 
@@ -541,3 +551,24 @@ the menu. Tab and Shift+Tab wrap within the dialog, as the APG Dialog
541
551
  pattern requires; Escape and a press outside close it. Without either prop
542
552
  the component is unchanged: a Menu Button whose Tab closes the menu. Rejected:
543
553
  closing on Tab past either end, which leaves a modal dialog by Tab.
554
+
555
+ ### DEC-13 (proposed) — A sub-menu's header and footer make its surface a dialog beside the menu
556
+
557
+ **Reference:** `component:DropdownMenu/DEC-13`
558
+ **Decider:** proposed by `vjeux`, 2026-10-14; pending owner review
559
+
560
+ A filter field above a sub-menu's rows, or a door below them, is a control
561
+ that is not a menu item, so DEC-8 and DEC-10 apply to the sub-menu's own
562
+ surface as they apply to the root's: the sub-menu's menu holds only its rows,
563
+ and the surface around it is a named modal dialog whose Tab order wraps. A
564
+ flyout renders inside the menu that holds its row, so a dialog there would
565
+ still sit inside that menu; the `DropdownMenu` therefore renders a host beside its menu,
566
+ inside its popover, and a sub-menu with regions renders its surface there (the
567
+ flyout layer, or the drilled-in view, which then takes the root menu's place).
568
+ Rendered there, a flyout no longer hides with the flyout that holds its row,
569
+ so it closes with it. The pointer leaving that flyout does not close it while
570
+ focus is inside, because what was typed into its field would go with it.
571
+ Rejected: regions inside the flyout beside the sub-menu's menu,
572
+ which puts them inside the parent menu; a separate DropdownMenu for the level,
573
+ which loses the row, the drill-in and the single press model; and closing the
574
+ flyout when the pointer leaves it as any flyout does.