@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
@@ -3,11 +3,11 @@ schema_version: 3
3
3
  template_version: 4
4
4
  kind: component
5
5
  id: component:BottomSheetSwitcher
6
- authority: draft
6
+ authority: current
7
7
  archive_reason: null
8
8
  superseded_by: null
9
- approved_by: null
10
- approved_at: null
9
+ approved_by: imdreamrunner
10
+ approved_at: 2026-10-21
11
11
  owners: [cixzhang]
12
12
  review_triggers: [public-api, behavior, layout, theming, accessibility]
13
13
  verified_by:
@@ -34,16 +34,18 @@ system_specs: [spec:AST-002, spec:AST-027]
34
34
  ## Intent
35
35
 
36
36
  BottomSheetSwitcher coordinates a mutually exclusive sequence of BottomSheet
37
- children inside one shared native dialog. This draft records verified shipped and
38
- remediated behavior without adding a prop, changing a default, or settling the
39
- open modality, hosting, identifier, or theming decisions below.
37
+ children, each presented in its own native dialog (DEC-1), without adding a
38
+ prop or changing a default. Where the shipped switcher does not yet meet a
39
+ requirement, the row carries a present-tense implementation gap note. The
40
+ modality, identifier, and theming questions below stay open.
40
41
 
41
42
  ## Compatibility and migration
42
43
 
43
44
  - Released default preserved: `yes`
44
- - Compatibility class: observational backfill plus a shared-dismissal conformance
45
- fix; public props, defaults, exports, DOM ownership, and transition behavior stay
46
- unchanged
45
+ - Compatibility class: nonbreaking; public props, defaults, exports, visual
46
+ output, and transition behavior stay unchanged. The element the `ref` resolves
47
+ to, and that it changes on a handoff, is implementation rather than public
48
+ contract (DEC-2)
47
49
  - Controlled/uncontrolled behavior: `activeSheet` remains fully controlled
48
50
  - Migration decision: none
49
51
 
@@ -53,10 +55,18 @@ Consumer migration instructions belong in consumer docs and release notes.
53
55
 
54
56
  **Owns**
55
57
 
56
- - One shared native dialog for all directly nested BottomSheet children.
58
+ - The presentation lifecycle of every directly nested BottomSheet child's native
59
+ dialog: which is presented, retained during a handoff, or closed.
60
+ Implementation gap: the switcher renders one dialog of its own and hosts
61
+ every child as a panel inside it; no child owns a dialog yet.
57
62
  - Which child sheet is active, retained during a handoff, or hidden.
58
- - Shared scrim, focus containment, scroll lock, Escape/platform-close routing,
59
- and final focus return for the flow.
63
+ - Which sheet dialog hosts the flow (its ref, `id`, and dismissal
64
+ registration) and which paints the flow's one scrim, plus
65
+ Escape/platform-close routing and final focus return for the flow. Focus
66
+ containment is the native modal dialog's; the flow's one page scroll lock is
67
+ held for the whole modal flow (DEC-1).
68
+ - The flow wrapper: the isolated surface that orders non-modal sheet dialogs
69
+ locally (`spec:AST-027` FR2).
60
70
 
61
71
  **Does not own / non-goals**
62
72
 
@@ -69,30 +79,27 @@ Consumer migration instructions belong in consumer docs and release notes.
69
79
 
70
80
  ## Public concepts
71
81
 
