@astryxdesign/core 0.6.0 → 0.6.1

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 (203) hide show
  1. package/CHANGELOG.md +41 -3
  2. package/dist/AppShell/AppShell.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +12 -1
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  5. package/dist/BottomSheet/BottomSheetSwitcher.js +44 -15
  6. package/dist/Breadcrumbs/BreadcrumbItem.d.ts +3 -2
  7. package/dist/Breadcrumbs/BreadcrumbItem.d.ts.map +1 -1
  8. package/dist/Breadcrumbs/BreadcrumbItem.js +3 -7
  9. package/dist/Center/Center.d.ts +23 -16
  10. package/dist/Center/Center.d.ts.map +1 -1
  11. package/dist/Center/Center.js +7 -5
  12. package/dist/CodeBlock/CodeBlock.js +2 -2
  13. package/dist/DateInput/DateInput.d.ts.map +1 -1
  14. package/dist/DateInput/DateInput.js +12 -2
  15. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  16. package/dist/DateTimeInput/DateTimeInput.js +12 -2
  17. package/dist/Field/Field.d.ts.map +1 -1
  18. package/dist/Field/Field.js +1 -0
  19. package/dist/Field/InputClearButton.d.ts +2 -2
  20. package/dist/Field/InputClearButton.d.ts.map +1 -1
  21. package/dist/Field/InputClearButton.js +5 -1
  22. package/dist/Field/PanelSearchInput.d.ts.map +1 -1
  23. package/dist/Field/PanelSearchInput.js +16 -4
  24. package/dist/FileInput/FileInput.d.ts.map +1 -1
  25. package/dist/FileInput/FileInput.js +12 -1
  26. package/dist/HoverCard/useHoverCard.js +2 -2
  27. package/dist/Indicator/CheckboxIndicator.js +2 -2
  28. package/dist/Indicator/RadioIndicator.js +2 -2
  29. package/dist/Layer/layerStack.d.ts +10 -0
  30. package/dist/Layer/layerStack.d.ts.map +1 -1
  31. package/dist/Layer/layerStack.js +21 -9
  32. package/dist/Layer/useLayerDismissal.d.ts +2 -3
  33. package/dist/Layer/useLayerDismissal.d.ts.map +1 -1
  34. package/dist/Layer/useLayerDismissal.js +2 -3
  35. package/dist/NavIcon/NavIcon.js +2 -2
  36. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  37. package/dist/NumberInput/NumberInput.js +12 -2
  38. package/dist/Popover/usePopover.d.ts +3 -2
  39. package/dist/Popover/usePopover.d.ts.map +1 -1
  40. package/dist/Popover/usePopover.js +4 -2
  41. package/dist/ProgressBar/ProgressBar.js +2 -2
  42. package/dist/ScrollableArea/ScrollableArea.d.ts +79 -0
  43. package/dist/ScrollableArea/ScrollableArea.d.ts.map +1 -0
  44. package/dist/ScrollableArea/ScrollableArea.js +144 -0
  45. package/dist/ScrollableArea/index.d.ts +11 -0
  46. package/dist/ScrollableArea/index.d.ts.map +1 -0
  47. package/dist/ScrollableArea/index.js +11 -0
  48. package/dist/StatusDot/StatusDot.js +2 -2
  49. package/dist/TextArea/TextArea.js +2 -2
  50. package/dist/TextInput/TextInput.d.ts.map +1 -1
  51. package/dist/TextInput/TextInput.js +19 -4
  52. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  53. package/dist/TimeInput/TimeInput.js +12 -2
  54. package/dist/Typeahead/BaseTypeahead.d.ts +21 -14
  55. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/BaseTypeahead.js +56 -20
  57. package/dist/astryx.css +15 -0
  58. package/dist/hooks/index.d.ts +2 -0
  59. package/dist/hooks/index.d.ts.map +1 -1
  60. package/dist/hooks/index.js +1 -0
  61. package/dist/hooks/scrollGeometry.d.ts +24 -0
  62. package/dist/hooks/scrollGeometry.d.ts.map +1 -0
  63. package/dist/hooks/scrollGeometry.js +86 -0
  64. package/dist/hooks/scrollOwnerRegistry.d.ts +15 -0
  65. package/dist/hooks/scrollOwnerRegistry.d.ts.map +1 -0
  66. package/dist/hooks/scrollOwnerRegistry.js +24 -0
  67. package/dist/hooks/useFocusTrap.d.ts +8 -0
  68. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  69. package/dist/hooks/useFocusTrap.js +22 -11
  70. package/dist/hooks/useScrollableArea.d.ts +51 -0
  71. package/dist/hooks/useScrollableArea.d.ts.map +1 -0
  72. package/dist/hooks/useScrollableArea.js +287 -0
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -0
  76. package/dist/theme/defineTheme.d.ts +2 -6
  77. package/dist/theme/defineTheme.d.ts.map +1 -1
  78. package/dist/theme/defineTheme.js +1 -1
  79. package/dist/theme/derivedVarRegistry.js +1 -1
  80. package/dist/theme/localTokens.d.ts +8 -11
  81. package/dist/theme/localTokens.d.ts.map +1 -1
  82. package/dist/theme/localTokens.js +17 -71
  83. package/dist/theme/themeAdaptations.d.ts.map +1 -1
  84. package/dist/theme/themeAdaptations.js +4 -4
  85. package/dist/utils/themeProps.d.ts +10 -10
  86. package/dist/utils/themeProps.d.ts.map +1 -1
  87. package/dist/utils/themeProps.js +27 -10
  88. package/locales/en.json +16 -0
  89. package/locales/pseudo.json +12 -0
  90. package/package.json +7 -2
  91. package/src/AppShell/AppShell.test.tsx +36 -0
  92. package/src/AppShell/AppShell.tsx +4 -1
  93. package/src/AspectRatio/AspectRatio.doc.mjs +3 -3
  94. package/src/Banner/Banner.test.tsx +3 -1
  95. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +56 -1
  96. package/src/BottomSheet/BottomSheetSwitcher.spec.md +211 -0
  97. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +134 -2
  98. package/src/BottomSheet/BottomSheetSwitcher.tsx +43 -20
  99. package/src/Breadcrumbs/BreadcrumbItem.doc.mjs +10 -5
  100. package/src/Breadcrumbs/BreadcrumbItem.spec.md +225 -0
  101. package/src/Breadcrumbs/BreadcrumbItem.tsx +8 -13
  102. package/src/Breadcrumbs/Breadcrumbs.doc.mjs +2 -2
  103. package/src/Breadcrumbs/Breadcrumbs.test.tsx +49 -2
  104. package/src/Center/Center.doc.mjs +32 -28
  105. package/src/Center/Center.spec.md +225 -0
  106. package/src/Center/Center.test.tsx +42 -4
  107. package/src/Center/Center.tsx +24 -17
  108. package/src/Chat/ChatSystemMessage.test.tsx +2 -9
  109. package/src/CodeBlock/CodeBlock.doc.mjs +2 -2
  110. package/src/CodeBlock/CodeBlock.tsx +2 -2
  111. package/src/DateInput/DateInput.test.tsx +4 -4
  112. package/src/DateInput/DateInput.tsx +15 -4
  113. package/src/DateRangeInput/DateRangeInput.test.tsx +2 -2
  114. package/src/DateTimeInput/DateTimeInput.test.tsx +6 -4
  115. package/src/DateTimeInput/DateTimeInput.tsx +18 -7
  116. package/src/DropdownMenu/DropdownMenuSelectable.test.tsx +4 -77
  117. package/src/Field/Field.test.tsx +42 -0
  118. package/src/Field/Field.tsx +6 -0
  119. package/src/Field/InputClearButton.test.tsx +35 -1
  120. package/src/Field/InputClearButton.tsx +7 -3
  121. package/src/Field/PanelSearchInput.tsx +21 -8
  122. package/src/FieldStatus/FieldStatus.spec.md +27 -17
  123. package/src/FieldStatus/FieldStatus.test.tsx +7 -5
  124. package/src/FieldStatus/__tests__/StatusMessage.a11y.chromium.spec.ts +198 -0
  125. package/src/FieldStatus/__tests__/StatusMessage.a11y.known-failures.ts +13 -0
  126. package/src/FieldStatus/__tests__/StatusMessage.a11y.renders.tsx +305 -0
  127. package/src/FieldStatus/__tests__/StatusMessage.a11y.states.ts +317 -0
  128. package/src/FieldStatus/__tests__/StatusMessage.a11y.test.tsx +155 -0
  129. package/src/FileInput/FileInput.tsx +10 -1
  130. package/src/FormLayout/__snapshots__/FormLayout.test.tsx.snap +3 -3
  131. package/src/HoverCard/HoverCard.doc.mjs +4 -4
  132. package/src/HoverCard/useHoverCard.tsx +2 -2
  133. package/src/Indicator/CheckboxIndicator.tsx +2 -2
  134. package/src/Indicator/Indicator.doc.mjs +2 -2
  135. package/src/Indicator/Indicator.test.tsx +1 -1
  136. package/src/Indicator/RadioIndicator.tsx +2 -2
  137. package/src/Layer/layerStack.ts +20 -9
  138. package/src/Layer/useLayerDismissal.ts +2 -3
  139. package/src/MultiSelector/MultiSelector.test.tsx +4 -4
  140. package/src/NavIcon/NavIcon.doc.mjs +4 -4
  141. package/src/NavIcon/NavIcon.tsx +2 -2
  142. package/src/NumberInput/NumberInput.tsx +18 -7
  143. package/src/Popover/Popover.doc.mjs +10 -10
  144. package/src/Popover/Popover.spec.md +55 -65
  145. package/src/Popover/Popover.test.tsx +29 -0
  146. package/src/Popover/usePopover.doc.mjs +4 -4
  147. package/src/Popover/usePopover.tsx +7 -4
  148. package/src/ProgressBar/ProgressBar.doc.mjs +4 -4
  149. package/src/ProgressBar/ProgressBar.test.tsx +1 -31
  150. package/src/ProgressBar/ProgressBar.tsx +2 -2
  151. package/src/RadioList/RadioList.test.tsx +5 -144
  152. package/src/RadioList/__tests__/RadioGroup.a11y.chromium.spec.ts +255 -0
  153. package/src/RadioList/__tests__/RadioGroup.a11y.known-failures.ts +12 -0
  154. package/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx +232 -0
  155. package/src/RadioList/__tests__/RadioGroup.a11y.states.ts +503 -0
  156. package/src/RadioList/__tests__/RadioGroup.a11y.test.tsx +217 -0
  157. package/src/ScrollableArea/ScrollableArea.doc.mjs +100 -0
  158. package/src/ScrollableArea/ScrollableArea.spec.md +189 -0
  159. package/src/ScrollableArea/ScrollableArea.test.tsx +299 -0
  160. package/src/ScrollableArea/ScrollableArea.tsx +259 -0
  161. package/src/ScrollableArea/index.ts +26 -0
  162. package/src/ScrollableArea/modules/useScrollableArea.spec.md +121 -0
  163. package/src/SegmentedControl/SegmentedControl.test.tsx +5 -172
  164. package/src/Selector/Selector.test.tsx +4 -4
  165. package/src/Spinner/Spinner.test.tsx +0 -18
  166. package/src/StatusDot/StatusDot.doc.mjs +4 -4
  167. package/src/StatusDot/StatusDot.tsx +2 -2
  168. package/src/TabList/TabList.test.tsx +5 -9
  169. package/src/TabList/__tests__/Tabs.a11y.chromium.spec.ts +191 -0
  170. package/src/TabList/__tests__/Tabs.a11y.known-failures.ts +45 -0
  171. package/src/TabList/__tests__/Tabs.a11y.renders.tsx +92 -0
  172. package/src/TabList/__tests__/Tabs.a11y.states.ts +247 -0
  173. package/src/TabList/__tests__/Tabs.a11y.test.tsx +153 -0
  174. package/src/Table/Table.doc.mjs +2 -2
  175. package/src/TextArea/TextArea.doc.mjs +4 -4
  176. package/src/TextArea/TextArea.tsx +2 -2
  177. package/src/TextInput/TextInput.doc.mjs +2 -1
  178. package/src/TextInput/TextInput.test.tsx +94 -0
  179. package/src/TextInput/TextInput.tsx +22 -6
  180. package/src/TimeInput/TimeInput.tsx +18 -7
  181. package/src/Toast/ToastViewport.test.tsx +1 -39
  182. package/src/Typeahead/BaseTypeahead.doc.mjs +229 -33
  183. package/src/Typeahead/BaseTypeahead.spec.md +269 -0
  184. package/src/Typeahead/BaseTypeahead.test.tsx +200 -0
  185. package/src/Typeahead/BaseTypeahead.tsx +99 -30
  186. package/src/hooks/index.ts +13 -0
  187. package/src/hooks/scrollGeometry.ts +155 -0
  188. package/src/hooks/scrollOwnerRegistry.ts +47 -0
  189. package/src/hooks/useFocusTrap.ts +22 -11
  190. package/src/hooks/useFocusTrapEscapeShim.test.tsx +4 -3
  191. package/src/hooks/useScrollableArea.doc.mjs +108 -0
  192. package/src/hooks/useScrollableArea.test.tsx +437 -0
  193. package/src/hooks/useScrollableArea.ts +469 -0
  194. package/src/index.ts +1 -0
  195. package/src/theme/defineTheme.test.ts +65 -105
  196. package/src/theme/defineTheme.ts +3 -9
  197. package/src/theme/derivedVarRegistry.ts +1 -1
  198. package/src/theme/localTokens.ts +25 -96
  199. package/src/theme/publicThemeHelperContract.test.ts +2 -2
  200. package/src/theme/themeAdaptations.test.ts +16 -42
  201. package/src/theme/themeAdaptations.ts +6 -9
  202. package/src/utils/themeProps.test.ts +29 -10
  203. package/src/utils/themeProps.ts +36 -17
