@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,469 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useScrollableArea.ts
7
+ * @input Logical scroll intent, keyboard ownership, caller-owned viewport/content props
8
+ * @output Safe prop getters and stable per-axis effective scroll state
9
+ * @position Canonical behavior core for ScrollableArea and structure-owning adopters
10
+ *
11
+ * SYNC: When modified, update:
12
+ * - /packages/core/src/hooks/useScrollableArea.doc.mjs
13
+ * - /packages/core/src/hooks/useScrollableArea.test.tsx
14
+ * - /packages/core/src/hooks/index.ts
15
+ */
16
+
17
+ import {
18
+ useCallback,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ type CSSProperties,
23
+ type Ref,
24
+ type RefAttributes,
25
+ type RefCallback,
26
+ } from 'react';
27
+ import * as stylex from '@stylexjs/stylex';
28
+ import type {BaseProps} from '../BaseProps';
29
+ import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';
30
+ import {mergeProps} from '../utils/mergeProps';
31
+ import {mergeRefs} from '../utils/mergeRefs';
32
+ import {observeResize} from '../utils/sharedResizeObserver';
33
+ import {
34
+ getLogicalAxisMapping,
35
+ measureLogicalOverflowGeometry,
36
+ measureLogicalScrollAxis,
37
+ type LogicalAxisMapping,
38
+ type LogicalOverflowGeometry,
39
+ } from './scrollGeometry';
40
+ import {
41
+ registerScrollOwner,
42
+ unregisterScrollOwner,
43
+ } from './scrollOwnerRegistry';
44
+
45
+ export type ScrollAxis = 'inline' | 'block' | 'both';
46
+ export type ScrollOverscroll = 'allow' | 'contain';
47
+ export type ScrollStickyContainment = 'whenScrollable' | 'always';
48
+
49
+ export type ScrollKeyboardAccess =
50
+ | {owner: 'content'}
51
+ | {owner: 'viewport'; label: string; role?: 'group' | 'region'};
52
+
53
+ export interface ScrollAxisState {
54
+ isScrollable: boolean;
55
+ atStart: boolean;
56
+ atEnd: boolean;
57
+ }
58
+
59
+ export interface ScrollableAreaState {
60
+ inline: ScrollAxisState;
61
+ block: ScrollAxisState;
62
+ }
63
+
64
+ export interface UseScrollableAreaOptions {
65
+ axis: ScrollAxis;
66
+ keyboardAccess: ScrollKeyboardAccess;
67
+ overscroll?: ScrollOverscroll;
68
+ stickyContainment?: ScrollStickyContainment;
69
+ }
70
+
71
+ export type ScrollableElementProps<E extends HTMLElement> = BaseProps<E> &
72
+ RefAttributes<E>;
73
+
74
+ export interface UseScrollableAreaResult {
75
+ getViewportProps<E extends HTMLElement>(
76
+ props?: ScrollableElementProps<E>,
77
+ ): ScrollableElementProps<E>;
78
+ getContentProps<E extends HTMLElement>(
79
+ props?: ScrollableElementProps<E>,
80
+ ): ScrollableElementProps<E>;
81
+ state: ScrollableAreaState;
82
+ }
83
+
84
+ const INACTIVE_AXIS_STATE: ScrollAxisState = {
85
+ isScrollable: false,
86
+ atStart: true,
87
+ atEnd: true,
88
+ };
89
+ const INITIAL_STATE: ScrollableAreaState = {
90
+ inline: INACTIVE_AXIS_STATE,
91
+ block: INACTIVE_AXIS_STATE,
92
+ };
93
+ const INITIAL_OVERFLOW: LogicalOverflowGeometry = {
94
+ inline: false,
95
+ block: false,
96
+ };
97
+ const INITIAL_MAPPING: LogicalAxisMapping = {
98
+ inline: 'x',
99
+ block: 'y',
100
+ inlineReversed: false,
101
+ blockReversed: false,
102
+ };
103
+
104
+ const styles = stylex.create({
105
+ overflow: (
106
+ overflowX: 'auto' | 'hidden' | 'clip',
107
+ overflowY: 'auto' | 'hidden' | 'clip',
108
+ ) => ({overflowX, overflowY}),
109
+ });
110
+
111
+ function axisIsRequested(requested: ScrollAxis, axis: 'inline' | 'block') {
112
+ return requested === axis || requested === 'both';
113
+ }
114
+
115
+ function statesEqual(a: ScrollableAreaState, b: ScrollableAreaState): boolean {
116
+ return (
117
+ a.inline.isScrollable === b.inline.isScrollable &&
118
+ a.inline.atStart === b.inline.atStart &&
119
+ a.inline.atEnd === b.inline.atEnd &&
120
+ a.block.isScrollable === b.block.isScrollable &&
121
+ a.block.atStart === b.block.atStart &&
122
+ a.block.atEnd === b.block.atEnd
123
+ );
124
+ }
125
+
126
+ function overflowEqual(
127
+ a: LogicalOverflowGeometry,
128
+ b: LogicalOverflowGeometry,
129
+ ): boolean {
130
+ return a.inline === b.inline && a.block === b.block;
131
+ }
132
+
133
+ function mappingsEqual(a: LogicalAxisMapping, b: LogicalAxisMapping): boolean {
134
+ return (
135
+ a.inline === b.inline &&
136
+ a.block === b.block &&
137
+ a.inlineReversed === b.inlineReversed &&
138
+ a.blockReversed === b.blockReversed
139
+ );
140
+ }
141
+
142
+ function withoutOwnedOverflow(
143
+ style: CSSProperties | undefined,
144
+ ): CSSProperties | undefined {
145
+ if (style == null) {
146
+ return undefined;
147
+ }
148
+ const result = {...style};
149
+ delete result.overflow;
150
+ delete result.overflowX;
151
+ delete result.overflowY;
152
+ delete result.overflowInline;
153
+ delete result.overflowBlock;
154
+ return result;
155
+ }
156
+
157
+ function useStableComposedRef(
158
+ internalRef: RefCallback<HTMLElement>,
159
+ ): (externalRef: Ref<HTMLElement> | undefined) => RefCallback<HTMLElement> {
160
+ const cachedExternalRef = useRef<Ref<HTMLElement> | undefined>(undefined);
161
+ const cachedComposedRef = useRef<RefCallback<HTMLElement> | null>(null);
162
+ return useCallback(
163
+ (externalRef: Ref<HTMLElement> | undefined) => {
164
+ if (
165
+ cachedComposedRef.current != null &&
166
+ Object.is(cachedExternalRef.current, externalRef)
167
+ ) {
168
+ return cachedComposedRef.current;
169
+ }
170
+ const composed = mergeRefs(externalRef, internalRef);
171
+ cachedExternalRef.current = externalRef;
172
+ cachedComposedRef.current = composed;
173
+ return composed;
174
+ },
175
+ [internalRef],
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Adds axis-aware scroll behavior to caller-owned viewport and content boxes.
181
+ * The returned prop getters compose refs and preserve caller props, so spread
182
+ * order cannot detach measurement or override behavior-owned accessibility.
183
+ */
184
+ export function useScrollableArea({
185
+ axis,
186
+ keyboardAccess,
187
+ overscroll = 'allow',
188
+ stickyContainment = 'whenScrollable',
189
+ }: UseScrollableAreaOptions): UseScrollableAreaResult {
190
+ const [viewport, setViewport] = useState<HTMLElement | null>(null);
191
+ const [content, setContent] = useState<HTMLElement | null>(null);
192
+ const [measured, setMeasured] = useState<ScrollableAreaState>(INITIAL_STATE);
193
+ const [overflow, setOverflow] =
194
+ useState<LogicalOverflowGeometry>(INITIAL_OVERFLOW);
195
+ const [mapping, setMapping] = useState<LogicalAxisMapping>(INITIAL_MAPPING);
196
+ const stateRef = useRef<ScrollableAreaState>(INITIAL_STATE);
197
+
198
+ const viewportRef = useCallback((node: HTMLElement | null) => {
199
+ setViewport(current => (current === node ? current : node));
200
+ }, []);
201
+ const contentRef = useCallback((node: HTMLElement | null) => {
202
+ setContent(current => (current === node ? current : node));
203
+ }, []);
204
+ const getComposedViewportRef = useStableComposedRef(viewportRef);
205
+ const getComposedContentRef = useStableComposedRef(contentRef);
206
+
207
+ useIsomorphicLayoutEffect(() => {
208
+ if (viewport == null || content == null) {
209
+ return;
210
+ }
211
+
212
+ let frame: number | null = null;
213
+ let isActive = true;
214
+
215
+ const measure = () => {
216
+ frame = null;
217
+ const computedStyle = getComputedStyle(viewport);
218
+ const nextMapping = getLogicalAxisMapping(
219
+ computedStyle.writingMode,
220
+ computedStyle.direction,
221
+ );
222
+ const inlineOverflow = axisIsRequested(axis, 'inline')
223
+ ? measureLogicalOverflowGeometry(viewport, 'inline', nextMapping)
224
+ : false;
225
+ const blockOverflow = axisIsRequested(axis, 'block')
226
+ ? measureLogicalOverflowGeometry(viewport, 'block', nextMapping)
227
+ : false;
228
+ const inline = axisIsRequested(axis, 'inline')
229
+ ? measureLogicalScrollAxis(viewport, 'inline', nextMapping)
230
+ : INACTIVE_AXIS_STATE;
231
+ const block = axisIsRequested(axis, 'block')
232
+ ? measureLogicalScrollAxis(viewport, 'block', nextMapping)
233
+ : INACTIVE_AXIS_STATE;
234
+
235
+ // Hidden, disconnected, and zero-size viewports are unknown rather than
236
+ // fitting. Keep the last valid state until a bounded signal remeasures.
237
+ if (
238
+ inlineOverflow == null ||
239
+ blockOverflow == null ||
240
+ inline == null ||
241
+ block == null
242
+ ) {
243
+ return;
244
+ }
245
+
246
+ const nextOverflow = {
247
+ inline: inlineOverflow,
248
+ block: blockOverflow,
249
+ };
250
+ const nextState = {inline, block};
251
+ stateRef.current = nextState;
252
+ registerScrollOwner(viewport, nextState);
253
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- measured DOM geometry controls whether the viewport becomes a CSS scroll container
254
+ setOverflow(current =>
255
+ overflowEqual(current, nextOverflow) ? current : nextOverflow,
256
+ );
257
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- measured DOM state is the hook's output
258
+ setMeasured(current =>
259
+ statesEqual(current, nextState) ? current : nextState,
260
+ );
261
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- writing-mode changes remap behavior-owned physical CSS
262
+ setMapping(current =>
263
+ mappingsEqual(current, nextMapping) ? current : nextMapping,
264
+ );
265
+ };
266
+
267
+ const scheduleMeasure = () => {
268
+ if (!isActive || frame != null) {
269
+ return;
270
+ }
271
+ frame = window.requestAnimationFrame(measure);
272
+ };
273
+
274
+ registerScrollOwner(viewport, stateRef.current);
275
+ measure();
276
+ const stopObservingViewport = observeResize(viewport, scheduleMeasure);
277
+ const stopObservingContent = observeResize(content, scheduleMeasure);
278
+
279
+ viewport.addEventListener('scroll', scheduleMeasure, {passive: true});
280
+ window.addEventListener('resize', scheduleMeasure);
281
+ window.visualViewport?.addEventListener('resize', scheduleMeasure);
282
+ content.addEventListener('load', scheduleMeasure, true);
283
+ viewport.addEventListener('transitionend', scheduleMeasure);
284
+ viewport.addEventListener('animationend', scheduleMeasure);
285
+ content.addEventListener('transitionend', scheduleMeasure);
286
+ content.addEventListener('animationend', scheduleMeasure);
287
+
288
+ const mutationObserver =
289
+ typeof MutationObserver === 'undefined'
290
+ ? null
291
+ : new MutationObserver(scheduleMeasure);
292
+ mutationObserver?.observe(viewport, {
293
+ attributes: true,
294
+ attributeFilter: ['class', 'dir', 'hidden', 'style'],
295
+ });
296
+ mutationObserver?.observe(content, {
297
+ attributes: true,
298
+ attributeFilter: ['class', 'dir', 'hidden', 'style'],
299
+ characterData: true,
300
+ childList: true,
301
+ subtree: true,
302
+ });
303
+ let ancestor = viewport.parentElement;
304
+ while (ancestor != null) {
305
+ mutationObserver?.observe(ancestor, {
306
+ attributes: true,
307
+ attributeFilter: ['class', 'dir', 'hidden', 'style'],
308
+ });
309
+ ancestor = ancestor.parentElement;
310
+ }
311
+
312
+ const fonts = document.fonts;
313
+ fonts?.addEventListener?.('loadingdone', scheduleMeasure);
314
+ void fonts?.ready.then(scheduleMeasure);
315
+
316
+ return () => {
317
+ isActive = false;
318
+ unregisterScrollOwner(viewport);
319
+ stopObservingViewport();
320
+ stopObservingContent();
321
+ mutationObserver?.disconnect();
322
+ viewport.removeEventListener('scroll', scheduleMeasure);
323
+ window.removeEventListener('resize', scheduleMeasure);
324
+ window.visualViewport?.removeEventListener('resize', scheduleMeasure);
325
+ content.removeEventListener('load', scheduleMeasure, true);
326
+ viewport.removeEventListener('transitionend', scheduleMeasure);
327
+ viewport.removeEventListener('animationend', scheduleMeasure);
328
+ content.removeEventListener('transitionend', scheduleMeasure);
329
+ content.removeEventListener('animationend', scheduleMeasure);
330
+ fonts?.removeEventListener?.('loadingdone', scheduleMeasure);
331
+ if (frame != null) {
332
+ window.cancelAnimationFrame(frame);
333
+ }
334
+ };
335
+ }, [axis, content, viewport]);
336
+
337
+ const state = useMemo<ScrollableAreaState>(
338
+ () => ({
339
+ inline: axisIsRequested(axis, 'inline')
340
+ ? measured.inline
341
+ : INACTIVE_AXIS_STATE,
342
+ block: axisIsRequested(axis, 'block')
343
+ ? measured.block
344
+ : INACTIVE_AXIS_STATE,
345
+ }),
346
+ [axis, measured],
347
+ );
348
+
349
+ const getViewportProps = useCallback(
350
+ <E extends HTMLElement>(
351
+ props: ScrollableElementProps<E> = {},
352
+ ): ScrollableElementProps<E> => {
353
+ const hasEffectiveAxis =
354
+ state.inline.isScrollable || state.block.isScrollable;
355
+ const requestedX =
356
+ (axisIsRequested(axis, 'inline') && mapping.inline === 'x') ||
357
+ (axisIsRequested(axis, 'block') && mapping.block === 'x');
358
+ const requestedY =
359
+ (axisIsRequested(axis, 'inline') && mapping.inline === 'y') ||
360
+ (axisIsRequested(axis, 'block') && mapping.block === 'y');
361
+ const overflowingX =
362
+ (overflow.inline && mapping.inline === 'x') ||
363
+ (overflow.block && mapping.block === 'x');
364
+ const overflowingY =
365
+ (overflow.inline && mapping.inline === 'y') ||
366
+ (overflow.block && mapping.block === 'y');
367
+ const containsSticky =
368
+ overflowingX || overflowingY || stickyContainment === 'always';
369
+ const scrollsOnX =
370
+ stickyContainment === 'always' ? requestedX : overflowingX;
371
+ const scrollsOnY =
372
+ stickyContainment === 'always' ? requestedY : overflowingY;
373
+ const {xstyle, ...domProps} = props;
374
+ const callerProps = {
375
+ ...domProps,
376
+ style: withoutOwnedOverflow(domProps.style),
377
+ };
378
+ const styledProps = mergeProps(
379
+ callerProps,
380
+ stylex.props(
381
+ xstyle,
382
+ styles.overflow(
383
+ containsSticky ? (scrollsOnX ? 'auto' : 'hidden') : 'clip',
384
+ containsSticky ? (scrollsOnY ? 'auto' : 'hidden') : 'clip',
385
+ ),
386
+ ),
387
+ ) as ScrollableElementProps<E>;
388
+ const keepsProgrammaticFocus =
389
+ !hasEffectiveAxis &&
390
+ viewport != null &&
391
+ typeof document !== 'undefined' &&
392
+ document.activeElement === viewport;
393
+ const behaviorStyle: CSSProperties = {...styledProps.style};
394
+ const containInline =
395
+ overscroll === 'contain' && state.inline.isScrollable;
396
+ const containBlock = overscroll === 'contain' && state.block.isScrollable;
397
+ const inlinePhysical = mapping.inline;
398
+ const blockPhysical = mapping.block;
399
+
400
+ if (axisIsRequested(axis, 'inline')) {
401
+ behaviorStyle[
402
+ inlinePhysical === 'x' ? 'overscrollBehaviorX' : 'overscrollBehaviorY'
403
+ ] = containInline ? 'contain' : 'auto';
404
+ }
405
+ if (axisIsRequested(axis, 'block')) {
406
+ behaviorStyle[
407
+ blockPhysical === 'x' ? 'overscrollBehaviorX' : 'overscrollBehaviorY'
408
+ ] = containBlock ? 'contain' : 'auto';
409
+ }
410
+
411
+ const keyboardProps =
412
+ keyboardAccess.owner === 'viewport'
413
+ ? {
414
+ role: keyboardAccess.role ?? 'group',
415
+ 'aria-label': keyboardAccess.label,
416
+ tabIndex: hasEffectiveAxis
417
+ ? 0
418
+ : keepsProgrammaticFocus
419
+ ? -1
420
+ : undefined,
421
+ }
422
+ : {};
423
+
424
+ const result: ScrollableElementProps<E> = {
425
+ ...styledProps,
426
+ ...keyboardProps,
427
+ ref: getComposedViewportRef(styledProps.ref),
428
+ style: behaviorStyle,
429
+ 'data-scroll-axis': axis,
430
+ 'data-scrollable-inline': state.inline.isScrollable
431
+ ? 'true'
432
+ : undefined,
433
+ 'data-scrollable-block': state.block.isScrollable ? 'true' : undefined,
434
+ 'data-scroll-inline-start': state.inline.atStart ? 'true' : undefined,
435
+ 'data-scroll-inline-end': state.inline.atEnd ? 'true' : undefined,
436
+ 'data-scroll-block-start': state.block.atStart ? 'true' : undefined,
437
+ 'data-scroll-block-end': state.block.atEnd ? 'true' : undefined,
438
+ };
439
+ return result;
440
+ },
441
+ [
442
+ axis,
443
+ getComposedViewportRef,
444
+ keyboardAccess,
445
+ mapping,
446
+ overflow,
447
+ overscroll,
448
+ state,
449
+ stickyContainment,
450
+ viewport,
451
+ ],
452
+ );
453
+
454
+ const getContentProps = useCallback(
455
+ <E extends HTMLElement>(
456
+ props: ScrollableElementProps<E> = {},
457
+ ): ScrollableElementProps<E> => {
458
+ const result: ScrollableElementProps<E> = {
459
+ ...props,
460
+ ref: getComposedContentRef(props.ref),
461
+ 'data-scroll-content': '',
462
+ };
463
+ return result;
464
+ },
465
+ [getComposedContentRef],
466
+ );
467
+
468
+ return {getViewportProps, getContentProps, state};
469
+ }
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export * from './CheckboxList';
43
43
  export * from './Collapsible';
44
44
  export * from './RadioList';
45
45
  export * from './Resizable';
46
+ export * from './ScrollableArea';
46
47
  export * from './Divider';
47
48
  export * from './VisuallyHidden';
48
49
  export * from './EmptyState';