72
- | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
73
- | ------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------ |
74
- | Active sheet | `null` or one nested BottomSheet `sheetId` | Selects the only interactive child, or closes the flow | All presentations | `null` is closed; the prop is required | `component:BottomSheetSwitcher` | released | A non-matching or duplicate id is not given a safe public fallback by the current contract |
75
- | Shared presentation | `true` / `false` through `hasScrim` | Selects the current modal scrim-backed host or non-modal no-scrim host | Entire flow | `true` | `component:BottomSheetSwitcher` | released | Boolean only |
76
- | Child identity | Non-empty `sheetId` on each direct BottomSheet child | Associates controlled selection, labeling, purpose, and transition state with a child | Direct nested BottomSheet children | none | `component:BottomSheet` | released | Empty ids warn and stay hidden; duplicate-id behavior is not specified |
77
- | Dismissal request | `onActiveSheetChange(null)` | Reports an allowed implicit close without taking control from the caller | Escape/platform close, scrim click, or swipe according to active child purpose | none | `component:BottomSheetSwitcher`; `family:overlay-dismissal` owns ordering | released | The caller may retain its controlled value |
78
- | Dialog surface | inherited dialog props plus `ref` and `onCancel` | Reaches the one shared native dialog | Entire flow | none | `component:BottomSheetSwitcher` | released | Component-owned semantics and handlers retain precedence |
82
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
83
+ | ------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------- | ------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------ |
84
+ | Active sheet | `null` or one nested BottomSheet `sheetId` | Selects the only interactive child, or closes the flow | All presentations | `null` is closed; the prop is required | `component:BottomSheetSwitcher` | released | A non-matching or duplicate id is not given a safe public fallback by the current contract |
85
+ | Shared presentation | `true` / `false` through `hasScrim` | Selects the current modal scrim-backed host or non-modal no-scrim host | Entire flow | `true` | `component:BottomSheetSwitcher` | released | Boolean only |
86
+ | Child identity | Non-empty `sheetId` on each direct BottomSheet child | Associates controlled selection, labeling, purpose, and transition state with a child | Direct nested BottomSheet children | none | `component:BottomSheet` | released | Empty ids warn and stay hidden; duplicate-id behavior is not specified |
87
+ | Dismissal request | `onActiveSheetChange(null)` | Reports an allowed implicit close without taking control from the caller | Escape/platform close, scrim click, or swipe according to active child purpose | none | `component:BottomSheetSwitcher`; `family:overlay-dismissal` owns ordering | released | The caller may retain its controlled value |
88
+ | Dialog surface | inherited dialog props plus `ref` and `onCancel` | Dialog props and handlers, `onCancel` included, reach every sheet dialog; `ref`, `id`, and `data-testid` resolve to the host dialog, which is never null for an object ref while a sheet is mounted (DEC-2). Implementation gap: every dialog prop, `ref`, `id`, and `data-testid` reaches the switcher's one shared dialog | Entire flow | none | `component:BottomSheetSwitcher` | released | Component-owned semantics and handlers retain precedence |
79
89
 
80
90
  ## Behavioral and layout contract
81
91
 
82
- Draft requirements identify their basis so observed code is not mistaken for an
83
- intentional decision. This draft cannot clear the gaps it records.
84
-
85
- | ID | Candidate invariant | Basis | Draft review state |
86
- | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------- |
87
- | FR1 | Zero or one child sheet is interactive for each controlled `activeSheet` value; all other children are hidden or retained inert and `aria-hidden` during a transition. | Current source, docs, and tests | Verified shipped behavior |
88
- | FR2 | All child sheets share one native dialog. Opening the first sheet opens that dialog once; handoffs do not replace it. | Current source, docs, and tests | Verified shipped behavior |
89
- | FR3 | During a handoff, the entering sheet is above the retained sheet. A taller retained sheet aligns down to a shorter entering sheet, and the retained sheet fades only after required transform motion completes. | Current source and transition tests | Verified shipped behavior; exact visual treatment remains human-reviewed |
90
- | FR4 | Closing retains the outgoing sheet through its exit, then closes the dialog. A modal flow restores focus to the element captured when that modal flow opened. | Current source and focus tests | Verified shipped behavior |
91
- | FR5 | `hasScrim=true` uses `showModal()`, a native backdrop, focus containment, and page scroll lock. `hasScrim=false` uses `show()` without a backdrop or scroll lock and leaves the page interactive. | Current source, docs, tests, and Chromium evidence | Verified shipped behavior; the non-modal host remains a `spec:AST-027` conformance gap |
92
- | FR6 | The active child purpose governs implicit dismissal: `info` allows Escape, scrim click, and swipe; `form` allows Escape; `required` blocks all three and exposes `alertdialog`. | Current BottomSheet docs and switcher tests | Verified shipped behavior |
93
- | FR7 | While visible, the switcher participates in `family:overlay-dismissal`; one Escape or platform close request reaches only the topmost present layer, and descendants receive a deeper logical layer scope. | `family:overlay-dismissal` and the audit regression test | Settled remediation in this audit |
94
- | FR8 | A stale gesture from an outgoing sheet cannot change the shared scrim, and unmounting a retained sheet cannot keep the shared dialog open. | Current tests | Verified shipped behavior |
95
- | FR9 | The switcher forwards its ref, neutral dialog attributes, styling inputs, and composed handlers to the shared dialog while preserving its owned ARIA and dismissal behavior. | Current source, public API architecture, and tests | Verified shipped behavior |
92
+ | ID | Invariant | Basis |
93
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
94
+ | FR1 | Zero or one child sheet is interactive for each controlled `activeSheet` value; all other children are hidden or retained inert and `aria-hidden` during a transition. | Current source, docs, and tests |
95
+ | FR2 | Each child sheet owns one native dialog. Opening a sheet presents its dialog once; a handoff presents the entering sheet's dialog above the retained one and closes the retained dialog only after its panel has faded. Returning to a sheet whose dialog is still open beneath a newer one re-presents it so the interactive sheet is the topmost layer. Implementation gap: the switcher presents one shared dialog once per flow and hosts every child as a panel inside it; a handoff swaps panels in that dialog, and no dialog is re-presented. | DEC-1 |
96
+ | FR3 | During a handoff, the entering sheet is above the retained sheet. A taller retained sheet aligns down to a shorter entering sheet, and the retained sheet fades only after required transform motion completes. | Current source and transition tests |
97
+ | FR4 | Closing retains the outgoing sheet through its exit, then closes the dialog. A modal flow restores focus to the element captured when that modal flow opened. | Current source and focus tests |
98
+ | FR5 | `hasScrim=true` uses `showModal()`, a native backdrop, and native focus containment, and locks page scroll: the page does not scroll while a modal flow is presented; the lock is held continuously across a handoff, never released and re-acquired between steps; it still holds beneath a `Dialog` opened from a step; it is released after the flow's exit (DEC-1). `hasScrim=false` uses `show()` without a backdrop or scroll lock and leaves the page interactive. In a non-modal flow the sheet dialogs order themselves with local values inside the switcher's isolated flow wrapper (DEC-1); the page-level value that wrapper carries remains a `spec:AST-027` conformance gap. Implementation gap: a script focus trap runs on top of the modal dialog's native containment; a non-modal flow is one shared dialog carrying the page-level value itself, with no flow wrapper. | DEC-1; `spec:AST-027` FR2, FR4, FR8 |
99
+ | FR6 | The active child purpose governs implicit dismissal: `info` allows Escape, scrim click, and swipe; `form` allows Escape; `required` blocks all three and exposes `alertdialog`. | Current BottomSheet docs and switcher tests |
100
+ | FR7 | While visible, the switcher participates in `family:overlay-dismissal`; one Escape or platform close request reaches only the topmost present layer, and descendants receive a deeper logical layer scope. | `family:overlay-dismissal` and the audit regression test |
101
+ | FR8 | One backdrop paints the flow's scrim at a time: the retained sheet's dialog keeps it through a handoff and the entering dialog takes it over without a fade when the retained dialog closes. A stale gesture from an outgoing sheet cannot change the flow's scrim, and unmounting a retained sheet cannot keep its dialog open or the flow's modal layer held. Implementation gap: the one backdrop is the shared dialog's, so no takeover between dialogs happens. | DEC-1; current tests |
102
+ | FR9 | The switcher forwards neutral dialog attributes, styling inputs, and composed handlers, `onCancel` included, to every sheet dialog, so a handler fires for whichever sheet dialog raises the event; renders `id` and `data-testid` on the host dialog only, so each appears once in the document; attaches its ref to the host dialog; and preserves its owned ARIA and dismissal behavior. An object ref's `current` is never `null` while a sheet is mounted. A callback ref follows React's semantics on a handoff: a cleanup it returned runs when it detaches from the old host; a callback ref without a cleanup is called with `null` on detach, then with the new host. Implementation gap: every forwarded input, `ref`, `id`, and `data-testid` reaches the one shared dialog, which never changes on a handoff. | DEC-1, DEC-2; `architecture:public-component-api` INV8 |
96
103
 
