@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,217 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ /** @vitest-environment jsdom */
3
+
4
+ /**
5
+ * @file RadioGroup.a11y.test.tsx
6
+ * @input Uses the shared radio-group contract and complete binding inventory
7
+ * @output DOM-layer binding evidence for direct and menu radio-group parts
8
+ * @position Fast migration lane; browser-owned outcomes remain explicitly unrun.
9
+ */
10
+
11
+ import {cleanup, render, screen} from '@testing-library/react';
12
+ import {describe, expect, it} from 'vitest';
13
+ import {
14
+ RADIO_GROUP_PATTERN,
15
+ checkAccessibilitySpec,
16
+ createJsdomHarness,
17
+ expectAccessibilitySpec,
18
+ summarize,
19
+ unmatchedKnownFailures,
20
+ type BindingResult,
21
+ } from '@astryxdesign/a11y-spec';
22
+ import {RADIO_GROUP_KNOWN_FAILURES} from './RadioGroup.a11y.known-failures';
23
+ import {RADIO_GROUP_STATE_RENDERS} from './RadioGroup.a11y.renders';
24
+ import {
25
+ RADIO_GROUP_BINDING_STATES,
26
+ RADIO_GROUP_EXCLUSIONS,
27
+ type RadioGroupBindingRow,
28
+ } from './RadioGroup.a11y.states';
29
+
30
+ function subjectFor(state: RadioGroupBindingRow): Element {
31
+ return screen.getByRole(state.facts.role, {
32
+ name: state.subjectName,
33
+ hidden: true,
34
+ });
35
+ }
36
+
37
+ async function expectState(state: RadioGroupBindingRow): Promise<void> {
38
+ await expectAccessibilitySpec({
39
+ spec: RADIO_GROUP_PATTERN,
40
+ binding: state.binding,
41
+ state: state.id,
42
+ facts: state.facts,
43
+ knownFailures: RADIO_GROUP_KNOWN_FAILURES,
44
+ render: () => {
45
+ render(RADIO_GROUP_STATE_RENDERS[state.id]());
46
+ },
47
+ subject: () => subjectFor(state),
48
+ cleanup,
49
+ });
50
+ }
51
+
52
+ async function checkState(state: RadioGroupBindingRow): Promise<BindingResult> {
53
+ return checkAccessibilitySpec({
54
+ spec: RADIO_GROUP_PATTERN,
55
+ binding: state.binding,
56
+ state: state.id,
57
+ facts: state.facts,
58
+ knownFailures: RADIO_GROUP_KNOWN_FAILURES,
59
+ mount: async () => {
60
+ render(RADIO_GROUP_STATE_RENDERS[state.id]());
61
+ return createJsdomHarness({subject: subjectFor(state)});
62
+ },
63
+ unmount: cleanup,
64
+ });
65
+ }
66
+
67
+ describe('the shared radio-group pattern, jsdom lane', () => {
68
+ it.each(
69
+ RADIO_GROUP_BINDING_STATES.map(
70
+ state =>
71
+ [`${state.binding} [${state.id}]`, state.summary, state] as const,
72
+ ),
73
+ )('%s — %s', async (_id, _summary, state) => {
74
+ await expectState(state);
75
+ });
76
+
77
+ it('runs the DOM layer and reports higher layers as unrun', async () => {
78
+ const results: BindingResult[] = [];
79
+ for (const state of RADIO_GROUP_BINDING_STATES) {
80
+ results.push(await checkState(state));
81
+ }
82
+ const report = summarize(RADIO_GROUP_PATTERN, results);
83
+ expect(report.counts.pass).toBeGreaterThan(0);
84
+ expect(report.unrunLayers).toEqual(['accessibility-tree', 'real-browser']);
85
+ expect(report.counts.unexpectedPass).toBe(0);
86
+ expect(unmatchedKnownFailures(RADIO_GROUP_KNOWN_FAILURES, results)).toEqual(
87
+ [],
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('the radio-group binding inventory', () => {
93
+ it('names a distinct checked-in story for every state', () => {
94
+ const ids = RADIO_GROUP_BINDING_STATES.map(state => state.storyId);
95
+ expect(new Set(ids).size).toBe(ids.length);
96
+ });
97
+
98
+ it('binds every current direct and menu radio-group part', () => {
99
+ const bound = new Set(
100
+ RADIO_GROUP_BINDING_STATES.map(state => state.binding),
101
+ );
102
+ expect([...bound].sort()).toEqual([
103
+ 'DropdownMenuRadioGroup',
104
+ 'DropdownMenuRadioItem',
105
+ 'RadioList',
106
+ 'RadioListItem',
107
+ 'SegmentedControl',
108
+ 'SegmentedControlItem',
109
+ ]);
110
+ });
111
+
112
+ it('records role-conditional and delegated surfaces explicitly', () => {
113
+ expect(
114
+ RADIO_GROUP_EXCLUSIONS.map(({owner, classification}) => ({
115
+ owner,
116
+ classification,
117
+ })),
118
+ ).toEqual([
119
+ {
120
+ owner: 'SelectableCard in independent or multi-select composition',
121
+ classification: 'preserved',
122
+ },
123
+ {
124
+ owner: 'SelectableCard in caller-managed single-select composition',
125
+ classification: 'needs-human',
126
+ },
127
+ {
128
+ owner: 'DropdownMenu and ContextMenu radio-item movement',
129
+ classification: 'out-of-scope',
130
+ },
131
+ {
132
+ owner: 'TabMenu overflow choices',
133
+ classification: 'out-of-scope',
134
+ },
135
+ {
136
+ owner: 'Pagination dots',
137
+ classification: 'out-of-scope',
138
+ },
139
+ {
140
+ owner: 'Home and End keyboard shortcuts',
141
+ classification: 'out-of-scope',
142
+ },
143
+ ]);
144
+ expect(RADIO_GROUP_EXCLUSIONS.every(row => row.reason.length > 0)).toBe(
145
+ true,
146
+ );
147
+ });
148
+
149
+ it('locks interaction ownership instead of letting bindings opt out', () => {
150
+ const wrong: string[] = [];
151
+ for (const state of RADIO_GROUP_BINDING_STATES) {
152
+ const facts = state.facts;
153
+ if (facts.part === 'group' && facts.role === 'radiogroup') {
154
+ for (const fact of [
155
+ 'spaceSelection',
156
+ 'pointerSelection',
157
+ 'arrowSelection',
158
+ ] as const) {
159
+ if (facts[fact] !== facts.operable) {
160
+ wrong.push(
161
+ `${state.id}: direct-group ${fact}=${facts[fact]} but operable=${facts.operable}`,
162
+ );
163
+ }
164
+ }
165
+ if (facts.arrowSelection && facts.movement === 'none') {
166
+ wrong.push(`${state.id}: owns arrows without a movement model`);
167
+ }
168
+ } else if (facts.part === 'group') {
169
+ if (
170
+ facts.spaceSelection ||
171
+ facts.arrowSelection ||
172
+ facts.movement !== 'none'
173
+ ) {
174
+ wrong.push(`${state.id}: menu-owned keyboard behavior leaked in`);
175
+ }
176
+ if (facts.pointerSelection !== facts.operable) {
177
+ wrong.push(
178
+ `${state.id}: menu pointerSelection=${facts.pointerSelection} but operable=${facts.operable}`,
179
+ );
180
+ }
181
+ } else {
182
+ if (
183
+ facts.spaceSelection ||
184
+ facts.pointerSelection ||
185
+ facts.arrowSelection
186
+ ) {
187
+ wrong.push(`${state.id}: option row claims group-level interaction`);
188
+ }
189
+ if (facts.pointerCancellation !== facts.operable) {
190
+ wrong.push(
191
+ `${state.id}: pointerCancellation=${facts.pointerCancellation} but operable=${facts.operable}`,
192
+ );
193
+ }
194
+ }
195
+ }
196
+ expect(wrong).toEqual([]);
197
+ });
198
+
199
+ it('locks which bindings expose a visible label for the bound subject', () => {
200
+ const wrong = RADIO_GROUP_BINDING_STATES.filter(state => {
201
+ const expected =
202
+ state.binding !== 'SegmentedControl' &&
203
+ state.binding !== 'DropdownMenuRadioGroup' &&
204
+ state.id !== 'segmented-option-selected-hidden-label';
205
+ return state.facts.visibleLabel !== expected;
206
+ }).map(state => state.id);
207
+ expect(wrong).toEqual([]);
208
+ });
209
+
210
+ it('renders exactly one named subject for every state', () => {
211
+ for (const state of RADIO_GROUP_BINDING_STATES) {
212
+ render(RADIO_GROUP_STATE_RENDERS[state.id]());
213
+ expect(subjectFor(state), state.id).toBeTruthy();
214
+ cleanup();
215
+ }
216
+ });
217
+ });
@@ -0,0 +1,100 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
4
+ export const docs = {
5
+ name: 'ScrollableArea',
6
+ displayName: 'Scrollable Area',
7
+ category: 'Layout',
8
+ keywords: ['scroll', 'overflow', 'viewport', 'logical axis', 'keyboard', 'overscroll', 'sticky', 'scrollbar', 'padding', 'full bleed'],
9
+ usage: {
10
+ description: 'Provides a native scroll viewport and a real observed content box. The viewport enters the tab order only while a requested logical axis is effectively scrollable, and containment applies only to effective axes.',
11
+ bestPractices: [
12
+ {guidance: true, description: 'Give every area a concise label that identifies the content keyboard users will scroll.'},
13
+ {guidance: true, description: 'Choose `inline`, `block`, or `both` from content intent; the component maps the logical axes through writing mode and direction.'},
14
+ {guidance: true, description: 'Keep the default `overscroll="allow"` for nested areas unless the interaction deliberately needs containment.'},
15
+ {guidance: true, description: 'Use `useScrollableArea` instead when a component already owns both a viewport and a suitable content box, or when children must remain direct flex/grid items or retain a definite percentage block-size basis.'},
16
+ {guidance: true, description: 'ScrollableArea owns one normal block content box with a 100% minimum size. Inline and both-axis modes use max-content inline sizing, so intrinsic inline layout is intentionally wider than the viewport.'},
17
+ {guidance: true, description: 'Use logical padding props on the content box so nested full-bleed components receive the same inset geometry.'},
18
+ {guidance: true, description: 'Set `isFullBleed` only when the viewport itself should reach an ancestor container edge; it is off by default.'},
19
+ {guidance: true, description: 'Set `stickyContainment="always"` only when a fitting viewport should intentionally remain a Sticky boundary.'},
20
+ {guidance: false, description: 'Hide the native scrollbar without another visible and operable overflow affordance.'},
21
+ {guidance: false, description: 'Add another overflow wrapper around ScrollableArea; one native viewport should own scrolling.'},
22
+ ],
23
+ anatomy: [
24
+ {name: 'Viewport', required: true, description: 'The root native scroll container, accessible name owner, focus target while effective, and `astryx-scrollable-area` theme target.'},
25
+ {name: 'Content box', required: true, description: 'A real inner layout box observed together with the viewport. For inline scrolling it uses max-content inline sizing with a 100% minimum.'},
26
+ ],
27
+ },
28
+ props: [
29
+ {name: 'axis', type: "'inline' | 'block' | 'both'", description: 'Logical axis or axes where native scrolling is allowed.', default: "'block'"},
30
+ {name: 'label', type: 'string', description: 'Accessible name for the viewport when it becomes keyboard scrollable.', required: true},
31
+ {name: 'role', type: "'group' | 'region'", description: 'Semantics for the named viewport.', default: "'group'"},
32
+ {name: 'overscroll', type: "'allow' | 'contain'", description: 'Whether effective axes continue scrolling an ancestor at their edge.', default: "'allow'"},
33
+ {name: 'width', type: 'SizeValue', description: 'Width of the viewport; a number is interpreted as pixels, a string is used as-is.'},
34
+ {name: 'height', type: 'SizeValue', description: 'Height of the viewport; a number is interpreted as pixels, a string is used as-is.'},
35
+ {name: 'maxWidth', type: 'SizeValue', description: 'Maximum width of the viewport.'},
36
+ {name: 'minHeight', type: 'SizeValue', description: 'Minimum height of the viewport.'},
37
+ {name: 'stickyContainment', type: "'whenScrollable' | 'always'", description: 'Whether fitting content passes Sticky ownership to an outer container or deliberately keeps this viewport as the CSS Sticky boundary.', default: "'whenScrollable'"},
38
+ {name: 'padding', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Content padding on every logical edge; publishes matching inset geometry.', default: '0'},
39
+ {name: 'paddingInline', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Logical inline-axis content padding; overrides `padding` on that axis.'},
40
+ {name: 'paddingInlineStart', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Logical inline-start content padding; overrides broader padding values.'},
41
+ {name: 'paddingInlineEnd', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Logical inline-end content padding; overrides broader padding values.'},
42
+ {name: 'paddingBlock', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Logical block-axis content padding; overrides `padding` on that axis.'},
43
+ {name: 'paddingBlockStart', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Logical block-start content padding; overrides broader padding values.'},
44
+ {name: 'paddingBlockEnd', type: '0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 6 | 8 | 10', description: 'Logical block-end content padding; overrides broader padding values.'},
45
+ {name: 'isFullBleed', type: 'boolean', description: 'Lets the viewport escape inherited container padding without changing content padding.', default: 'false'},
46
+ {name: 'children', type: 'ReactNode', description: 'Content rendered inside the observed content box.'},
47
+ {name: 'xstyle', type: 'StyleXStyles', description: 'StyleX sizing and native scrollbar presentation overrides for the viewport.'},
48
+ ],
49
+ playground: {
50
+ defaults: {
51
+ axis: 'block',
52
+ label: 'Scrollable example',
53
+ children: 'Add enough content to exceed a constrained viewport.',
54
+ },
55
+ },
56
+ theming: {
57
+ targets: [
58
+ {className: 'astryx-scrollable-area', visualProps: ['axis']},
59
+ ],
60
+ },
61
+ };
62
+
63
+ /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
64
+ export const docsDense = {
65
+ description: 'native logical-axis scroll viewport with conditional keyboard access, live edge state, and observed content geometry',
66
+ usage: {
67
+ description: 'Use for a complete viewport/content composition; use useScrollableArea for existing structure.',
68
+ bestPractices: [
69
+ {guidance: true, description: 'Always provide a concise label.'},
70
+ {guidance: true, description: 'Prefer overscroll allow; contain only deliberate nested interactions.'},
71
+ {guidance: true, description: 'Use content padding props to publish inset; use isFullBleed only for viewport escape.'},
72
+ {guidance: false, description: 'Nest another overflow wrapper around it.'},
73
+ ],
74
+ anatomy: [
75
+ {name: 'Viewport', required: true, description: 'native scrolling, conditional tab stop, and theme target'},
76
+ {name: 'Content box', required: true, description: 'real observed layout box'},
77
+ ],
78
+ },
79
+ propDescriptions: {
80
+ axis: "logical scroll intent: 'inline' | 'block' (default) | 'both'",
81
+ label: 'required accessible viewport name',
82
+ role: "named viewport semantics: 'group' (default) | 'region'",
83
+ overscroll: "edge behavior: 'allow' (default) | 'contain' on effective axes only",
84
+ width: 'viewport width',
85
+ height: 'viewport height',
86
+ maxWidth: 'viewport maximum width',
87
+ minHeight: 'viewport minimum height',
88
+ stickyContainment: "fitting Sticky ownership: 'whenScrollable' (default) | 'always'",
89
+ padding: 'content inset on every edge; defaults to 0 and publishes geometry',
90
+ paddingInline: 'logical inline-axis content inset',
91
+ paddingInlineStart: 'logical inline-start content inset',
92
+ paddingInlineEnd: 'logical inline-end content inset',
93
+ paddingBlock: 'logical block-axis content inset',
94
+ paddingBlockStart: 'logical block-start content inset',
95
+ paddingBlockEnd: 'logical block-end content inset',
96
+ isFullBleed: 'opt-in viewport escape from inherited container padding',
97
+ children: 'content inside the observed box',
98
+ xstyle: 'viewport sizing and native scrollbar presentation overrides',
99
+ },
100
+ };
@@ -0,0 +1,189 @@
1
+ ---
2
+ schema_version: 3
3
+ template_version: 4
4
+ kind: component
5
+ id: component:ScrollableArea
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, layout, theming, accessibility]
13
+ verified_by:
14
+ [
15
+ packages/core/src/ScrollableArea/ScrollableArea.test.tsx,
16
+ packages/core/src/hooks/useScrollableArea.test.tsx,
17
+ apps/storybook/stories/ScrollableArea.stories.tsx,
18
+ ]
19
+ modules: [module:ScrollableArea/useScrollableArea]
20
+ families: []
21
+ design_specs: []
22
+ architecture:
23
+ [architecture:container-padding, architecture:public-component-api]
24
+ contributing: []
25
+ system_specs: [spec:AST-025/DEC-1]
26
+ ---
27
+
28
+ # ScrollableArea component contract
29
+
30
+ ## Intent
31
+
32
+ ScrollableArea gives builders a complete native scroll viewport and observable
33
+ content box without making them coordinate keyboard access, chaining, edge state,
34
+ and native scrollbar presentation. It is the reference composition over the
35
+ shared behavior hook, not the exclusive owner of that behavior.
36
+
37
+ ## Compatibility and migration
38
+
39
+ - Released default preserved: not yet released
40
+ - Compatibility class: additive component and package exports
41
+ - Controlled/uncontrolled behavior: not applicable
42
+ - Migration decision: `spec:AST-025/DEC-1`
43
+
44
+ ## Ownership boundary
45
+
46
+ **Owns**
47
+
48
+ - One root native viewport and one real observed inner content box.
49
+ - Conditional viewport keyboard access, accessible naming, logical-axis intent,
50
+ overscroll policy, and native scrollbar defaults supplied by the shared hook.
51
+ - Optional logical content padding that publishes matching container inset, plus
52
+ opt-in viewport bleed against inherited container padding.
53
+
54
+ **Does not own / non-goals**
55
+
56
+ - JavaScript-driven scrolling or custom scrollbar DOM.
57
+ - A general Sticky public API or fallback positioning algorithm.
58
+ - Existing component-owned viewport migrations such as Layout and Table.
59
+
60
+ ## Public concepts
61
+
62
+ | Concept | Closed values or states | Meaning | Availability by variant/orientation/state | Default | Owner | Stability | Invalid-value behavior |
63
+ | ------------------ | ------------------------------------------ | ------------------------------------------- | ----------------------------------------- | ---------------- | -------------------------------- | --------- | ---------------------- |
64
+ | logical axis | `inline`, `block`, `both` | axes where native scrolling is allowed | all | `block` | `spec:AST-025` | stable | type error |
65
+ | overscroll | `allow`, `contain` | whether effective axes propagate at an edge | all | `allow` | `spec:AST-025` | stable | type error |
66
+ | viewport sizing | `width`, `height`, `maxWidth`, `minHeight` | standard container geometry on the viewport | all | auto | `component:ScrollableArea` | stable | type error |
67
+ | content padding | shared logical spacing-step ladder | content-box inset and published geometry | all | `0` | `architecture:container-padding` | stable | type error |
68
+ | full bleed | `false`, `true` | whether viewport escapes inherited inset | all | `false` | `architecture:container-padding` | stable | type error |
69
+ | Sticky containment | `whenScrollable`, `always` | whether fitting content captures Sticky | all | `whenScrollable` | `spec:AST-025` | stable | type error |
70
+ | viewport semantics | `group`, `region` plus required label | names a conditional keyboard scroll target | all | `group` | `component:ScrollableArea` | stable | type error |
71
+
72
+ ## Behavioral and layout contract
73
+
74
+ | ID | Invariant | Basis | Acceptance and implementation state |
75
+ | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------- |
76
+ | FR1 | The root MUST remain the one native viewport and receive the public ref, standard container sizing props, and styling inputs through StyleX. | `spec:AST-025` IR3–IR4 | implemented |
77
+ | FR2 | The inner content wrapper MUST be a real normal-flow block box observed with the viewport, use a 100% minimum size, and use max-content inline sizing only when inline scrolling is requested. Children participate in that box rather than the viewport's flex/grid formatting context, and the minimum block size does not create a definite percentage-height basis; structures that must preserve those semantics adopt the hook directly. | `spec:AST-025` FR5–FR6 | implemented |
78
+ | FR3 | Native scrollbar behavior MUST remain authoritative; the default thumb uses `--color-neutral`, the track is transparent, and forced colors restore platform presentation. | `spec:AST-025` FR15 | implemented |
79
+ | FR4 | Scroll-state container queries MAY enhance descendant stuck/edge presentation, but hook state MUST remain the cross-browser source of truth. | `spec:AST-025` platform support | implemented progressively |
80
+ | FR5 | The content box MUST apply and publish logical padding with edge-over-axis-over-uniform precedence, defaulting every published edge to zero. `isFullBleed` MUST let only the viewport consume inherited inset; nested content continues to read the content box's published values. | `architecture:container-padding` | implemented |
81
+ | FR6 | A fitting viewport MUST use `clip` on both physical axes so it prevents paint overflow without capturing native Sticky. Excess requested-axis geometry MUST activate the writing-mode-resolved `auto`/`hidden` pair. `stickyContainment="always"` MUST explicitly retain that pair while fitting. | `spec:AST-025` FR21 | implemented |
82
+
83
+ ### Allowed variation
84
+
85
+ - **AV1 — Sizing and presentation.** Consumer `xstyle`, `className`, and `style`
86
+ may size the viewport and adjust standards-based native scrollbar properties.
87
+ - **AV2 — Content.** Any flow content may render in the observed box; builders
88
+ remain responsible for content semantics.
89
+
90
+ ### Representative states
91
+
92
+ | State | Required invariant | Allowed variation |
93
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------- |
94
+ | fitting | `clip` on both axes; no tab stop; Sticky passes outward unless containment is `always`; both edges true | viewport size and content |
95
+ | overflowing | named tab stop; per-axis logical edge state | one or both requested axes |
96
+ | overflow removed while focused | remove future tab stop without moving focus | current focus remains |
97
+ | forced colors | native platform scrollbar presentation | platform rendering |
98
+
99
+ ### Transformation and precedence order
100
+
101
+ - **ORD1 — Props.** Consumer DOM and styling props compose first; behavior-owned
102
+ ref, accessibility, and active-axis chaining win documented conflicts.
103
+
104
+ ### Performance and resources
105
+
106
+ - **PR1 — Shared, coalesced observation.** Viewport and content resize signals use
107
+ the shared observer, and repeated invalidations publish at most once per frame.
108
+
109
+ ## Accessibility contract
110
+
111
+ - **AR1 — Conditional access.** The viewport enters sequential navigation only
112
+ while at least one requested axis is effective, with the supplied label and role.
113
+ - **AR2 — Focus continuity.** Losing overflow MUST NOT blur or move focus.
114
+
115
+ ## Design relationships
116
+
117
+ | Anatomy or state | Design requirement | Representation authority | Hierarchy role | Component contract |
118
+ | ---------------- | ----------------------------------------------------------------- | ------------------------ | -------------- | ------------------ |
119
+ | Viewport | platform scrollbar with token-colored thumb and transparent track | `spec:AST-025` | supporting | FR1, FR3 |
120
+ | Content box | no independent paint by default | caller content | structural | FR2 |
121
+
122
+ ### Theming anatomy
123
+
124
+ <!-- anatomy-theming:v1 -->
125
+
126
+ ```json
127
+ {
128
+ "Viewport": {"target": "scrollable-area"},
129
+ "Content box": {
130
+ "none": {
131
+ "reason": "intentional: The content box is structural and paints no component-owned appearance."
132
+ }
133
+ }
134
+ }
135
+ ```
136
+
137
+ ## Family and system relationships
138
+
139
+ - `module:ScrollableArea/useScrollableArea` owns reusable measurement,
140
+ overflow-boundary, Sticky-containment, accessibility, and registration behavior.
141
+ - `architecture:container-padding` owns optional content inset publication and
142
+ opt-in viewport bleed.
143
+ - `spec:AST-025` owns shared effective-axis, observation, accessibility, overscroll,
144
+ ownership, edge, and native presentation rules.
145
+
146
+ ## Verification map
147
+
148
+ | Contract | Verification | Representative states | Mutation or failure expectation | Audit section |
149
+ | ---------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
150
+ | FR1–FR6, AR1–AR2 | component/hook tests and Storybook browser probe | fit, overflow, focused loss, LTR/RTL/vertical, nested allow/contain, fitting Sticky passthrough/containment, native scrollbar, padding, full bleed | duplicate viewport, stale state, dead nested scroll zone, implicit Sticky containment, unconditional tab stop, or stale inset fails | `audit:ScrollableArea/behavior` |
151
+
152
+ ## Decision log
153
+
154
+ ### DEC-1 — The convenience component owns one explicit content box
155
+
156
+ **Reference:** `component:ScrollableArea/DEC-1`
157
+ **Decider:** cixzhang, 2026-09-11
158
+
159
+ A stable viewport/content structure gives the reference component dependable live
160
+ measurement while leaving structure-owning components free to adopt the hook directly.
161
+
162
+ ### DEC-2 — ScrollableArea participates in the container-padding system
163
+
164
+ **Reference:** `component:ScrollableArea/DEC-2`
165
+ **Decider:** cixzhang, 2026-09-11
166
+
167
+ The content box publishes its actual logical padding, zero by default, so nested
168
+ bleed consumers never inherit stale inset. The viewport consumes ancestor inset
169
+ only when `isFullBleed` is explicit; scrolling alone does not silently escape its
170
+ parent container.
171
+
172
+ ### DEC-3 — Fitting content passes Sticky ownership outward by default
173
+
174
+ **Reference:** `component:ScrollableArea/DEC-3`
175
+ **Decider:** cixzhang, 2026-09-12
176
+
177
+ A fitting viewport uses `clip` on both axes to prevent a pre-measure paint flash
178
+ without becoming a CSS scroll container. Excess requested-axis geometry activates
179
+ the writing-mode-resolved scroll boundary. `stickyContainment="always"` is the
180
+ explicit opt-in for retaining that boundary while fitting.
181
+
182
+ ## Open questions
183
+
184
+ None.
185
+
186
+ ## Content boundary
187
+
188
+ This file does not duplicate consumer examples, the shared hook algorithm, Sticky's
189
+ future public API, or migration plans for existing component-owned viewports.