@@ -0,0 +1,121 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 1
4
+ kind: module
5
+ id: module:ScrollableArea/useScrollableArea
6
+ authority: current
7
+ archive_reason: null
8
+ superseded_by: null
9
+ approved_by: cixzhang
10
+ approved_at: 2026-09-11
11
+ owners: [cixzhang]
12
+ review_triggers: [public-api, behavior, accessibility]
13
+ verified_by: [packages/core/src/hooks/useScrollableArea.test.tsx]
14
+ parent_component: component:ScrollableArea
15
+ references: [architecture:public-component-api, spec:AST-025/DEC-1]
16
+ ---
17
+
18
+ # useScrollableArea module contract
19
+
20
+ ## Intent
21
+
22
+ useScrollableArea is the canonical reusable behavior core for components that
23
+ already own a viewport and content root. It provides safe prop/ref composition,
24
+ logical-axis measurement, live owner registration, edge state, keyboard access,
25
+ and scroll chaining without inserting structure.
26
+
27
+ ## Compatibility and migration
28
+
29
+ - Released default preserved: not yet released
30
+ - Compatibility class: additive public hook and types
31
+ - Migration decision: `spec:AST-025/DEC-1`
32
+
33
+ ## Ownership boundary
34
+
35
+ **Owns**
36
+
37
+ - Effective-axis measurement and stable logical-edge state.
38
+ - Observation, invalidation, DOM owner registration, conditional viewport access,
39
+ and effective-axis chaining.
40
+ - Safe composition of caller viewport/content props and refs.
41
+
42
+ **Does not own / non-goals**
43
+
44
+ - Viewport layout, paint, overflow declarations, scrollbar presentation, or
45
+ insertion of a content wrapper.
46
+ - Sticky positioning and public owner lookup.
47
+
48
+ ## Public API and concepts
49
+
50
+ | Concept | Closed values or states | Meaning | Default | Owner | Stability |
51
+ | -------------- | ---------------------------------- | ------------------------------------- | ------------------------- | ----------------------------------------- | --------- |
52
+ | axis | `inline`, `block`, `both` | requested logical scroll intent | required | `spec:AST-025` | stable |
53
+ | keyboard owner | `content`, named `viewport` | where keyboard scrolling is reached | required | `spec:AST-025` | stable |
54
+ | overscroll | `allow`, `contain` | edge propagation on effective axes | `allow` | `spec:AST-025` | stable |
55
+ | axis state | `isScrollable`, `atStart`, `atEnd` | effective ownership and logical edges | inactive, both edges true | `module:ScrollableArea/useScrollableArea` | stable |
56
+
57
+ ## Behavioral contract
58
+
59
+ | ID | Invariant | Basis | Acceptance and implementation state |
60
+ | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | --------------------------------------- |
61
+ | FR1 | An axis MUST be effective only when requested, computed overflow is scroll-capable, and geometry exceeds 1px. | `spec:AST-025` FR1–FR4 | implemented |
62
+ | FR2 | Viewport and content geometry MUST be observed; invalidations MUST coalesce and preserve the last valid state while unmeasurable. | `spec:AST-025` FR5–FR9 | implemented |
63
+ | FR3 | Logical edges and behavior-owned physical overflow MUST map through direction and horizontal, vertical, or sideways writing modes without requiring adopters to recreate the mapping. | `spec:AST-025` FR1, FR3–FR4 | implemented |
64
+ | FR4 | The nearest registered effective owner MUST win independently by axis. | `spec:AST-025` FR10, FR18 | implemented privately for future Sticky |
65
+ | FR5 | Viewport keyboard props and overscroll containment MUST appear only while applicable requested axes are effective. | `spec:AST-025` FR12–FR14 | implemented |
66
+ | FR6 | Prop getters MUST compose refs and preserve caller handlers/classes/styles/ARIA while behavior-owned accessibility and non-cancellable behavior win conflicts. | `spec:AST-025` IR4 | implemented |
67
+ | FR7 | The viewport prop getter MUST consume caller `xstyle` and internally compose geometry-driven `clip`/`auto`/`hidden` overflow plus explicit fitting Sticky containment. | `spec:AST-025` FR21 | implemented |
68
+
69
+ ### Transformation and precedence order
70
+
71
+ - **ORD1 — Effective state.** requested logical axis → computed physical axis →
72
+ scroll-capable overflow → geometry tolerance → normalized logical edge.
73
+ - **ORD2 — Prop composition.** caller props and `xstyle` → behavior-owned
74
+ fitting/active overflow style → stable composed ref → behavior-owned keyboard,
75
+ state, and chaining outputs.
76
+
77
+ ### Performance and resources
78
+
79
+ - **PR1 — Stable publication.** Equal effective/edge state reuses the prior object.
80
+ - **PR2 — Shared observation.** Both element boxes use the shared ResizeObserver;
81
+ all secondary invalidation signals coalesce through one animation frame.
82
+
83
+ ## Accessibility contract
84
+
85
+ - **AR1 — Named viewport owner.** Viewport-owned access requires a label and adds
86
+ the viewport to the tab order only while an applicable axis is effective.
87
+ - **AR2 — Focus continuity.** State changes never move or blur focus.
88
+
89
+ ## Design relationships
90
+
91
+ No visual representation is owned by this hook.
92
+
93
+ ## Parent and system relationships
94
+
95
+ - `component:ScrollableArea` owns the reference viewport/content composition.
96
+ - `spec:AST-025` owns the shared behavior and adoption boundary.
97
+
98
+ ## Verification map
99
+
100
+ | Contract | Verification | Representative states | Mutation or failure expectation |
101
+ | ---------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
102
+ | FR1–FR6, AR1–AR2 | `useScrollableArea.test.tsx` plus real-browser Storybook probe | horizontal/vertical/sideways, LTR/RTL, fit/overflow/edge, nested owners, focused loss | style-only or geometry-only ownership, physical-edge leakage, stale state, ref loss, or dead containment fails |
103
+
104
+ ## Decision log
105
+
106
+ ### DEC-1 — Prop getters are the public composition seam
107
+
108
+ **Reference:** `module:ScrollableArea/useScrollableArea/DEC-1`
109
+ **Decider:** cixzhang, 2026-09-11
110
+
111
+ One returned prop object per owned element keeps refs, accessibility, handlers,
112
+ and behavior together without making consumer spread order part of correctness.
113
+
114
+ ## Open questions
115
+
116
+ None.
117
+
118
+ ## Content boundary
119
+
120
+ This file does not duplicate consumer signatures/examples, ScrollableArea's
121
+ structure/presentation, or Sticky's future API and positioning mechanism.
@@ -3,8 +3,11 @@
3
3
  /**
4
4
  * @file SegmentedControl.test.tsx
5
5
  * @input Uses vitest, @testing-library/react, SegmentedControl components
6
- * @output Unit tests for SegmentedControl and SegmentedControlItem
7
- * @position Testing; validates SegmentedControl component implementation
6
+ * @output Component-specific callback, composition, layout, optional-key, and
7
+ * styling tests. Shared radio-group role, name, state, focus, and adopted
8
+ * interactions live in RadioList/__tests__/RadioGroup.a11y.*.
9
+ * @position Component-owned regression tests that do not duplicate the reusable
10
+ * radio-group contract.
8
11
  *
9
12
  * SYNC: When SegmentedControl components change, update tests to match new behavior
10
13
  */