97
104
  ### Allowed variation
98
105
 
@@ -104,14 +111,18 @@ intentional decision. This draft cannot clear the gaps it records.
104
111
 
105
112
  ### Representative states
106
113
 
107
- | State | Required invariant | Allowed variation |
108
- | ---------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
109
- | Closed | Shared dialog is closed and no child is interactive. | Child panels may remain mounted but hidden. |
110
- | First open | Matching child is visible and interactive in one shared dialog. | Child-owned height and content. |
111
- | Handoff | Entering child is interactive; retained child is visible but inert and hidden from accessibility APIs until it fades. | Alignment offset depends on measured panel geometry. |
112
- | Closing | Outgoing child is retained inert while exit motion completes; modal focus and scroll ownership remain until close. | Theme-controlled duration. |
113
- | Modal | Native modal host, backdrop, focus containment, and scroll lock are active. | Active child's `purpose` controls implicit dismissal. |
114
- | Non-modal | Native non-modal host has no backdrop or scroll lock and background content remains interactive. | The current clipping/stacking limitation is a conformance gap, not an allowed exception. |
114
+ Where a state names a sheet's own dialog, the retained dialog, or the flow
115
+ wrapper, the shipped switcher meets it with its one shared dialog instead; FR2
116
+ and FR5 record that implementation gap.
117
+
118
+ | State | Required invariant | Allowed variation |
119
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
120
+ | Closed | Every sheet dialog is closed and no child is interactive. | Child panels may remain mounted but hidden. |
121
+ | First open | Matching child is visible and interactive in its own dialog. | Child-owned height and content. |
122
+ | Handoff | Entering child is interactive in its own dialog; retained child's dialog stays open, inert, and hidden from accessibility APIs until it fades, and keeps painting the one scrim. | Alignment offset depends on measured panel geometry. |
123
+ | Closing | Outgoing child is retained inert while exit motion completes; modal focus and scroll ownership remain until close. | Theme-controlled duration. |
124
+ | Modal | Native modal host, backdrop, native focus containment, and the page scroll lock are active. Implementation gap: a script focus trap runs on top of the native containment. | Active child's `purpose` controls implicit dismissal. |
125
+ | Non-modal | Native non-modal host has no backdrop or scroll lock and background content remains interactive. | The current clipping/stacking limitation is a conformance gap, not an allowed exception. |
115
126
 
