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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  2. package/dist/BottomSheet/BottomSheet.js +8 -4
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.js +7 -0
  5. package/dist/Calendar/Calendar.js +3 -2
  6. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  7. package/dist/CheckboxInput/CheckboxInput.js +19 -24
  8. package/dist/ClickableCard/ClickableCard.d.ts +2 -2
  9. package/dist/ClickableCard/ClickableCard.d.ts.map +1 -1
  10. package/dist/ClickableCard/ClickableCard.js +11 -9
  11. package/dist/CodeBlock/CodeBlock.d.ts.map +1 -1
  12. package/dist/CodeBlock/CodeBlock.js +11 -1
  13. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  14. package/dist/ComplexSelector/ComplexSelector.js +23 -5
  15. package/dist/DateInput/DateInput.d.ts +4 -4
  16. package/dist/DateInput/DateInput.d.ts.map +1 -1
  17. package/dist/DateInput/DateInput.js +27 -20
  18. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  19. package/dist/DateInput/NativeDateField.js +14 -8
  20. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  21. package/dist/DateInput/TouchDateField.js +14 -10
  22. package/dist/DateRangeInput/DateRangeInput.d.ts +5 -5
  23. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  24. package/dist/DateRangeInput/DateRangeInput.js +27 -18
  25. package/dist/DateTimeInput/DateTimeInput.d.ts +5 -5
  26. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  27. package/dist/DateTimeInput/DateTimeInput.js +25 -27
  28. package/dist/DateTimeInput/NativeDateSegment.d.ts +3 -2
  29. package/dist/DateTimeInput/NativeDateSegment.d.ts.map +1 -1
  30. package/dist/DateTimeInput/NativeDateSegment.js +5 -5
  31. package/dist/DateTimeInput/NativeTimeSegment.d.ts +3 -2
  32. package/dist/DateTimeInput/NativeTimeSegment.d.ts.map +1 -1
  33. package/dist/DateTimeInput/NativeTimeSegment.js +5 -5
  34. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  35. package/dist/DateTimeInput/TouchDateTimeField.js +12 -9
  36. package/dist/Dialog/Dialog.d.ts.map +1 -1
  37. package/dist/Dialog/Dialog.js +9 -4
  38. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  39. package/dist/DropdownMenu/DropdownMenu.js +52 -10
  40. package/dist/DropdownMenu/DropdownMenuContext.d.ts +34 -1
  41. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  42. package/dist/DropdownMenu/DropdownMenuContext.js +15 -0
  43. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +21 -2
  44. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  45. package/dist/DropdownMenu/DropdownMenuSubMenu.js +375 -42
  46. package/dist/DropdownMenu/menuRegionStops.d.ts +2 -2
  47. package/dist/DropdownMenu/menuRegionStops.js +2 -2
  48. package/dist/FileInput/FileInput.d.ts.map +1 -1
  49. package/dist/FileInput/FileInput.js +14 -12
  50. package/dist/Link/Link.d.ts +2 -3
  51. package/dist/Link/Link.d.ts.map +1 -1
  52. package/dist/Link/Link.js +11 -19
  53. package/dist/Markdown/Markdown.d.ts.map +1 -1
  54. package/dist/Markdown/Markdown.js +48 -15
  55. package/dist/MultiSelector/MultiSelector.d.ts +4 -4
  56. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  57. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts +2 -0
  58. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.d.ts.map +1 -0
  59. package/dist/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.js +48 -0
  60. package/dist/MultiSelector/MultiSelector.js +25 -18
  61. package/dist/Pagination/Pagination.d.ts.map +1 -1
  62. package/dist/Pagination/Pagination.js +5 -2
  63. package/dist/Popover/Popover.d.ts.map +1 -1
  64. package/dist/Popover/Popover.js +8 -4
  65. package/dist/Selector/Selector.d.ts +4 -4
  66. package/dist/Selector/Selector.d.ts.map +1 -1
  67. package/dist/Selector/Selector.js +23 -14
  68. package/dist/Selector/SelectorOption.d.ts.map +1 -1
  69. package/dist/Selector/SelectorOption.js +1 -0
  70. package/dist/Slider/Slider.d.ts.map +1 -1
  71. package/dist/Slider/Slider.js +7 -14
  72. package/dist/Switch/Switch.d.ts.map +1 -1
  73. package/dist/Switch/Switch.js +25 -17
  74. package/dist/Switch/switch.markers.stylex.d.ts +2 -0
  75. package/dist/Switch/switch.markers.stylex.d.ts.map +1 -1
  76. package/dist/Switch/switch.markers.stylex.js +6 -0
  77. package/dist/TimeInput/TimeInput.d.ts +7 -6
  78. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  79. package/dist/TimeInput/TimeInput.js +37 -20
  80. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  81. package/dist/TimeInput/TouchTimeField.js +17 -6
  82. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  83. package/dist/Timestamp/Timestamp.js +41 -8
  84. package/dist/Token/Token.d.ts.map +1 -1
  85. package/dist/Token/Token.js +8 -3
  86. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  87. package/dist/Tooltip/Tooltip.js +36 -14
  88. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  89. package/dist/Tooltip/useTooltip.js +11 -2
  90. package/dist/TopNav/TopNav.d.ts.map +1 -1
  91. package/dist/TopNav/TopNav.js +2 -2
  92. package/dist/TopNav/TopNavHeading.d.ts.map +1 -1
  93. package/dist/TopNav/TopNavHeading.js +1 -0
  94. package/dist/TreeList/TreeListItem.d.ts.map +1 -1
  95. package/dist/TreeList/TreeListItem.js +8 -3
  96. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  97. package/dist/Typeahead/Typeahead.js +14 -2
  98. package/dist/astryx.css +8 -2
  99. package/dist/hooks/useGridFocus.d.ts.map +1 -1
  100. package/dist/hooks/useGridFocus.js +10 -1
  101. package/dist/hooks/useMenuPress.d.ts +11 -1
  102. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  103. package/dist/hooks/useMenuPress.js +43 -5
  104. package/dist/utils/disabledInteraction.d.ts +21 -0
  105. package/dist/utils/disabledInteraction.d.ts.map +1 -0
  106. package/dist/utils/disabledInteraction.js +33 -0
  107. package/dist/utils/timeParser.d.ts +4 -0
  108. package/dist/utils/timeParser.d.ts.map +1 -1
  109. package/dist/utils/timeParser.js +9 -2
  110. package/package.json +4 -4
  111. package/scripts/agent-doc-state.mjs +58 -9
  112. package/src/Banner/Banner.spec.md +37 -32
  113. package/src/BottomSheet/BottomSheet.spec.md +34 -54
  114. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  115. package/src/BottomSheet/BottomSheet.tsx +8 -4
  116. package/src/BottomSheet/BottomSheetSwitcher.spec.md +125 -58
  117. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +26 -0
  118. package/src/BottomSheet/BottomSheetSwitcher.tsx +6 -0
  119. package/src/Calendar/Calendar.test.tsx +68 -24
  120. package/src/Calendar/Calendar.tsx +2 -1
  121. package/src/CheckboxInput/CheckboxInput.test.tsx +44 -5
  122. package/src/CheckboxInput/CheckboxInput.tsx +16 -26
  123. package/src/CheckboxInput/__tests__/Checkbox.a11y.states.ts +3 -3
  124. package/src/CheckboxList/CheckboxList.test.tsx +9 -6
  125. package/src/ClickableCard/ClickableCard.doc.mjs +2 -2
  126. package/src/ClickableCard/ClickableCard.test.tsx +5 -4
  127. package/src/ClickableCard/ClickableCard.tsx +9 -9
  128. package/src/CodeBlock/CodeBlock.highlightStyles.test.tsx +87 -0
  129. package/src/CodeBlock/CodeBlock.tsx +11 -1
  130. package/src/ComplexSelector/ComplexSelector.focusableDisabled.test.tsx +76 -0
  131. package/src/ComplexSelector/ComplexSelector.tsx +21 -4
  132. package/src/ContextMenu/ContextMenu.test.tsx +41 -0
  133. package/src/DateInput/DateInput.doc.mjs +9 -8
  134. package/src/DateInput/DateInput.test.tsx +88 -13
  135. package/src/DateInput/DateInput.tsx +49 -21
  136. package/src/DateInput/DateInputTouch.test.tsx +22 -1
  137. package/src/DateInput/NativeDateField.test.tsx +29 -3
  138. package/src/DateInput/NativeDateField.tsx +20 -7
  139. package/src/DateInput/TouchDateField.tsx +24 -9
  140. package/src/DateRangeInput/DateRangeInput.doc.mjs +6 -5
  141. package/src/DateRangeInput/DateRangeInput.spec.md +13 -13
  142. package/src/DateRangeInput/DateRangeInput.test.tsx +82 -16
  143. package/src/DateRangeInput/DateRangeInput.tsx +40 -20
  144. package/src/DateTimeInput/DateTimeInput.doc.mjs +9 -8
  145. package/src/DateTimeInput/DateTimeInput.test.tsx +126 -11
  146. package/src/DateTimeInput/DateTimeInput.tsx +42 -26
  147. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +24 -0
  148. package/src/DateTimeInput/NativeDateSegment.tsx +9 -6
  149. package/src/DateTimeInput/NativePickerSegments.test.tsx +31 -0
  150. package/src/DateTimeInput/NativeTimeSegment.tsx +9 -6
  151. package/src/DateTimeInput/TouchDateTimeField.tsx +22 -7
  152. package/src/Dialog/Dialog.test.tsx +39 -0
  153. package/src/Dialog/Dialog.tsx +9 -4
  154. package/src/DropdownMenu/DropdownMenu.spec.md +33 -2
  155. package/src/DropdownMenu/DropdownMenu.tsx +79 -8
  156. package/src/DropdownMenu/DropdownMenuContext.public.test.ts +22 -0
  157. package/src/DropdownMenu/DropdownMenuContext.tsx +39 -1
  158. package/src/DropdownMenu/DropdownMenuItem.doc.mjs +66 -7
  159. package/src/DropdownMenu/DropdownMenuItem.spec.md +209 -0
  160. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +16 -1
  161. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +623 -1
  162. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +556 -99
  163. package/src/DropdownMenu/__tests__/DropdownMenuItemAudit.a11y.chromium.spec.ts +366 -0
  164. package/src/DropdownMenu/__tests__/SubMenuHeaderFooter.a11y.chromium.spec.ts +267 -0
  165. package/src/DropdownMenu/menuRegionStops.ts +2 -2
  166. package/src/DropdownMenu/modules/useMenuPress.spec.md +9 -0
  167. package/src/FieldStatus/FieldStatus.spec.md +3 -2
  168. package/src/FileInput/FileInput.test.tsx +30 -7
  169. package/src/FileInput/FileInput.tsx +21 -11
  170. package/src/Link/Link.test.tsx +21 -7
  171. package/src/Link/Link.tsx +16 -24
  172. package/src/Link/__tests__/Link.a11y.states.ts +1 -1
  173. package/src/Markdown/Markdown.rerender.test.tsx +143 -0
  174. package/src/Markdown/Markdown.tsx +89 -35
  175. package/src/MultiSelector/MultiSelector.disabledFocus.a11y.chromium.spec.ts +71 -0
  176. package/src/MultiSelector/MultiSelector.doc.mjs +5 -5
  177. package/src/MultiSelector/MultiSelector.focusableDisabled.test.tsx +121 -0
  178. package/src/MultiSelector/MultiSelector.test.tsx +49 -7
  179. package/src/MultiSelector/MultiSelector.tsx +53 -29
  180. package/src/Pagination/Pagination.test.tsx +5 -1
  181. package/src/Pagination/Pagination.tsx +3 -1
  182. package/src/Popover/Popover.test.tsx +15 -0
  183. package/src/Popover/Popover.tsx +19 -8
  184. package/src/PowerSearch/PowerSearchFilterEditor.test.tsx +3 -2
  185. package/src/PowerSearch/PowerSearchToken.test.tsx +7 -5
  186. package/src/Selector/Selector.doc.mjs +4 -4
  187. package/src/Selector/Selector.focusableDisabled.test.tsx +127 -0
  188. package/src/Selector/Selector.spec.md +11 -11
  189. package/src/Selector/Selector.test.tsx +64 -6
  190. package/src/Selector/Selector.tsx +51 -21
  191. package/src/Selector/SelectorOption.tsx +4 -0
  192. package/src/SideNav/SideNav.test.tsx +35 -0
  193. package/src/Slider/Slider.spec.md +11 -11
  194. package/src/Slider/Slider.test.tsx +3 -3
  195. package/src/Slider/Slider.tsx +8 -15
  196. package/src/Switch/Switch.test.tsx +85 -8
  197. package/src/Switch/Switch.tsx +26 -16
  198. package/src/Switch/__tests__/Switch.a11y.states.ts +2 -2
  199. package/src/Switch/switch.markers.stylex.ts +4 -0
  200. package/src/Table/Table.spec.md +2 -0
  201. package/src/Table/plugins/infiniteScroll/useTableInfiniteScroll.spec.md +244 -0
  202. package/src/Table/plugins/selection/useTableSelection.test.tsx +5 -3
  203. package/src/Table/plugins/virtualization/useTableVirtualization.spec.md +266 -0
  204. package/src/TimeInput/NativeTimeInput.test.tsx +25 -1
  205. package/src/TimeInput/Presentation.test.tsx +23 -0
  206. package/src/TimeInput/TimeInput.doc.mjs +13 -10
  207. package/src/TimeInput/TimeInput.test.tsx +139 -7
  208. package/src/TimeInput/TimeInput.tsx +53 -24
  209. package/src/TimeInput/TouchTimeField.tsx +27 -5
  210. package/src/Timestamp/Timestamp.doc.mjs +1 -1
  211. package/src/Timestamp/Timestamp.test.tsx +168 -0
  212. package/src/Timestamp/Timestamp.tsx +62 -11
  213. package/src/Token/Token.test.tsx +26 -4
  214. package/src/Token/Token.tsx +6 -2
  215. package/src/Tokenizer/Tokenizer.spec.md +28 -28
  216. package/src/Tooltip/Tooltip.test.tsx +51 -6
  217. package/src/Tooltip/Tooltip.tsx +41 -18
  218. package/src/Tooltip/__tests__/Tooltip.a11y.chromium.spec.ts +129 -0
  219. package/src/Tooltip/__tests__/Tooltip.a11y.renders.tsx +86 -0
  220. package/src/Tooltip/__tests__/Tooltip.a11y.states.ts +132 -0
  221. package/src/Tooltip/__tests__/Tooltip.a11y.test.tsx +126 -0
  222. package/src/Tooltip/__tests__/TooltipHoverBridge.a11y.chromium.spec.ts +74 -0
  223. package/src/Tooltip/useTooltip.test.tsx +23 -0
  224. package/src/Tooltip/useTooltip.tsx +22 -9
  225. package/src/TopNav/TopNav.tsx +11 -1
  226. package/src/TopNav/TopNavHeading.tsx +1 -0
  227. package/src/TopNav/__tests__/TopNavMobileLongHeading.a11y.chromium.spec.ts +74 -0
  228. package/src/TreeList/TreeList.test.tsx +28 -0
  229. package/src/TreeList/TreeListItem.tsx +6 -2
  230. package/src/Typeahead/Typeahead.test.tsx +89 -0
  231. package/src/Typeahead/Typeahead.tsx +15 -2
  232. package/src/__tests__/DisabledTriggerFocus.a11y.chromium.spec.ts +86 -0
  233. package/src/hooks/useGridFocus.ts +16 -1
  234. package/src/hooks/useMenuPress.ts +45 -7
  235. package/src/utils/disabledInteraction.ts +40 -0
  236. package/src/utils/timeParser.test.ts +11 -0
  237. package/src/utils/timeParser.ts +9 -2