@@ -47,41 +50,6 @@ beforeEach(() => {
47
50
  });
48
51
 
49
52
  describe('SegmentedControl', () => {
50
- it('renders a radiogroup with radio buttons', () => {
51
- render(
52
- <SegmentedControl value="grid" onChange={() => {}} label="View mode">
53
- <SegmentedControlItem value="grid" label="Grid" />
54
- <SegmentedControlItem value="list" label="List" />
55
- </SegmentedControl>,
56
- );
57
-
58
- expect(screen.getByRole('radiogroup')).toBeInTheDocument();
59
- expect(screen.getByRole('radiogroup')).toHaveAttribute(
60
- 'aria-label',
61
- 'View mode',
62
- );
63
- expect(screen.getByRole('radio', {name: 'Grid'})).toBeInTheDocument();
64
- expect(screen.getByRole('radio', {name: 'List'})).toBeInTheDocument();
65
- });
66
-
67
- it('marks selected item with aria-checked', () => {
68
- render(
69
- <SegmentedControl value="grid" onChange={() => {}} label="View mode">
70
- <SegmentedControlItem value="grid" label="Grid" />
71
- <SegmentedControlItem value="list" label="List" />
72
- </SegmentedControl>,
73
- );
74
-
75
- expect(screen.getByRole('radio', {name: 'Grid'})).toHaveAttribute(
76
- 'aria-checked',
77
- 'true',
78
- );
79
- expect(screen.getByRole('radio', {name: 'List'})).toHaveAttribute(
80
- 'aria-checked',
81
- 'false',
82
- );
83
- });
84
-
85
53
  it('calls onChange when an item is clicked', async () => {
86
54
  const user = userEvent.setup();
87
55
  const handleChange = vi.fn();
@@ -230,49 +198,6 @@ describe('SegmentedControl', () => {
230
198
  expect(labelStyle.textOverflow).toBe('ellipsis');
231
199
  });
232
200
 
233
- it('uses roving tabindex — selected item has tabIndex 0, others -1', () => {
234
- render(
235
- <SegmentedControl value="list" onChange={() => {}} label="View mode">
236
- <SegmentedControlItem value="grid" label="Grid" />
237
- <SegmentedControlItem value="list" label="List" />
238
- <SegmentedControlItem value="table" label="Table" />
239
- </SegmentedControl>,
240
- );
241
-
242
- expect(screen.getByRole('radio', {name: 'Grid'})).toHaveAttribute(
243
- 'tabIndex',
244
- '-1',
245
- );
246
- expect(screen.getByRole('radio', {name: 'List'})).toHaveAttribute(
247
- 'tabIndex',
248
- '0',
249
- );
250
- expect(screen.getByRole('radio', {name: 'Table'})).toHaveAttribute(
251
- 'tabIndex',
252
- '-1',
253
- );
254
- });
255
-
256
- it('keeps a tab stop when the value matches no item (tab-stop repair)', () => {
257
- render(
258
- <SegmentedControl
259
- value="nonexistent"
260
- onChange={() => {}}
261
- label="View mode">
262
- <SegmentedControlItem value="grid" label="Grid" />
263
- <SegmentedControlItem value="list" label="List" />
264
- <SegmentedControlItem value="table" label="Table" />
265
- </SegmentedControl>,
266
- );
267
-
268
- // No item is selected, but the group must remain Tab-reachable: the first
269
- // enabled radio is promoted to tabIndex=0.
270
- expect(screen.getByRole('radio', {name: 'Grid'})).toHaveAttribute(
271
- 'tabIndex',
272
- '0',
273
- );
274
- });
275
-
276
201
  it('promotes the first enabled item when the value matches no item and the first is disabled', () => {
277
202
  render(
278
203
  <SegmentedControl
@@ -360,7 +285,6 @@ describe('SegmentedControl keyboard navigation', () => {
360
285
  await user.keyboard('{ArrowRight}');
361
286
 
362
287
  expect(handleChange).toHaveBeenCalledWith('list');
363
- expect(screen.getByRole('radio', {name: 'List'})).toHaveFocus();
364
288
  });
365
289
 
366
290
  it('navigates with ArrowLeft and selects', async () => {
@@ -379,7 +303,6 @@ describe('SegmentedControl keyboard navigation', () => {
379
303
  await user.keyboard('{ArrowLeft}');
380
304
 
381
305
  expect(handleChange).toHaveBeenCalledWith('grid');
382
- expect(screen.getByRole('radio', {name: 'Grid'})).toHaveFocus();
383
306
  });
384
307
 
385
308
  it('wraps around from last to first with ArrowRight', async () => {
@@ -398,7 +321,6 @@ describe('SegmentedControl keyboard navigation', () => {
398
321
  await user.keyboard('{ArrowRight}');
399
322
 
400
323
  expect(handleChange).toHaveBeenCalledWith('grid');
401
- expect(screen.getByRole('radio', {name: 'Grid'})).toHaveFocus();
402
324
  });
403
325
 
404
326
  it('wraps around from first to last with ArrowLeft', async () => {
@@ -417,7 +339,6 @@ describe('SegmentedControl keyboard navigation', () => {
417
339
  await user.keyboard('{ArrowLeft}');
418
340
 
419
341
  expect(handleChange).toHaveBeenCalledWith('table');
420
- expect(screen.getByRole('radio', {name: 'Table'})).toHaveFocus();
421
342
  });
422
343
 
423
344
  it('Home key focuses first item', async () => {
@@ -460,59 +381,6 @@ describe('SegmentedControl keyboard navigation', () => {
460
381
  });
461
382
 
462
383
  describe('SegmentedControl disabled state', () => {
463
- it('marks entire group as disabled', () => {
464
- render(
465
- <SegmentedControl
466
- value="grid"
467
- onChange={() => {}}
468
- label="View mode"
469
- isDisabled>
470
- <SegmentedControlItem value="grid" label="Grid" />
471
- <SegmentedControlItem value="list" label="List" />
472
- </SegmentedControl>,
473
- );
474
-
475
- expect(screen.getByRole('radiogroup')).toHaveAttribute(
476
- 'aria-disabled',
477
- 'true',
478
- );
479
- });
480
-
481
- it('removes the tab stop from the selected item when the group is disabled (navigation-13)', () => {
482
- render(
483
- <SegmentedControl
484
- value="grid"
485
- onChange={() => {}}
486
- label="View mode"
487
- isDisabled>
488
- <SegmentedControlItem value="grid" label="Grid" />
489
- <SegmentedControlItem value="list" label="List" />
490
- </SegmentedControl>,
491
- );
492
- // Selected segment must not be a focusable-but-dead tab stop when disabled.
493
- const selected = screen.getByRole('radio', {name: 'Grid'});
494
- expect(selected).toHaveAttribute('tabIndex', '-1');
495
- expect(selected).toHaveAttribute('aria-disabled', 'true');
496
- // No enabled segment is tabbable either.
497
- expect(screen.getByRole('radio', {name: 'List'})).toHaveAttribute(
498
- 'tabIndex',
499
- '-1',
500
- );
501
- });
502
-
503
- it('removes the tab stop from an individually disabled selected item', () => {
504
- render(
505
- <SegmentedControl value="grid" onChange={() => {}} label="View mode">
506
- <SegmentedControlItem value="grid" label="Grid" isDisabled />
507
- <SegmentedControlItem value="list" label="List" />
508
- </SegmentedControl>,
509
- );
510
- expect(screen.getByRole('radio', {name: 'Grid'})).toHaveAttribute(
511
- 'tabIndex',
512
- '-1',
513
- );
514
- });
515
-
516
384
  it('does not call onChange when group is disabled', async () => {
517
385
  const user = userEvent.setup({pointerEventsCheck: 0});
518
386
  const handleChange = vi.fn();
@@ -543,11 +411,6 @@ describe('SegmentedControl disabled state', () => {
543
411
  </SegmentedControl>,
544
412
  );
545
413
 
546
- expect(screen.getByRole('radio', {name: 'List'})).toHaveAttribute(
547
- 'aria-disabled',
548
- 'true',
549
- );
550
-
551
414
  await user.click(screen.getByRole('radio', {name: 'List'}));
552
415
  expect(handleChange).not.toHaveBeenCalled();
553
416
  });
@@ -634,20 +497,6 @@ describe('SegmentedControl disabled state', () => {
634
497
  expect(screen.queryByRole('tooltip', h)).not.toBeInTheDocument();
635
498
  });
636
499
 
637
- it('keeps the selected segment focusable when a reason is provided', () => {
638
- renderControl();
639
- const selected = screen.getByRole('radio', {name: 'Grid', hidden: true});
640
- expect(selected).toHaveAttribute('aria-disabled', 'true');
641
- expect(selected).toHaveAttribute('tabindex', '0');
642
- });
643
-
644
- it('links the reason tooltip from the group via aria-describedby', () => {
645
- renderControl();
646
- const group = screen.getByRole('radiogroup');
647
- const tooltip = screen.getByRole('tooltip', h);
648
- expect(group.getAttribute('aria-describedby')).toContain(tooltip.id);
649
- });
650
-
651
500
  it('blocks selection while focusable-disabled', () => {
652
501
  const onChange = vi.fn();
653
502
  renderControl({onChange});
@@ -655,22 +504,6 @@ describe('SegmentedControl disabled state', () => {
655
504
  fireEvent.click(list);
656
505
  expect(onChange).not.toHaveBeenCalled();
657
506
  });
658
-
659
- it('drops all segments from the tab order when disabled without a reason', () => {
660
- render(
661
- <SegmentedControl
662
- value="grid"
663
- onChange={() => {}}
664
- label="View mode"
665
- isDisabled>
666
- <SegmentedControlItem value="grid" label="Grid" />
667
- <SegmentedControlItem value="list" label="List" />
668
- </SegmentedControl>,
669
- );
670
- for (const radio of screen.getAllByRole('radio', h)) {
671
- expect(radio).toHaveAttribute('tabindex', '-1');
672
- }
673
- });
674
507
  });
675
508
  });
676
509
 
@@ -3032,8 +3032,8 @@ describe('Selector clear icon theme target', () => {
3032
3032
  // The canonical target lands on the icon element itself (not the button),
3033
3033
  // so a theme can restyle just this glyph (color, size, hover) via
3034
3034
  // `defineTheme` — a button-level target could not reach the icon's own
3035
- // color/size. The original per-component name rides along for a
3036
- // deprecation window.
3035
+ // color/size. The original per-component name remains as a compatibility
3036
+ // alias.
3037
3037
  const icon = getClearIcon();
3038
3038
  expect(icon).toHaveClass('astryx-input-clear-icon');
3039
3039
  expect(icon).toHaveClass('astryx-selector-clear-icon');
@@ -3060,8 +3060,8 @@ describe('Selector clear icon theme target', () => {
3060
3060
  it('routes the clear glyph through the shared clear button, keeping the legacy target', () => {
3061
3061
  // The clear affordance now composes the shared InputClearButton (a ghost
3062
3062
  // Button with a secondary/sm glyph), so the icon carries the canonical
3063
- // `astryx-input-clear-icon` target and — for a deprecation window — the
3064
- // original `astryx-selector-clear-icon`. Aside from those target classes
3063
+ // `astryx-input-clear-icon` target plus the supported compatibility alias
3064
+ // `astryx-selector-clear-icon`. Aside from those target classes
3065
3065
  // it matches the shared button's own `close`/`sm`/`secondary` glyph
3066
3066
  // exactly, so the default look is defined in one place.
3067
3067
  render(
@@ -72,24 +72,6 @@ describe('Spinner', () => {
72
72
  expect(spinner).toHaveAttribute('data-shade', 'inherit');
73
73
  });
74
74
 
75
- it('has role="status"', () => {
76
- render(<Spinner data-testid="spinner" />);
77
- expect(screen.getByRole('status')).toBeInTheDocument();
78
- });
79
-
80
- it('has aria-label="Loading" by default', () => {
81
- render(<Spinner data-testid="spinner" />);
82
- expect(screen.getByTestId('spinner')).toHaveAttribute(
83
- 'aria-label',
84
- 'Loading',
85
- );
86
- });
87
-
88
- it('names the status element from the visible string label', () => {
89
- render(<Spinner label="Fetching data" data-testid="spinner" />);
90
- expect(screen.getByRole('status')).toHaveAccessibleName('Fetching data');
91
- });
92
-
93
75
  it('does not duplicate a visible string label as aria-label', () => {
94
76
  render(<Spinner label="Fetching data" data-testid="spinner" />);
95
77
  const status = screen.getByRole('status');
@@ -63,8 +63,8 @@ export const docs = {
63
63
  theming: {
64
64
  targets: [
65
65
  {className: 'astryx-status-dot', visualProps: ['variant']},
66
- // Still emitted beside the names above, so themes written against
67
- // them keep working. Drop in the next major.
66
+ // Retained beside the canonical names for backwards compatibility.
67
+ // New themes use the canonical targets above.
68
68
  {className: 'astryx-statusdot', visualProps: ['variant'], deprecatedFor: 'status-dot'},
69
69
  ],
70
70
  },
@@ -124,8 +124,8 @@ export const docsZh = {
124
124
  theming: {
125
125
  targets: [
126
126
  {className: 'astryx-status-dot', visualProps: ['variant']},
127
- // Still emitted beside the names above, so themes written against
128
- // them keep working. Drop in the next major.
127
+ // Retained beside the canonical names for backwards compatibility.
128
+ // New themes use the canonical targets above.
129
129
  {className: 'astryx-statusdot', visualProps: ['variant'], deprecatedFor: 'status-dot'},
130
130
  ],
131
131
  },
@@ -203,8 +203,8 @@ export function StatusDot({
203
203
  themeProps(
204
204
  'status-dot',
205
205
  {variant},
206
- // `statusdot` ran the compound name together; themes styling it keep
207
- // working until the next major.
206
+ // `statusdot` ran the compound name together; keep it emitted so
207
+ // existing themes continue to work.
208
208
  {legacyNames: ['statusdot']},
209
209
  ),
210
210
  stylex.props(
@@ -1316,7 +1316,7 @@ describe('TabList ARIA pattern — role="tablist"', () => {
1316
1316
  return vi.spyOn(console, 'warn').mockImplementation(() => {});
1317
1317
  }
1318
1318
 
1319
- it('speaks the tabs pattern: a labelled tablist of tabs with aria-selected', () => {
1319
+ it('does not mix navigation semantics into explicit Tabs', () => {
1320
1320
  render(
1321
1321
  <TabList value="b" onChange={() => {}} role="tablist" aria-label="Views">
1322
1322
  <Tab value="a" label="Alpha" panelId="panel-a" />
@@ -1324,14 +1324,10 @@ describe('TabList ARIA pattern — role="tablist"', () => {
1324
1324
  </TabList>,
1325
1325
  );
1326
1326
 
1327
- const tablist = screen.getByRole('tablist', {name: 'Views'});
1328
- const tabs = screen.getAllByRole('tab');
1329
- expect(tabs).toHaveLength(2);
1330
- expect(tabs.every(tab => tablist.contains(tab))).toBe(true);
1331
- expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
1332
- expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
1333
- expect(tabs[1]).toHaveAttribute('aria-controls', 'panel-b');
1334
- expect(tabs[1]).not.toHaveAttribute('aria-current');
1327
+ expect(screen.queryByRole('navigation')).toBeNull();
1328
+ for (const tab of screen.getAllByRole('tab')) {
1329
+ expect(tab).not.toHaveAttribute('aria-current');
1330
+ }
1335
1331
  });
1336
1332
 
1337
1333
  it('lets the consumer name the tablist from another element', () => {
@@ -0,0 +1,191 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Tabs.a11y.chromium.spec.ts
5
+ * @input Uses the shared Tabs contract, Chromium harness, checked-in stories, inventory, and exact known failures
6
+ * @output Real-browser and accessibility-tree evidence for explicit TabList Tabs parts
7
+ * @position Browser lane required by AST-013; it makes no real-AT claim.
8
+ */
9
+
10
+ import {
11
+ expect,
12
+ test,
13
+ type CDPSession,
14
+ type Locator,
15
+ type Page,
16
+ } from '@playwright/test';
17
+ import {
18
+ TABS_PATTERN,
19
+ blockingResults,
20
+ checkAccessibilitySpec,
21
+ formatFailures,
22
+ formatReport,
23
+ neverExercised,
24
+ summarize,
25
+ unmatchedKnownFailures,
26
+ type BindingResult,
27
+ } from '@astryxdesign/a11y-spec';
28
+ import {
29
+ createChromiumHarness,
30
+ holdMotionStill,
31
+ } from '@astryxdesign/a11y-spec/chromium';
32
+ import {
33
+ DEFAULT_STORYBOOK_DIR,
34
+ serveStorybook,
35
+ type StaticServer,
36
+ } from '@astryxdesign/a11y-spec/storybook';
37
+ import {TABS_KNOWN_FAILURES} from './Tabs.a11y.known-failures';
38
+ import {
39
+ TABS_BINDING_STATES,
40
+ type TabsBindingRow,
41
+ type TabsBindingState,
42
+ } from './Tabs.a11y.states';
43
+
44
+ let storybook: StaticServer;
45
+
46
+ test.beforeAll(async () => {
47
+ storybook = await serveStorybook(
48
+ process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR,
49
+ );
50
+ });
51
+
52
+ test.afterAll(async () => {
53
+ await storybook?.close();
54
+ });
55
+
56
+ function storyUrl(storyId: string): string {
57
+ return `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`;
58
+ }
59
+
60
+ function subjectFor(page: Page, state: TabsBindingRow): Locator {
61
+ const root = page.locator('#storybook-root');
62
+ if ('selector' in state.subject) {
63
+ return root.locator(state.subject.selector);
64
+ }
65
+ return root.getByRole(state.subject.role, {
66
+ name: state.subject.name,
67
+ includeHidden: true,
68
+ });
69
+ }
70
+
71
+ function relatedFor(
72
+ page: Page,
73
+ state: TabsBindingRow,
74
+ ): Readonly<Record<string, Locator>> {
75
+ const metadata = state as TabsBindingState;
76
+ const root = page.locator('#storybook-root');
77
+ return Object.fromEntries(
78
+ Object.entries(metadata.relations ?? {}).map(([name, relation]) => [
79
+ name,
80
+ root.locator(relation.selector),
81
+ ]),
82
+ );
83
+ }
84
+
85
+ async function mountState(page: Page, state: TabsBindingRow): Promise<void> {
86
+ await page.goto(storyUrl(state.storyId), {waitUntil: 'load'});
87
+ await holdMotionStill(page);
88
+ await subjectFor(page, state).waitFor({state: 'attached'});
89
+ }
90
+
91
+ async function runState(
92
+ page: Page,
93
+ cdp: CDPSession,
94
+ state: TabsBindingRow,
95
+ ): Promise<BindingResult> {
96
+ return checkAccessibilitySpec({
97
+ spec: TABS_PATTERN,
98
+ binding: state.binding,
99
+ state: state.id,
100
+ facts: state.facts,
101
+ knownFailures: TABS_KNOWN_FAILURES,
102
+ mount: async () => {
103
+ await mountState(page, state);
104
+ return createChromiumHarness({
105
+ page,
106
+ subject: subjectFor(page, state),
107
+ cdp,
108
+ visibleLabel: state.facts.visibleLabel ? undefined : null,
109
+ related: relatedFor(page, state),
110
+ });
111
+ },
112
+ });
113
+ }
114
+
115
+ test('every binding fact matches the rendered semantic state', async ({
116
+ page,
117
+ }) => {
118
+ test.setTimeout(3 * 60 * 1000);
119
+ const cdp = await page.context().newCDPSession(page);
120
+ const wrong: string[] = [];
121
+
122
+ for (const state of TABS_BINDING_STATES) {
123
+ await mountState(page, state);
124
+ const subject = await createChromiumHarness({
125
+ page,
126
+ subject: subjectFor(page, state),
127
+ cdp,
128
+ related: relatedFor(page, state),
129
+ }).subject();
130
+ const computed = await subject.computed();
131
+
132
+ const expectedRole =
133
+ state.facts.part === 'tablist'
134
+ ? 'tablist'
135
+ : state.facts.part === 'tab'
136
+ ? 'tab'
137
+ : state.facts.active === false
138
+ ? null
139
+ : 'tabpanel';
140
+ if (computed.role !== expectedRole) {
141
+ wrong.push(
142
+ `${state.id}: declares role=${expectedRole}, browser exposes ${computed.role}`,
143
+ );
144
+ }
145
+ if (
146
+ state.facts.selected != null &&
147
+ computed.selected !== state.facts.selected
148
+ ) {
149
+ wrong.push(
150
+ `${state.id}: declares selected=${state.facts.selected}, browser exposes ${computed.selected}`,
151
+ );
152
+ }
153
+ if (
154
+ state.facts.disabled != null &&
155
+ computed.disabled !== state.facts.disabled
156
+ ) {
157
+ wrong.push(
158
+ `${state.id}: declares disabled=${state.facts.disabled}, browser exposes ${computed.disabled}`,
159
+ );
160
+ }
161
+ }
162
+
163
+ expect(wrong).toEqual([]);
164
+ });
165
+
166
+ test('every expectation is exercised by at least one bound state', async ({
167
+ page,
168
+ }) => {
169
+ test.setTimeout(5 * 60 * 1000);
170
+ const cdp = await page.context().newCDPSession(page);
171
+ const results: BindingResult[] = [];
172
+ for (const state of TABS_BINDING_STATES) {
173
+ results.push(await runState(page, cdp, state));
174
+ }
175
+ expect(neverExercised(results)).toEqual([]);
176
+ expect(unmatchedKnownFailures(TABS_KNOWN_FAILURES, results)).toEqual([]);
177
+ });
178
+
179
+ for (const state of TABS_BINDING_STATES) {
180
+ test(`${state.binding} [${state.id}] — ${state.summary}`, async ({page}) => {
181
+ test.setTimeout(3 * 60 * 1000);
182
+ const cdp = await page.context().newCDPSession(page);
183
+ const result = await runState(page, cdp, state);
184
+ const report = summarize(TABS_PATTERN, [result]);
185
+ // eslint-disable-next-line no-console -- the report is this run's artifact
186
+ console.log(formatReport(report));
187
+ expect(formatFailures(blockingResults([result]))).toBe('');
188
+ expect(report.unrunLayers).toEqual([]);
189
+ expect(report.counts.unexpectedPass).toBe(0);
190
+ });
191
+ }