116
127
  ### Transformation and precedence order
117
128
 
@@ -124,8 +135,13 @@ intentional decision. This draft cannot clear the gaps it records.
124
135
 
125
136
  ### Performance and resources
126
137
 
127
- - **PR1 — One shared host.** A flow keeps one dialog, focus boundary, scroll lock,
128
- and backdrop across child handoffs rather than mounting a host for every child.
138
+ - **PR1 — One painted scrim.** In a modal flow, at rest exactly one sheet
139
+ dialog is presented; during a handoff two are, with one backdrop painted.
140
+ Native modality holds across a modal flow's child handoffs because a sheet
141
+ dialog is modal throughout, and the one page scroll lock is held for the
142
+ whole flow rather than per step. A non-modal flow paints no backdrop and
143
+ holds no lock. Implementation gap: one shared dialog is presented
144
+ throughout, so a handoff has one dialog open, not two.
129
145
  - **PR2 — Stable child registration.** Parent rerenders and changing consumer ref
130
146
  identities do not unregister a mounted child or cancel an active handoff.
131
147
 
@@ -141,11 +157,11 @@ intentional decision. This draft cannot clear the gaps it records.
141
157
 
142
158
  ## Design relationships
143
159
 
144
- | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
145
- | ---------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------ | -------------- | ------------------ |
146
- | Shared dialog | Transparent host for the flow's interaction and accessibility boundary | Current source and public docs | Structural | FR2, FR5, AR1 |
147
- | Sheet panels | Delegate visible panel, content, handle, and gesture presentation to BottomSheet | `component:BottomSheet` draft as observational context | Prominent | FR1, FR3 |
148
- | Scrim | Optional native backdrop that dims and blocks the page in modal presentation | Current source and public docs | Supporting | FR5 |
160
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
161
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -------------- | ------------------ |
162
+ | Shared dialog | Transparent host for the flow's interaction and accessibility boundary, one per child sheet (DEC-1). Implementation gap: one such host serves the whole flow | Current source and public docs; DEC-1 for the per-sheet clause | Structural | FR2, FR5, AR1 |
163
+ | Sheet panels | Delegate visible panel, content, handle, and gesture presentation to BottomSheet | `component:BottomSheet` as observational context | Prominent | FR1, FR3 |
164
+ | Scrim | Optional native backdrop that dims and blocks the page in modal presentation; one sheet dialog paints it at a time (DEC-1) | Current source and public docs; DEC-1 for the one-painter clause | Supporting | FR5, FR8 |
149
165
 
150
166
  ### Theming anatomy
151
167
 
@@ -163,7 +179,7 @@ intentional decision. This draft cannot clear the gaps it records.
163
179
  },
164
180
  "Scrim": {
165
181
  "none": {
166
- "reason": "reachability-gap: The switcher-owned native backdrop paints the scrim but has no current public theming target."
182
+ "reason": "reachability-gap: The switcher-coordinated native backdrop paints the scrim but has no current public theming target."
167
183
  }
168
184
  }
169
185
  }
@@ -179,21 +195,72 @@ intentional decision. This draft cannot clear the gaps it records.
179
195
  - `architecture:public-component-api` and `spec:AST-002` own the released prop,
180
196
  DOM, ref, and compatibility surface.
181
197
  - `spec:AST-027` identifies the non-modal `show()` plus page-level `z-index` path
182
- as a migration gap; this draft does not treat it as an exception.
198
+ as a migration gap, not an exception.
183
199
 
184
200
  ## Verification map
185
201
 