@@ -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={() => {}} />);
@@ -80,7 +80,8 @@ export type NativeTimeSegmentProps = {
80
80
  max?: ISOTimeString;
81
81
  hourFormat: '12h' | '24h';
82
82
  isEffectivelyDisabled: boolean;
83
- hasDisabledMessage: boolean;
83
+ /** While disabled, stay focusable: aria-disabled and readOnly, not `disabled`. */
84
+ isFocusableDisabled: boolean;
84
85
  isEffectivelyRequired: boolean;
85
86
  isBusy: boolean;
86
87
  statusType?: InputStatusType;
@@ -112,7 +113,7 @@ export function NativeTimeSegment({
112
113
  max,
113
114
  hourFormat,
114
115
  isEffectivelyDisabled,
115
- hasDisabledMessage,
116
+ isFocusableDisabled,
116
117
  isEffectivelyRequired,
117
118
  isBusy,
118
119
  statusType,
@@ -285,7 +286,7 @@ export function NativeTimeSegment({
285
286
  <button
286
287
  type="button"
287
288
  onClick={openPicker}
288
- disabled={isEffectivelyDisabled}
289
+ aria-disabled={isEffectivelyDisabled || undefined}
289
290
  aria-label={openPickerLabel}
290
291
  tabIndex={-1}
291
292
  {...stylex.props(
@@ -309,9 +310,11 @@ export function NativeTimeSegment({
309
310
  min={min}
310
311
  max={max}
311
312
  step={60}
312
- disabled={isEffectivelyDisabled && !hasDisabledMessage}
313
- aria-disabled={hasDisabledMessage ? 'true' : undefined}
314
- readOnly={hasDisabledMessage || undefined}
313
+ disabled={isEffectivelyDisabled && !isFocusableDisabled}
314
+ aria-disabled={
315
+ isEffectivelyDisabled && isFocusableDisabled ? 'true' : undefined
316
+ }
317
+ readOnly={(isEffectivelyDisabled && isFocusableDisabled) || undefined}
315
318
  // The segment's own label applies only when nothing else names it;
316
319
  // otherwise a caller aria-label from the spread above stays.
317
320
  aria-label={
@@ -159,6 +159,17 @@ const sizeStyles = stylex.create({
159
159
  const HORIZONTAL_SEGMENT_BASIS = 196;
160
160
 
161
161
  const styles = stylex.create({
162
+ // Same as DateTimeInput's: the focus ring for a focused disabled field.
163
+ disabledFocusRing: {
164
+ borderColor: {
165
+ default: colorVars['--color-border-emphasized'],
166
+ ':focus-within': colorVars['--color-accent'],
167
+ },
168
+ boxShadow: {
169
+ default: 'none',
170
+ ':focus-within': `inset 0px 0px 0px 2px ${colorVars['--color-accent-muted']}`,
171
+ },
172
+ },
162
173
  iconButton: {
163
174
  display: 'flex',
164
175
  alignItems: 'center',
@@ -1330,6 +1341,7 @@ export function TouchDateTimeField({
1330
1341
  sizeStyles[size],
1331
1342
  styles.touchDateWrapper,
1332
1343
  isEffectivelyDisabled && inputWrapperStyles.disabled,
1344
+ isEffectivelyDisabled && styles.disabledFocusRing,
1333
1345
  status && inputStatusBorderStyles[status.type],
1334
1346
  status &&
1335
1347
  !isEffectivelyDisabled &&
@@ -1340,7 +1352,7 @@ export function TouchDateTimeField({
1340
1352
  <button
1341
1353
  type="button"
1342
1354
  onClick={() => openSheet('date')}
1343
- disabled={isEffectivelyDisabled}
1355
+ aria-disabled={isEffectivelyDisabled || undefined}
1344
1356
  aria-label={t('@astryx.dateInput.openCalendar')}
1345
1357
  tabIndex={-1}
1346
1358
  {...stylex.props(
@@ -1367,7 +1379,11 @@ export function TouchDateTimeField({
1367
1379
  readOnly
1368
1380
  inputMode="none"
1369
1381
  onChange={() => {}}
1370
- onClick={chainEventHandlers(() => openSheet('date'), onClickProp)}
1382
+ onClick={
1383
+ isEffectivelyDisabled
1384
+ ? undefined
1385
+ : chainEventHandlers(() => openSheet('date'), onClickProp)
1386
+ }
1371
1387
  onKeyDown={chainEventHandlers(
1372
1388
  (event: React.KeyboardEvent<HTMLInputElement>) =>
1373
1389
  handleInputKeyDown(event, 'date'),
@@ -1375,8 +1391,7 @@ export function TouchDateTimeField({
1375
1391
  )}
1376
1392
  aria-labelledby={controlLabelledBy}
1377
1393
  placeholder={placeholder}
1378
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
1379
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
1394
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
1380
1395
  aria-describedby={ariaDescribedBy}
1381
1396
  aria-required={isEffectivelyRequired ? 'true' : undefined}
1382
1397
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
@@ -1417,6 +1432,7 @@ export function TouchDateTimeField({
1417
1432
  sizeStyles[size],
1418
1433
  styles.touchTimeWrapper,
1419
1434
  isEffectivelyDisabled && inputWrapperStyles.disabled,
1435
+ isEffectivelyDisabled && styles.disabledFocusRing,
1420
1436
  status && inputStatusBorderStyles[status.type],
1421
1437
  status &&
1422
1438
  !isEffectivelyDisabled &&
@@ -1427,7 +1443,7 @@ export function TouchDateTimeField({
1427
1443
  <button
1428
1444
  type="button"
1429
1445
  onClick={() => openSheet('time')}
1430
- disabled={isEffectivelyDisabled}
1446
+ aria-disabled={isEffectivelyDisabled || undefined}
1431
1447
  aria-label={t('@astryx.dateTimeInput.openTimePicker', {
1432
1448
  label: resolvedTimeLabel,
1433
1449
  })}
@@ -1456,8 +1472,7 @@ export function TouchDateTimeField({
1456
1472
  onClick={() => openSheet('time')}
1457
1473
  onKeyDown={event => handleInputKeyDown(event, 'time')}
1458
1474
  placeholder={timePlaceholder}
1459
- disabled={isEffectivelyDisabled && !showsDisabledMessage}
1460
- aria-disabled={showsDisabledMessage ? 'true' : undefined}
1475
+ aria-disabled={isEffectivelyDisabled ? 'true' : undefined}
1461
1476
  aria-label={resolvedTimeLabel}
1462
1477
  aria-describedby={ariaDescribedBy}
1463
1478
  aria-required={isEffectivelyRequired ? 'true' : undefined}
@@ -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.