@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,299 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {readFileSync} from 'node:fs';
4
+ import {act, render, screen} from '@testing-library/react';
5
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
6
+ import {ScrollableArea} from './ScrollableArea';
7
+
8
+ function setGeometry(
9
+ element: HTMLElement,
10
+ values: Partial<
11
+ Pick<
12
+ HTMLElement,
13
+ 'clientWidth' | 'clientHeight' | 'scrollWidth' | 'scrollHeight'
14
+ >
15
+ >,
16
+ ) {
17
+ for (const [key, value] of Object.entries(values)) {
18
+ Object.defineProperty(element, key, {
19
+ configurable: true,
20
+ value,
21
+ writable: true,
22
+ });
23
+ }
24
+ }
25
+
26
+ describe('ScrollableArea', () => {
27
+ let frames: FrameRequestCallback[];
28
+
29
+ beforeEach(() => {
30
+ frames = [];
31
+ vi.stubGlobal(
32
+ 'requestAnimationFrame',
33
+ vi.fn((callback: FrameRequestCallback) => {
34
+ frames.push(callback);
35
+ return frames.length;
36
+ }),
37
+ );
38
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
39
+ vi.stubGlobal(
40
+ 'ResizeObserver',
41
+ vi.fn(function () {
42
+ return {observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn()};
43
+ }),
44
+ );
45
+ });
46
+
47
+ afterEach(() => vi.unstubAllGlobals());
48
+
49
+ function flushFrame() {
50
+ void act(() => {
51
+ const pending = frames.splice(0);
52
+ pending.forEach(callback => callback(performance.now()));
53
+ });
54
+ }
55
+
56
+ it('renders one native viewport and one real content box', () => {
57
+ render(
58
+ <ScrollableArea label="Messages" data-testid="viewport">
59
+ <p>Message history</p>
60
+ </ScrollableArea>,
61
+ );
62
+
63
+ const viewport = screen.getByTestId('viewport');
64
+ expect(viewport).toHaveAccessibleName('Messages');
65
+ expect(viewport).toHaveAttribute('role', 'group');
66
+ expect(viewport).toHaveAttribute('data-scroll-axis', 'block');
67
+ expect(viewport).not.toHaveAttribute('tabindex');
68
+ expect(viewport.children).toHaveLength(1);
69
+ expect(viewport.firstElementChild).toHaveAttribute('data-scroll-content');
70
+ expect(viewport).toHaveTextContent('Message history');
71
+ });
72
+
73
+ it('becomes keyboard reachable only when the requested axis is effective', () => {
74
+ render(
75
+ <ScrollableArea
76
+ axis="block"
77
+ label="Messages"
78
+ data-testid="viewport"
79
+ style={{height: 100}}>
80
+ <p>Message history</p>
81
+ </ScrollableArea>,
82
+ );
83
+ const viewport = screen.getByTestId('viewport');
84
+ // jsdom does not resolve StyleX's dynamic CSS variables. Mirror the
85
+ // generated class values so this integration test can exercise measurement.
86
+ viewport.style.overflowX = 'hidden';
87
+ viewport.style.overflowY = 'auto';
88
+ setGeometry(viewport, {
89
+ clientWidth: 100,
90
+ clientHeight: 100,
91
+ scrollWidth: 100,
92
+ scrollHeight: 180,
93
+ });
94
+
95
+ void act(() => viewport.dispatchEvent(new Event('scroll')));
96
+ flushFrame();
97
+ expect(viewport).toHaveAttribute('tabindex', '0');
98
+ expect(viewport).toHaveAttribute('data-scrollable-block', 'true');
99
+
100
+ setGeometry(viewport, {scrollHeight: 100});
101
+ viewport.focus();
102
+ void act(() => viewport.dispatchEvent(new Event('scroll')));
103
+ flushFrame();
104
+ expect(viewport).toHaveAttribute('tabindex', '-1');
105
+ expect(document.activeElement).toBe(viewport);
106
+ });
107
+
108
+ it('keeps viewport props, styles, class names, events, and refs', () => {
109
+ const onClick = vi.fn();
110
+ const ref = vi.fn();
111
+ render(
112
+ <ScrollableArea
113
+ label="Results"
114
+ role="region"
115
+ ref={ref}
116
+ onClick={onClick}
117
+ className="consumer-class"
118
+ style={{maxHeight: 200}}
119
+ data-testid="viewport">
120
+ Results
121
+ </ScrollableArea>,
122
+ );
123
+
124
+ const viewport = screen.getByTestId('viewport');
125
+ viewport.click();
126
+ expect(onClick).toHaveBeenCalledTimes(1);
127
+ expect(ref).toHaveBeenCalledWith(viewport);
128
+ expect(viewport).toHaveClass('astryx-scrollable-area', 'consumer-class');
129
+ expect(viewport.style.maxHeight).toBe('200px');
130
+ expect(viewport).toHaveAttribute('role', 'region');
131
+ });
132
+
133
+ it('reflects logical axis and chaining intent without containing a fitting area', () => {
134
+ render(
135
+ <ScrollableArea
136
+ axis="both"
137
+ label="Canvas"
138
+ overscroll="contain"
139
+ data-testid="viewport">
140
+ Canvas
141
+ </ScrollableArea>,
142
+ );
143
+ const viewport = screen.getByTestId('viewport');
144
+ expect(viewport).toHaveAttribute('data-axis', 'both');
145
+ expect(viewport).toHaveAttribute('data-scroll-axis', 'both');
146
+ expect(viewport.style.overscrollBehaviorX).toBe('auto');
147
+ expect(viewport.style.overscrollBehaviorY).toBe('auto');
148
+ });
149
+
150
+ it('keeps logical overflow and content sizing for writing-mode changes', () => {
151
+ const {rerender} = render(
152
+ <ScrollableArea axis="inline" label="Timeline" data-testid="viewport">
153
+ Timeline
154
+ </ScrollableArea>,
155
+ );
156
+ const viewport = screen.getByTestId('viewport');
157
+ const content = viewport.firstElementChild as HTMLElement;
158
+ const inlineViewportClass = viewport.className;
159
+ expect(viewport.style.overflowInline).toBe('');
160
+ expect(viewport.style.overflowBlock).toBe('');
161
+ const inlineContentClass = content.className;
162
+ expect(content.style.inlineSize).toBe('');
163
+ expect(content.style.minInlineSize).toBe('');
164
+
165
+ rerender(
166
+ <ScrollableArea axis="block" label="Timeline" data-testid="viewport">
167
+ Timeline
168
+ </ScrollableArea>,
169
+ );
170
+ expect(viewport.className).not.toBe(inlineViewportClass);
171
+ expect(viewport.style.overflowInline).toBe('');
172
+ expect(viewport.style.overflowBlock).toBe('');
173
+ expect(content.className).not.toBe(inlineContentClass);
174
+ expect(content.style.inlineSize).toBe('');
175
+ const source = stylesSource();
176
+ expect(source).not.toContain('dynamicStyles.overflow(');
177
+ expect(source).not.toContain('logicalOverflowStyle');
178
+ expect(hookSource()).toContain('styles.overflow(');
179
+ expect(source).toContain("minInlineSize: '100%'");
180
+ expect(source).toContain("inlineSize: 'max-content'");
181
+ });
182
+
183
+ it('uses clip while fitting and makes fitting Sticky containment explicit', () => {
184
+ const {rerender} = render(
185
+ <ScrollableArea label="Messages" data-testid="viewport">
186
+ Messages
187
+ </ScrollableArea>,
188
+ );
189
+ const viewport = screen.getByTestId('viewport');
190
+ expect(viewport.getAttribute('style')).toContain('clip');
191
+ expect(viewport).not.toHaveAttribute('tabindex');
192
+
193
+ rerender(
194
+ <ScrollableArea
195
+ label="Messages"
196
+ stickyContainment="always"
197
+ data-testid="viewport">
198
+ Messages
199
+ </ScrollableArea>,
200
+ );
201
+ expect(viewport.getAttribute('style')).toContain('auto');
202
+ expect(viewport.getAttribute('style')).toContain('hidden');
203
+ expect(viewport).not.toHaveAttribute('tabindex');
204
+ });
205
+
206
+ it('accepts standard container sizing props on the viewport', () => {
207
+ render(
208
+ <ScrollableArea
209
+ label="Messages"
210
+ width={320}
211
+ height="50vh"
212
+ maxWidth="100%"
213
+ minHeight={120}
214
+ data-testid="viewport">
215
+ Messages
216
+ </ScrollableArea>,
217
+ );
218
+
219
+ expect(screen.getByTestId('viewport')).toBeInTheDocument();
220
+ expect(stylesSource()).toContain('dynamicStyles.sizing(');
221
+ });
222
+
223
+ it('publishes content padding without changing the viewport default', () => {
224
+ const {rerender} = render(
225
+ <ScrollableArea label="Messages" data-testid="viewport">
226
+ Messages
227
+ </ScrollableArea>,
228
+ );
229
+ const viewport = screen.getByTestId('viewport');
230
+ const content = viewport.firstElementChild as HTMLElement;
231
+ const defaultViewportClass = viewport.className;
232
+ const defaultContentClass = content.className;
233
+
234
+ rerender(
235
+ <ScrollableArea
236
+ label="Messages"
237
+ padding={4}
238
+ paddingInlineEnd={2}
239
+ data-testid="viewport">
240
+ Messages
241
+ </ScrollableArea>,
242
+ );
243
+
244
+ expect(viewport.className).toBe(defaultViewportClass);
245
+ expect(content.className).not.toBe(defaultContentClass);
246
+ const source = stylesSource();
247
+ expect(source).toContain('containerPaddingInlineVarStyles[padding]');
248
+ expect(source).toContain(
249
+ 'containerPaddingInlineEndVarStyles[paddingInlineEnd]',
250
+ );
251
+ });
252
+
253
+ it('keeps inherited container bleed opt-in on the viewport', () => {
254
+ const {rerender} = render(
255
+ <ScrollableArea label="Messages" data-testid="viewport">
256
+ Messages
257
+ </ScrollableArea>,
258
+ );
259
+ const viewport = screen.getByTestId('viewport');
260
+ const containedClass = viewport.className;
261
+
262
+ rerender(
263
+ <ScrollableArea label="Messages" isFullBleed data-testid="viewport">
264
+ Messages
265
+ </ScrollableArea>,
266
+ );
267
+
268
+ expect(viewport.className).not.toBe(containedClass);
269
+ const source = stylesSource();
270
+ expect(source).toContain('var(--container-padding-inline-start, 0px)');
271
+ expect(source).toContain('isFullBleed && styles.fullBleed');
272
+ });
273
+
274
+ it('uses the neutral token for native scrollbar color with a transparent track', () => {
275
+ const source = stylesSource();
276
+ expect(source).toContain("colorVars['--color-neutral']");
277
+ expect(source).toContain('transparent');
278
+ expect(source).toContain("'@media (forced-colors: active)': 'auto'");
279
+ });
280
+
281
+ it('guards scroll-state query containment as a progressive enhancement', () => {
282
+ const source = stylesSource();
283
+ expect(source).toContain("'@supports (container-type: scroll-state)'");
284
+ expect(source).toContain("'scroll-state'");
285
+ });
286
+ });
287
+
288
+ function hookSource(): string {
289
+ return readFileSync('packages/core/src/hooks/useScrollableArea.ts', 'utf8');
290
+ }
291
+
292
+ function stylesSource(): string {
293
+ // This focused source assertion complements the built-CSS browser probe: it
294
+ // keeps the capability guard and token reference reviewable at unit-test speed.
295
+ return readFileSync(
296
+ 'packages/core/src/ScrollableArea/ScrollableArea.tsx',
297
+ 'utf8',
298
+ );
299
+ }
@@ -0,0 +1,259 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file ScrollableArea.tsx
7
+ * @input Logical scroll intent, accessible name, overscroll policy, viewport sizing, content padding, and content
8
+ * @output Native scroll viewport with an observed real content box
9
+ * @position Reference composition over useScrollableArea
10
+ *
11
+ * SYNC: When modified, update:
12
+ * - /packages/core/src/ScrollableArea/ScrollableArea.test.tsx
13
+ * - /packages/core/src/ScrollableArea/ScrollableArea.doc.mjs
14
+ * - /apps/storybook/stories/ScrollableArea.stories.tsx
15
+ */
16
+
17
+ import type {ReactNode} from 'react';
18
+ import * as stylex from '@stylexjs/stylex';
19
+ import type {BaseProps} from '../BaseProps';
20
+ import {
21
+ paddingStyles,
22
+ paddingInlineStyles,
23
+ paddingInlineStartStyles,
24
+ paddingInlineEndStyles,
25
+ paddingBlockStyles,
26
+ paddingBlockStartStyles,
27
+ paddingBlockEndStyles,
28
+ containerPaddingInlineVarStyles,
29
+ containerPaddingInlineStartVarStyles,
30
+ containerPaddingInlineEndVarStyles,
31
+ containerPaddingBlockStartVarStyles,
32
+ containerPaddingBlockEndVarStyles,
33
+ } from '../Layout/padding.stylex';
34
+ import type {SizeValue, SpacingStep} from '../utils/types';
35
+ import {
36
+ useScrollableArea,
37
+ type ScrollAxis,
38
+ type ScrollOverscroll,
39
+ type ScrollStickyContainment,
40
+ } from '../hooks/useScrollableArea';
41
+ import {colorVars} from '../theme/tokens.stylex';
42
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
43
+ import {mergeProps} from '../utils/mergeProps';
44
+ import {themeProps} from '../utils/themeProps';
45
+
46
+ const styles = stylex.create({
47
+ viewport: {
48
+ boxSizing: 'border-box',
49
+ maxInlineSize: '100%',
50
+ maxBlockSize: '100%',
51
+ WebkitOverflowScrolling: 'touch',
52
+ scrollbarColor: {
53
+ default: `${colorVars['--color-neutral']} transparent`,
54
+ '@media (forced-colors: active)': 'auto',
55
+ },
56
+ scrollbarWidth: 'auto',
57
+ scrollbarGutter: 'auto',
58
+ // Chromium can resolve CSS-only stuck/edge presentation for descendants.
59
+ // Unsupported engines keep the hook state as the behavior source of truth.
60
+ containerType: {
61
+ default: null,
62
+ '@supports (container-type: scroll-state)': 'scroll-state',
63
+ },
64
+ },
65
+ fullBleed: {
66
+ marginInlineStart: 'calc(-1 * var(--container-padding-inline-start, 0px))',
67
+ marginInlineEnd: 'calc(-1 * var(--container-padding-inline-end, 0px))',
68
+ maxInlineSize:
69
+ 'calc(100% + var(--container-padding-inline-start, 0px) + var(--container-padding-inline-end, 0px))',
70
+ marginBlockStart: {
71
+ default: null,
72
+ ':first-child': 'calc(-1 * var(--container-padding-block-start, 0px))',
73
+ },
74
+ marginBlockEnd: {
75
+ default: null,
76
+ ':last-child': 'calc(-1 * var(--container-padding-block-end, 0px))',
77
+ },
78
+ },
79
+ content: {
80
+ boxSizing: 'border-box',
81
+ minInlineSize: '100%',
82
+ minBlockSize: '100%',
83
+ },
84
+ contentWithInlineOverflow: {
85
+ inlineSize: 'max-content',
86
+ },
87
+ });
88
+
89
+ const dynamicStyles = stylex.create({
90
+ sizing: (
91
+ width: SizeValue | null,
92
+ height: SizeValue | null,
93
+ maxWidth: SizeValue | null,
94
+ minHeight: SizeValue | null,
95
+ ) => ({width, height, maxWidth, minHeight}),
96
+ });
97
+
98
+ export type ScrollableAreaStickyContainment = ScrollStickyContainment;
99
+
100
+ export interface ScrollableAreaProps extends Omit<
101
+ BaseProps<HTMLDivElement>,
102
+ 'aria-label' | 'children' | 'role' | 'tabIndex'
103
+ > {
104
+ /** Content rendered inside the observed content box. */
105
+ children?: ReactNode;
106
+ /** Logical axis or axes where scrolling is allowed. @default 'block' */
107
+ axis?: ScrollAxis;
108
+ /** Accessible name used when an effective viewport enters the tab order. */
109
+ label: string;
110
+ /** Semantics for the named scroll viewport. @default 'group' */
111
+ role?: 'group' | 'region';
112
+ /** Whether effective axes pass scroll gestures to ancestors at an edge. @default 'allow' */
113
+ overscroll?: ScrollOverscroll;
114
+ /** Width of the viewport; numbers are interpreted as pixels. */
115
+ width?: SizeValue;
116
+ /** Height of the viewport; numbers are interpreted as pixels. */
117
+ height?: SizeValue;
118
+ /** Maximum width of the viewport. */
119
+ maxWidth?: SizeValue;
120
+ /** Minimum height of the viewport. */
121
+ minHeight?: SizeValue;
122
+ /** Whether a fitting viewport deliberately remains a Sticky containing boundary. @default 'whenScrollable' */
123
+ stickyContainment?: ScrollableAreaStickyContainment;
124
+ /** Content padding using the shared spacing scale. @default 0 */
125
+ padding?: SpacingStep;
126
+ /** Logical inline-axis content padding; overrides `padding` on that axis. */
127
+ paddingInline?: SpacingStep;
128
+ /** Logical inline-start content padding; overrides broader padding values. */
129
+ paddingInlineStart?: SpacingStep;
130
+ /** Logical inline-end content padding; overrides broader padding values. */
131
+ paddingInlineEnd?: SpacingStep;
132
+ /** Logical block-axis content padding; overrides `padding` on that axis. */
133
+ paddingBlock?: SpacingStep;
134
+ /** Logical block-start content padding; overrides broader padding values. */
135
+ paddingBlockStart?: SpacingStep;
136
+ /** Logical block-end content padding; overrides broader padding values. */
137
+ paddingBlockEnd?: SpacingStep;
138
+ /** Let the viewport escape inherited container padding. @default false */
139
+ isFullBleed?: boolean;
140
+ /** Ref connected to the native scroll viewport. */
141
+ ref?: React.Ref<HTMLDivElement>;
142
+ }
143
+
144
+ /**
145
+ * A native scroll viewport with axis-aware accessibility and chaining.
146
+ *
147
+ * The root is the viewport. Its single child is a normal block content box with
148
+ * a 100% minimum size; children participate in that box rather than directly in
149
+ * viewport flex/grid layout. Inline scrolling gives the box max-content inline
150
+ * sizing, and its minimum block size deliberately does not create a definite
151
+ * percentage-height basis. Use the hook on owned structure when those formatting
152
+ * semantics must stay unchanged. Native scrolling and platform scrollbars remain
153
+ * authoritative.
154
+ *
155
+ * @example
156
+ * ```
157
+ * <ScrollableArea axis="block" label="Activity history">
158
+ * <ActivityList />
159
+ * </ScrollableArea>
160
+ * ```
161
+ */
162
+ export function ScrollableArea({
163
+ children,
164
+ axis = 'block',
165
+ label,
166
+ role = 'group',
167
+ overscroll = 'allow',
168
+ width,
169
+ height,
170
+ maxWidth,
171
+ minHeight,
172
+ stickyContainment = 'whenScrollable',
173
+ padding = 0,
174
+ paddingInline,
175
+ paddingInlineStart,
176
+ paddingInlineEnd,
177
+ paddingBlock,
178
+ paddingBlockStart,
179
+ paddingBlockEnd,
180
+ isFullBleed = false,
181
+ ref,
182
+ xstyle,
183
+ className,
184
+ style,
185
+ ...props
186
+ }: ScrollableAreaProps) {
187
+ const {getViewportProps, getContentProps} = useScrollableArea({
188
+ axis,
189
+ keyboardAccess: {owner: 'viewport', label, role},
190
+ overscroll,
191
+ stickyContainment,
192
+ });
193
+
194
+ const mergedViewportProps = mergeProps(
195
+ themeProps('scrollable-area', {axis}),
196
+ stylex.props(
197
+ styles.viewport,
198
+ isFullBleed && styles.fullBleed,
199
+ focusOutlineStyles.focusVisible,
200
+ dynamicStyles.sizing(
201
+ width ?? null,
202
+ height ?? null,
203
+ maxWidth ?? null,
204
+ minHeight ?? null,
205
+ ),
206
+ xstyle,
207
+ ),
208
+ className,
209
+ style,
210
+ );
211
+ const viewportProps = getViewportProps<HTMLDivElement>({
212
+ ...props,
213
+ ref,
214
+ ...mergedViewportProps,
215
+ });
216
+
217
+ return (
218
+ <div {...viewportProps}>
219
+ <div
220
+ {...getContentProps<HTMLDivElement>({
221
+ ...stylex.props(
222
+ styles.content,
223
+ axis !== 'block' && styles.contentWithInlineOverflow,
224
+ paddingStyles[padding],
225
+ containerPaddingInlineVarStyles[padding],
226
+ containerPaddingBlockStartVarStyles[padding],
227
+ containerPaddingBlockEndVarStyles[padding],
228
+ paddingInline != null && paddingInlineStyles[paddingInline],
229
+ paddingInline != null &&
230
+ containerPaddingInlineVarStyles[paddingInline],
231
+ paddingBlock != null && paddingBlockStyles[paddingBlock],
232
+ paddingBlock != null &&
233
+ containerPaddingBlockStartVarStyles[paddingBlock],
234
+ paddingBlock != null &&
235
+ containerPaddingBlockEndVarStyles[paddingBlock],
236
+ paddingInlineStart != null &&
237
+ paddingInlineStartStyles[paddingInlineStart],
238
+ paddingInlineStart != null &&
239
+ containerPaddingInlineStartVarStyles[paddingInlineStart],
240
+ paddingInlineEnd != null &&
241
+ paddingInlineEndStyles[paddingInlineEnd],
242
+ paddingInlineEnd != null &&
243
+ containerPaddingInlineEndVarStyles[paddingInlineEnd],
244
+ paddingBlockStart != null &&
245
+ paddingBlockStartStyles[paddingBlockStart],
246
+ paddingBlockStart != null &&
247
+ containerPaddingBlockStartVarStyles[paddingBlockStart],
248
+ paddingBlockEnd != null && paddingBlockEndStyles[paddingBlockEnd],
249
+ paddingBlockEnd != null &&
250
+ containerPaddingBlockEndVarStyles[paddingBlockEnd],
251
+ ),
252
+ })}>
253
+ {children}
254
+ </div>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ ScrollableArea.displayName = 'ScrollableArea';
@@ -0,0 +1,26 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file index.ts
5
+ * @input ScrollableArea implementation and shared hook types
6
+ * @output Public ScrollableArea package surface
7
+ * @position Package entry point for ScrollableArea
8
+ */
9
+
10
+ export {ScrollableArea} from './ScrollableArea';
11
+ export type {
12
+ ScrollableAreaProps,
13
+ ScrollableAreaStickyContainment,
14
+ } from './ScrollableArea';
15
+ export {useScrollableArea} from '../hooks/useScrollableArea';
16
+ export type {
17
+ ScrollAxis,
18
+ ScrollAxisState,
19
+ ScrollOverscroll,
20
+ ScrollStickyContainment,
21
+ ScrollKeyboardAccess,
22
+ ScrollableAreaState,
23
+ ScrollableElementProps,
24
+ UseScrollableAreaOptions,
25
+ UseScrollableAreaResult,
26
+ } from '../hooks/useScrollableArea';