186
- | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
187
- | ---------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
188
- | FR1–FR4, FR8 | `BottomSheetSwitcher.test.tsx` | closed, first open, handoffs in either completion order, rapid replacement, unmount, close | A child becomes interactive at the wrong time, a retained child disappears early, or the shared host lifecycle breaks. | `audit:BottomSheetSwitcher/behavior` |
189
- | FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; Chromium evidence | modal/non-modal, info/form/required, nested layer, IME, focus return | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges. | `audit:BottomSheetSwitcher/accessibility` |
190
- | FR9 | `BottomSheetSwitcher.test.tsx`; export checks | ref, DOM/ARIA/data/events, class, style, xstyle | A supported input is dropped or overrides component-owned semantics accidentally. | `audit:BottomSheetSwitcher/api` |
191
- | Theming anatomy | `scripts/check-knowledge.mjs`; theming tests | dialog, delegated sheet panel, scrim | A non-painting host gains a target, a delegated panel loses its owner, or the scrim gap disappears without review. | `audit:BottomSheetSwitcher/theming` |
202
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
203
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
204
+ | FR1–FR4, FR8 | `BottomSheetSwitcher.test.tsx` | closed, first open, handoffs in either completion order, rapid replacement, unmount, close | A child becomes interactive at the wrong time, a retained child disappears early, a second backdrop paints, or a sheet dialog's lifecycle breaks. | `audit:BottomSheetSwitcher/behavior` |
205
+ | FR5–FR7, AR1–AR3 | `BottomSheetSwitcher.test.tsx`; `BottomSheetNativeModal.a11y.browser.spec.ts` (implementation gap: not yet in the tree) | modal/non-modal, info/form/required, nested layer, IME, focus return, scroll lock in engines, non-modal local ordering | Modality, dismissal order, naming, inertness, scroll lock, or focus behavior diverges, or a sheet dialog takes a page-level z-index. | `audit:BottomSheetSwitcher/accessibility` |
206
+ | FR9 | `BottomSheetSwitcher.test.tsx`; export checks | ref across open/handoff/close/unmount, callback-ref cleanup, `id` once, DOM/ARIA/data/events, class, style, xstyle | A supported input is dropped or duplicated, an object ref is null while a sheet is mounted or skips a cleanup, or an input overrides component-owned semantics accidentally. | `audit:BottomSheetSwitcher/api` |
207
+ | Theming anatomy | `scripts/check-knowledge.mjs`; theming tests | dialog, delegated sheet panel, scrim | A non-painting host gains a target, a delegated panel loses its owner, or the scrim gap disappears without review. | `audit:BottomSheetSwitcher/theming` |
192
208
 
193
209
  ## Decision log
194
210
 
195
- None. This draft records observed behavior and one remediation already required by
196
- current shared authority; it introduces no component-local design or API decision.
211
+ ### DEC-1 — Each step owns its native dialog; the switcher coordinates
212
+
213
+ **Reference:** `component:BottomSheetSwitcher/DEC-1`
214
+ **Decider:** imdreamrunner, 2026-10-20
215
+
216
+ A switcher step is a BottomSheet with its own native dialog, presented and
217
+ closed like a standalone sheet, so one DOM shape serves standalone sheets and
218
+ switcher steps alike and the browser supplies modality per step: an inert
219
+ page, focus containment, `cancel` for Escape and the platform close, and the
220
+ backdrop. The switcher owns only what no single dialog can decide: which dialog
221
+ is presented, retained, or closed; the one scrim handed from the retained
222
+ dialog to the entering one without a fade; re-presenting a sheet that is still
223
+ open beneath a newer one when the flow returns to it; focus kept in the active
224
+ sheet when a retained dialog closes; focus returned to the flow's opener;
225
+ `cancel` gated by the active step's `purpose` through the shared dismissal
226
+ stack; and the flow's one page scroll lock, held from the moment a modal flow
227
+ is presented until after its exit, so a handoff between two open dialogs never
228
+ releases and re-acquires it. The switcher's one element is a flow wrapper with
229
+ no box in a modal flow and, in a non-modal flow, the isolated surface inside
230
+ which the sheet dialogs order themselves with local values.
231
+ Rejected: one switcher-owned dialog hosting every step as a panel, because a
232
+ step is then not a sheet and every standalone-sheet mechanism needs a second,
233
+ switcher-only implementation.
234
+ Rejected: a script focus trap on top of native containment, because the
235
+ browser already contains focus in a modal dialog and the trap fights its
236
+ inertness.
237
+ Rejected: a page-level `z-index` on a non-modal step's dialog, because
238
+ `spec:AST-027` FR4 forbids a new page band; the wrapper is the isolation owner.
239
+
240
+ ### DEC-2 — The host dialog's identity is implementation, not contract
241
+
242
+ **Reference:** `component:BottomSheetSwitcher/DEC-2`
243
+ **Decider:** imdreamrunner, 2026-10-20
244
+
245
+ The `ref`, `id`, and `data-testid` of the switcher reach the dialog hosting
246
+ the flow: the active sheet's, the exiting sheet's through its exit, and, while
247
+ the flow is closed, the dialog that hosted it last or the first mounted
248
+ sheet's, so an object ref is never `null` while a sheet is mounted and the
249
+ identifying attributes appear once. Which element that is, and that it changes
250
+ on a handoff, is implementation. The released documentation (v0.7.1
251
+ `BottomSheetSwitcher.doc.mjs`) describes the ref as "Ref forwarded to the one
252
+ shared native dialog." and the switcher as "Its ref and shared DOM props
253
+ target that dialog." Code that copies `ref.current` once at mount keeps a
254
+ real `<dialog>`, but after a handoff that element may no longer be the host;
255
+ read `ref.current` at use time, or use a callback ref, to follow the flow. DOM
256
+ props and handlers given to the switcher (`className`, `style`, `onCancel`,
257
+ `onToggle`, `aria-*`, and the rest) reach every sheet dialog, with `id` and
258
+ `data-testid` on the host only, so a handler fires for whichever sheet dialog
259
+ raises the event. The owner rules this hosting — the moving host and the
260
+ per-dialog props alike — nonbreaking under `spec:AST-017` FR3 because no
261
+ visual output and no public API surface changes.
262
+ Rejected: classifying the moving ref as a breaking change, because no public
263
+ API, documented interaction, or visual output changes.
197
264
 
198
265
  ## Open questions
199
266
 
@@ -708,6 +708,32 @@ describe('BottomSheetSwitcher', () => {
708
708
  expect(onActiveSheetChange).toHaveBeenNthCalledWith(2, null);
709
709
  });
710
710
 
711
+ it('keeps the flow open when a file picker inside a step is dismissed, and still closes on its own close request', () => {
712
+ // A file input fires a bubbling `cancel` at itself when its picker is
713
+ // dismissed. A step renders no dialog, so it reaches the switcher's.
714
+ const onActiveSheetChange = vi.fn();
715
+ render(
716
+ <BottomSheetSwitcher
717
+ activeSheet="upload"
718
+ onActiveSheetChange={onActiveSheetChange}>
719
+ <BottomSheet sheetId="upload" label="Upload" purpose="form">
720
+ <input type="file" aria-label="Picture" />
721
+ </BottomSheet>
722
+ </BottomSheetSwitcher>,
723
+ );
724
+
725
+ const fromPicker = new Event('cancel', {bubbles: true, cancelable: true});
726
+ fireEvent(screen.getByLabelText('Picture'), fromPicker);
727
+
728
+ expect(onActiveSheetChange).not.toHaveBeenCalled();
729
+ expect(fromPicker.defaultPrevented).toBe(false);
730
+
731
+ fireEvent(getSharedDialog(), new Event('cancel', {cancelable: true}));
732
+
733
+ expect(onActiveSheetChange).toHaveBeenCalledTimes(1);
734
+ expect(onActiveSheetChange).toHaveBeenCalledWith(null);
735
+ });
736
+
711
737
  it('honors purpose=required for a switcher-managed sheet', () => {
712
738
  const onActiveSheetChange = vi.fn();
713
739
  render(
@@ -591,8 +591,14 @@ export function BottomSheetSwitcher({
591
591
  ? undefined
592
592
  : sheetLabels.get(visibleTransition.retainedSheet));
593
593
 
594
+ // A `cancel` that bubbles up from a step is not a close request for the
595
+ // flow: a file input fires one when its picker is dismissed, and a step
596
+ // renders no dialog of its own, so it lands here. It is left alone.
594
597
  const handleCancel = useCallback(
595
598
  (event: SyntheticEvent<HTMLDialogElement>) => {
599
+ if (event.target !== event.currentTarget) {
600
+ return;
601
+ }
596
602
  event.preventDefault();
597
603
  if (shouldDismissOnCloseRequest()) {
598
604
  dismissOnEscape();
@@ -382,25 +382,35 @@ describe('Calendar', () => {
382
382
  // ─── Date Constraints ────────────────────────────────────────
383
383
 
384
384
  it('respects min date constraint', () => {
385
- render(<Calendar focusDate="2026-01-01" min="2026-01-10" />);
385
+ const onChange = vi.fn();
386
+ render(
387
+ <Calendar focusDate="2026-01-01" min="2026-01-10" onChange={onChange} />,
388
+ );
386
389
 
387
390
  // Day 5 should be disabled (before min)
388
391
  const day5 = getDayButton(5);
389
- expect(day5).toBeDisabled();
392
+ expect(day5).toHaveAttribute('aria-disabled', 'true');
393
+ // spec:AST-085 FR1: focusable through aria-disabled, not natively disabled;
394
+ // a click still selects nothing.
395
+ expect(day5).not.toHaveAttribute('disabled');
396
+ day5.focus();
397
+ expect(day5).toHaveFocus();
398
+ day5.click();
399
+ expect(onChange).not.toHaveBeenCalled();
390
400
 
391
401
  // Day 15 should be enabled (after min)
392
402
  const day15 = getDayButton(15);
393
- expect(day15).not.toBeDisabled();
403
+ expect(day15).not.toHaveAttribute('aria-disabled', 'true');
394
404
  });
395
405
 
396
406
  it('respects max date constraint', () => {
397
407
  render(<Calendar focusDate="2026-01-01" max="2026-01-20" />);
398
408
 
399
409
  const day25 = getDayButton(25);
400
- expect(day25).toBeDisabled();
410
+ expect(day25).toHaveAttribute('aria-disabled', 'true');
401
411
 
402
412
  const day15 = getDayButton(15);
403
- expect(day15).not.toBeDisabled();
413
+ expect(day15).not.toHaveAttribute('aria-disabled', 'true');
404
414
  });
405
415
 
406
416
  // ─── Initial Visible Month ───────────────────────────────────
@@ -424,14 +434,20 @@ describe('Calendar', () => {
424
434
  render(<Calendar min="2027-03-04" max="2027-06-30" />);
425
435
 
426
436
  expect(screen.getByText('March 2027')).toBeInTheDocument();
427
- expect(getDayButton(4, 'March', 2027)).not.toBeDisabled();
437
+ expect(getDayButton(4, 'March', 2027)).not.toHaveAttribute(
438
+ 'aria-disabled',
439
+ 'true',
440
+ );
428
441
  });
429
442
 
430
443
  it('opens on max when the window is entirely in the past', () => {
431
444
  render(<Calendar min="2019-01-01" max="2019-04-30" />);
432
445
 
433
446
  expect(screen.getByText('April 2019')).toBeInTheDocument();
434
- expect(getDayButton(30, 'April', 2019)).not.toBeDisabled();
447
+ expect(getDayButton(30, 'April', 2019)).not.toHaveAttribute(
448
+ 'aria-disabled',
449
+ 'true',
450
+ );
435
451
  });
436
452
 
437
453
  it('keeps max in the last pane in the two-month layout', () => {
@@ -466,7 +482,7 @@ describe('Calendar', () => {
466
482
 
467
483
  // January 4, 2026 is a Sunday - should be disabled
468
484
  const sunday = getDayButton(4);
469
- expect(sunday).toBeDisabled();
485
+ expect(sunday).toHaveAttribute('aria-disabled', 'true');
470
486
  });
471
487
 
472
488
  it('caps the end date to maxRangeSpan once a start is picked', async () => {
@@ -475,15 +491,43 @@ describe('Calendar', () => {
475
491
  render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
476
492
 
477
493
  // Before a start is picked every day is selectable.
478
- expect(getDayButton(20)).not.toBeDisabled();
494
+ expect(getDayButton(20)).not.toHaveAttribute('aria-disabled', 'true');
479
495
 
480
496
  // Pick Jan 10 as the start. A 7-day window spans Jan 4–16 (start ± 6).
481
497
  await user.click(getDayButton(10));
482
498
 
483
- expect(getDayButton(16)).not.toBeDisabled(); // 6 days after — the edge
484
- expect(getDayButton(17)).toBeDisabled(); // 7 days after — beyond the cap
485
- expect(getDayButton(4)).not.toBeDisabled(); // 6 days before — symmetric
486
- expect(getDayButton(3)).toBeDisabled(); // 7 days before — beyond the cap
499
+ expect(getDayButton(16)).not.toHaveAttribute('aria-disabled', 'true'); // 6 days after — the edge
500
+ expect(getDayButton(17)).toHaveAttribute('aria-disabled', 'true'); // 7 days after — beyond the cap
501
+ expect(getDayButton(4)).not.toHaveAttribute('aria-disabled', 'true'); // 6 days before — symmetric
502
+ expect(getDayButton(3)).toHaveAttribute('aria-disabled', 'true'); // 7 days before — beyond the cap
503
+ });
504
+
505
+ it('keeps one Tab stop when the span cap disables the day that held it', async () => {
506
+ const user = userEvent.setup();
507
+
508
+ render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
509
+
510
+ // Arrow from Jan 1 to Jan 25 so Jan 25 holds the grid's Tab stop.
511
+ getDayButton(1).focus();
512
+ await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}');
513
+ for (let i = 0; i < 3; i += 1) {
514
+ await user.keyboard('{ArrowRight}');
515
+ }
516
+ expect(getDayButton(25)).toHaveFocus();
517
+
518
+ // Picking Jan 10 as the start disables Jan 25 (beyond the 7-day cap).
519
+ await user.click(getDayButton(10));
520
+ expect(getDayButton(25)).toHaveAttribute('aria-disabled', 'true');
521
+
522
+ // spec:AST-085 FR1: a disabled day stays focusable but never keeps the
523
+ // grid's Tab stop, so the grid still has exactly one, on an enabled day.
524
+ const grid = screen.getByRole('grid');
525
+ const stops = Array.from(
526
+ grid.querySelectorAll<HTMLElement>('button[data-date][tabindex="0"]'),
527
+ );
528
+ expect(stops).toHaveLength(1);
529
+ expect(stops[0]).not.toHaveAttribute('aria-disabled', 'true');
530
+ expect(getDayButton(25)).toHaveAttribute('tabindex', '-1');
487
531
  });
488
532
 
489
533
  it('enforces minRangeSpan once a start is picked', async () => {
@@ -496,11 +540,11 @@ describe('Calendar', () => {
496
540
 
497
541
  // The picked start itself stays enabled — it is the active selection
498
542
  // anchor, not an unreachable end.
499
- expect(getDayButton(10)).not.toBeDisabled();
500
- expect(getDayButton(11)).toBeDisabled(); // 1 day apart — too short
501
- expect(getDayButton(12)).not.toBeDisabled(); // 3-day span — allowed
502
- expect(getDayButton(8)).not.toBeDisabled(); // 3-day span the other way
503
- expect(getDayButton(9)).toBeDisabled(); // 2-day span — too short
543
+ expect(getDayButton(10)).not.toHaveAttribute('aria-disabled', 'true');
544
+ expect(getDayButton(11)).toHaveAttribute('aria-disabled', 'true'); // 1 day apart — too short
545
+ expect(getDayButton(12)).not.toHaveAttribute('aria-disabled', 'true'); // 3-day span — allowed
546
+ expect(getDayButton(8)).not.toHaveAttribute('aria-disabled', 'true'); // 3-day span the other way
547
+ expect(getDayButton(9)).toHaveAttribute('aria-disabled', 'true'); // 2-day span — too short
504
548
  });
505
549
 
506
550
  it('commits a same-day range with the default minimum', async () => {
@@ -565,8 +609,8 @@ describe('Calendar', () => {
565
609
 
566
610
  // …and the start is cleared: the days that minRangeSpan disabled around
567
611
  // the old anchor are selectable again, so a new start can be placed there.
568
- expect(getDayButton(11)).not.toBeDisabled();
569
- expect(getDayButton(9)).not.toBeDisabled();
612
+ expect(getDayButton(11)).not.toHaveAttribute('aria-disabled', 'true');
613
+ expect(getDayButton(9)).not.toHaveAttribute('aria-disabled', 'true');
570
614
  });
571
615
 
572
616
  it('announces the cleared range in the provider locale', async () => {
@@ -603,7 +647,7 @@ describe('Calendar', () => {
603
647
 
604
648
  // A picked single date must not disable far-away days.
605
649
  await user.click(getDayButton(10));
606
- expect(getDayButton(25)).not.toBeDisabled();
650
+ expect(getDayButton(25)).not.toHaveAttribute('aria-disabled', 'true');
607
651
  });
608
652
 
609
653
  it('keeps a controlled value that is wider than maxRangeSpan (selection-only)', () => {
@@ -621,7 +665,7 @@ describe('Calendar', () => {
621
665
 
622
666
  // No selection is in progress (both endpoints are set), so no anchor →
623
667
  // every day is pickable and both endpoints render selected.
624
- expect(getDayButton(20)).not.toBeDisabled();
668
+ expect(getDayButton(20)).not.toHaveAttribute('aria-disabled', 'true');
625
669
  expect(getDayButton(5).closest('[role="gridcell"]')).toHaveAttribute(
626
670
  'aria-selected',
627
671
  'true',
@@ -1713,7 +1757,7 @@ describe('Calendar', () => {
1713
1757
  render(<Calendar focusDate="2026-01-01" min="2026-01-05" />);
1714
1758
 
1715
1759
  const day1 = getDayButton(1);
1716
- expect(day1).toBeDisabled();
1760
+ expect(day1).toHaveAttribute('aria-disabled', 'true');
1717
1761
 
1718
1762
  const day8 = getDayButton(8);
1719
1763
  day8.focus();
@@ -1741,7 +1785,7 @@ describe('Calendar', () => {
1741
1785
  render(<Calendar focusDate="2026-01-01" dateConstraints={[disableJan8]} />);
1742
1786
 
1743
1787
  const day8 = getDayButton(8);
1744
- expect(day8).toBeDisabled();
1788
+ expect(day8).toHaveAttribute('aria-disabled', 'true');
1745
1789
 
1746
1790
  const day15 = getDayButton(15);
1747
1791
  day15.focus();
@@ -1372,7 +1372,8 @@ function DayCell({
1372
1372
  // Mark today's cell programmatically (APG date-picker pattern), not just
1373
1373
  // visually, so screen-reader users can identify the current date.
1374
1374
  aria-current={state.isToday ? 'date' : undefined}
1375
- disabled={isDisabled}
1375
+ // A disabled day stays focusable through aria-disabled (spec:AST-085
1376
+ // FR1); arrows still skip it and its click and hover are guarded.
1376
1377
  // Initial roving tab-stop seed; useGridFocus owns it after mount.
1377
1378
  tabIndex={isTabbableDay ? 0 : -1}
1378
1379
  onClick={() => !state.effectivelyDisabled && onDayClick(date)}