@octanejs/radix 0.1.2

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. package/src/useId.ts +15 -0
@@ -0,0 +1,966 @@
1
+ // Ported from @radix-ui/react-scroll-area (source:
2
+ // .radix-primitives/packages/react/scroll-area/src/scroll-area.tsx +
3
+ // use-state-machine.ts). Custom cross-browser scrollbars over a native scroll
4
+ // viewport (native bars hidden via an injected style; `display: table` content
5
+ // wrapper so scroll size is measurable). Scrollbar visibility strategies: `hover`
6
+ // (pointer enter/leave + hide delay), `scroll` (a hidden→scrolling→idle state
7
+ // machine driven by scroll events), `auto` (overflow measurement), `always`.
8
+ // Thumb geometry is pure math (linearScale) over {content, viewport, scrollbar}
9
+ // sizes reported by ResizeObserver; thumb position updates ride an UNLINKED
10
+ // requestAnimationFrame loop while scrolling (scroll-linked-effect avoidance,
11
+ // verbatim from the source). ResizeObserver use is jsdom-guarded like use-size.ts.
12
+ import {
13
+ createElement,
14
+ useCallback,
15
+ useEffect,
16
+ useLayoutEffect,
17
+ useReducer,
18
+ useRef,
19
+ useState,
20
+ } from 'octane';
21
+
22
+ import { composeEventHandlers } from './compose-event-handlers';
23
+ import { useComposedRefs } from './compose-refs';
24
+ import { createContextScope } from './context';
25
+ import { useDirection } from './direction';
26
+ import { S, subSlot } from './internal';
27
+ import { Presence } from './Presence';
28
+ import { Primitive } from './Primitive';
29
+ import { useCallbackRef } from './use-callback-ref';
30
+
31
+ type Direction = 'ltr' | 'rtl';
32
+ interface Sizes {
33
+ content: number;
34
+ viewport: number;
35
+ scrollbar: {
36
+ size: number;
37
+ paddingStart: number;
38
+ paddingEnd: number;
39
+ };
40
+ }
41
+
42
+ const SCROLL_AREA_NAME = 'ScrollArea';
43
+
44
+ const [createScrollAreaContext, createScrollAreaScope] = createContextScope(SCROLL_AREA_NAME);
45
+ export { createScrollAreaScope };
46
+
47
+ interface ScrollAreaContextValue {
48
+ type: 'auto' | 'always' | 'scroll' | 'hover';
49
+ dir: Direction;
50
+ scrollHideDelay: number;
51
+ scrollArea: HTMLElement | null;
52
+ viewport: HTMLElement | null;
53
+ onViewportChange(viewport: HTMLElement | null): void;
54
+ content: HTMLDivElement | null;
55
+ onContentChange(content: HTMLDivElement): void;
56
+ scrollbarX: HTMLElement | null;
57
+ onScrollbarXChange(scrollbar: HTMLElement | null): void;
58
+ scrollbarXEnabled: boolean;
59
+ onScrollbarXEnabledChange(rendered: boolean): void;
60
+ scrollbarY: HTMLElement | null;
61
+ onScrollbarYChange(scrollbar: HTMLElement | null): void;
62
+ scrollbarYEnabled: boolean;
63
+ onScrollbarYEnabledChange(rendered: boolean): void;
64
+ onCornerWidthChange(width: number): void;
65
+ onCornerHeightChange(height: number): void;
66
+ }
67
+
68
+ const [ScrollAreaProvider, useScrollAreaContext] =
69
+ createScrollAreaContext<ScrollAreaContextValue>(SCROLL_AREA_NAME);
70
+
71
+ export function Root(props: any): any {
72
+ const slot = S('ScrollArea.Root');
73
+ const {
74
+ __scopeScrollArea,
75
+ type = 'hover',
76
+ dir,
77
+ scrollHideDelay = 600,
78
+ ref: forwardedRef,
79
+ ...scrollAreaProps
80
+ } = props ?? {};
81
+ const [scrollArea, setScrollArea] = useState<HTMLElement | null>(null, subSlot(slot, 'area'));
82
+ const [viewport, setViewport] = useState<HTMLElement | null>(null, subSlot(slot, 'viewport'));
83
+ const [content, setContent] = useState<HTMLDivElement | null>(null, subSlot(slot, 'content'));
84
+ const [scrollbarX, setScrollbarX] = useState<HTMLElement | null>(null, subSlot(slot, 'sbx'));
85
+ const [scrollbarY, setScrollbarY] = useState<HTMLElement | null>(null, subSlot(slot, 'sby'));
86
+ const [cornerWidth, setCornerWidth] = useState(0, subSlot(slot, 'cw'));
87
+ const [cornerHeight, setCornerHeight] = useState(0, subSlot(slot, 'ch'));
88
+ const [scrollbarXEnabled, setScrollbarXEnabled] = useState(false, subSlot(slot, 'sbxOn'));
89
+ const [scrollbarYEnabled, setScrollbarYEnabled] = useState(false, subSlot(slot, 'sbyOn'));
90
+ const composedRefs = useComposedRefs(forwardedRef, setScrollArea, subSlot(slot, 'refs'));
91
+ const direction = useDirection(dir);
92
+
93
+ return createElement(ScrollAreaProvider, {
94
+ scope: __scopeScrollArea,
95
+ type,
96
+ dir: direction,
97
+ scrollHideDelay,
98
+ scrollArea,
99
+ viewport,
100
+ onViewportChange: setViewport,
101
+ content,
102
+ onContentChange: setContent,
103
+ scrollbarX,
104
+ onScrollbarXChange: setScrollbarX,
105
+ scrollbarXEnabled,
106
+ onScrollbarXEnabledChange: setScrollbarXEnabled,
107
+ scrollbarY,
108
+ onScrollbarYChange: setScrollbarY,
109
+ scrollbarYEnabled,
110
+ onScrollbarYEnabledChange: setScrollbarYEnabled,
111
+ onCornerWidthChange: setCornerWidth,
112
+ onCornerHeightChange: setCornerHeight,
113
+ children: createElement(Primitive.div, {
114
+ dir: direction,
115
+ ...scrollAreaProps,
116
+ ref: composedRefs,
117
+ style: {
118
+ position: 'relative',
119
+ // Pass corner sizes as CSS vars to reduce re-renders of context consumers
120
+ '--radix-scroll-area-corner-width': cornerWidth + 'px',
121
+ '--radix-scroll-area-corner-height': cornerHeight + 'px',
122
+ ...props?.style,
123
+ },
124
+ }),
125
+ });
126
+ }
127
+
128
+ // Hide scrollbars cross-browser and enable momentum scroll for touch devices.
129
+ function ViewportStyle(props: { nonce?: string }): any {
130
+ return createElement('style', {
131
+ dangerouslySetInnerHTML: {
132
+ __html: `[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}`,
133
+ },
134
+ nonce: props.nonce,
135
+ });
136
+ }
137
+
138
+ export function Viewport(props: any): any {
139
+ const slot = S('ScrollArea.Viewport');
140
+ const { __scopeScrollArea, children, nonce, ref: forwardedRef, ...viewportProps } = props ?? {};
141
+ const context = useScrollAreaContext('ScrollAreaViewport', __scopeScrollArea);
142
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
143
+ const composedRefs = useComposedRefs(
144
+ forwardedRef,
145
+ ref,
146
+ context.onViewportChange,
147
+ subSlot(slot, 'refs'),
148
+ );
149
+ return [
150
+ createElement(ViewportStyle, { key: 'style', nonce }),
151
+ createElement(Primitive.div, {
152
+ key: 'viewport',
153
+ 'data-radix-scroll-area-viewport': '',
154
+ ...viewportProps,
155
+ ref: composedRefs,
156
+ style: {
157
+ // `visible` is unsupported and `auto` would show native bars — force `scroll`
158
+ // and hide the native scrollbars with the injected style (see the source's
159
+ // rationale comment).
160
+ overflowX: context.scrollbarXEnabled ? 'scroll' : 'hidden',
161
+ overflowY: context.scrollbarYEnabled ? 'scroll' : 'hidden',
162
+ ...props?.style,
163
+ },
164
+ // `display: table` ensures the content div matches the size of its children in
165
+ // both axes so scroll width/height changes are measurable for thumb sizing.
166
+ children: createElement('div', {
167
+ ref: context.onContentChange,
168
+ style: { minWidth: '100%', display: 'table' },
169
+ children,
170
+ }),
171
+ }),
172
+ ];
173
+ }
174
+
175
+ const SCROLLBAR_NAME = 'ScrollAreaScrollbar';
176
+
177
+ export function Scrollbar(props: any): any {
178
+ const slot = S('ScrollArea.Scrollbar');
179
+ const { forceMount, ...scrollbarProps } = props ?? {};
180
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props?.__scopeScrollArea);
181
+ const { onScrollbarXEnabledChange, onScrollbarYEnabledChange } = context;
182
+ const isHorizontal = props?.orientation === 'horizontal';
183
+
184
+ useEffect(
185
+ () => {
186
+ isHorizontal ? onScrollbarXEnabledChange(true) : onScrollbarYEnabledChange(true);
187
+ return () => {
188
+ isHorizontal ? onScrollbarXEnabledChange(false) : onScrollbarYEnabledChange(false);
189
+ };
190
+ },
191
+ [isHorizontal, onScrollbarXEnabledChange, onScrollbarYEnabledChange],
192
+ subSlot(slot, 'e:enabled'),
193
+ );
194
+
195
+ return context.type === 'hover'
196
+ ? createElement(ScrollbarHover, { ...scrollbarProps, forceMount })
197
+ : context.type === 'scroll'
198
+ ? createElement(ScrollbarScroll, { ...scrollbarProps, forceMount })
199
+ : context.type === 'auto'
200
+ ? createElement(ScrollbarAuto, { ...scrollbarProps, forceMount })
201
+ : context.type === 'always'
202
+ ? createElement(ScrollbarVisible, { ...scrollbarProps, 'data-state': 'visible' })
203
+ : null;
204
+ }
205
+
206
+ function ScrollbarHover(props: any): any {
207
+ const slot = S('ScrollArea.ScrollbarHover');
208
+ const { forceMount, ...scrollbarProps } = props;
209
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea);
210
+ const [visible, setVisible] = useState(false, subSlot(slot, 'visible'));
211
+
212
+ useEffect(
213
+ () => {
214
+ const scrollArea = context.scrollArea;
215
+ let hideTimer = 0;
216
+ if (scrollArea) {
217
+ const handlePointerEnter = (): void => {
218
+ window.clearTimeout(hideTimer);
219
+ setVisible(true);
220
+ };
221
+ const handlePointerLeave = (): void => {
222
+ hideTimer = window.setTimeout(() => setVisible(false), context.scrollHideDelay);
223
+ };
224
+ scrollArea.addEventListener('pointerenter', handlePointerEnter);
225
+ scrollArea.addEventListener('pointerleave', handlePointerLeave);
226
+ return () => {
227
+ window.clearTimeout(hideTimer);
228
+ scrollArea.removeEventListener('pointerenter', handlePointerEnter);
229
+ scrollArea.removeEventListener('pointerleave', handlePointerLeave);
230
+ };
231
+ }
232
+ },
233
+ [context.scrollArea, context.scrollHideDelay],
234
+ subSlot(slot, 'e:hover'),
235
+ );
236
+
237
+ return createElement(Presence, {
238
+ present: forceMount || visible,
239
+ children: createElement(ScrollbarAuto, {
240
+ 'data-state': visible ? 'visible' : 'hidden',
241
+ ...scrollbarProps,
242
+ }),
243
+ });
244
+ }
245
+
246
+ // use-state-machine.ts, inlined: a reducer over a {state: {event: nextState}} table.
247
+ function useStateMachine(
248
+ initialState: string,
249
+ machine: Record<string, Record<string, string>>,
250
+ slot: symbol | undefined,
251
+ ): [string, (event: string) => void] {
252
+ return useReducer(
253
+ (state: string, event: string): string => {
254
+ const nextState = machine[state][event];
255
+ return nextState ?? state;
256
+ },
257
+ initialState,
258
+ slot,
259
+ );
260
+ }
261
+
262
+ function ScrollbarScroll(props: any): any {
263
+ const slot = S('ScrollArea.ScrollbarScroll');
264
+ const { forceMount, ...scrollbarProps } = props;
265
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea);
266
+ const isHorizontal = props.orientation === 'horizontal';
267
+ const debounceScrollEnd = useDebounceCallback(
268
+ () => send('SCROLL_END'),
269
+ 100,
270
+ subSlot(slot, 'debounce'),
271
+ );
272
+ const [state, send] = useStateMachine(
273
+ 'hidden',
274
+ {
275
+ hidden: {
276
+ SCROLL: 'scrolling',
277
+ },
278
+ scrolling: {
279
+ SCROLL_END: 'idle',
280
+ POINTER_ENTER: 'interacting',
281
+ },
282
+ interacting: {
283
+ SCROLL: 'interacting',
284
+ POINTER_LEAVE: 'idle',
285
+ },
286
+ idle: {
287
+ HIDE: 'hidden',
288
+ SCROLL: 'scrolling',
289
+ POINTER_ENTER: 'interacting',
290
+ },
291
+ },
292
+ subSlot(slot, 'machine'),
293
+ );
294
+
295
+ useEffect(
296
+ () => {
297
+ if (state === 'idle') {
298
+ const hideTimer = window.setTimeout(() => send('HIDE'), context.scrollHideDelay);
299
+ return () => window.clearTimeout(hideTimer);
300
+ }
301
+ },
302
+ [state, context.scrollHideDelay, send],
303
+ subSlot(slot, 'e:hide'),
304
+ );
305
+
306
+ useEffect(
307
+ () => {
308
+ const viewport = context.viewport;
309
+ const scrollDirection = isHorizontal ? 'scrollLeft' : 'scrollTop';
310
+
311
+ if (viewport) {
312
+ let prevScrollPos = (viewport as any)[scrollDirection];
313
+ const handleScroll = (): void => {
314
+ const scrollPos = (viewport as any)[scrollDirection];
315
+ const hasScrollInDirectionChanged = prevScrollPos !== scrollPos;
316
+ if (hasScrollInDirectionChanged) {
317
+ send('SCROLL');
318
+ debounceScrollEnd();
319
+ }
320
+ prevScrollPos = scrollPos;
321
+ };
322
+ viewport.addEventListener('scroll', handleScroll);
323
+ return () => viewport.removeEventListener('scroll', handleScroll);
324
+ }
325
+ },
326
+ [context.viewport, isHorizontal, send, debounceScrollEnd],
327
+ subSlot(slot, 'e:scroll'),
328
+ );
329
+
330
+ return createElement(Presence, {
331
+ present: forceMount || state !== 'hidden',
332
+ children: createElement(ScrollbarVisible, {
333
+ 'data-state': state === 'hidden' ? 'hidden' : 'visible',
334
+ ...scrollbarProps,
335
+ onPointerEnter: composeEventHandlers(props.onPointerEnter, () => send('POINTER_ENTER')),
336
+ onPointerLeave: composeEventHandlers(props.onPointerLeave, () => send('POINTER_LEAVE')),
337
+ }),
338
+ });
339
+ }
340
+
341
+ function ScrollbarAuto(props: any): any {
342
+ const slot = S('ScrollArea.ScrollbarAuto');
343
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea);
344
+ const { forceMount, ...scrollbarProps } = props;
345
+ const [visible, setVisible] = useState(false, subSlot(slot, 'visible'));
346
+ const isHorizontal = props.orientation === 'horizontal';
347
+ const handleResize = useDebounceCallback(
348
+ () => {
349
+ if (context.viewport) {
350
+ const isOverflowX = context.viewport.offsetWidth < context.viewport.scrollWidth;
351
+ const isOverflowY = context.viewport.offsetHeight < context.viewport.scrollHeight;
352
+ setVisible(isHorizontal ? isOverflowX : isOverflowY);
353
+ }
354
+ },
355
+ 10,
356
+ subSlot(slot, 'debounce'),
357
+ );
358
+
359
+ useResizeObserver(context.viewport, handleResize, subSlot(slot, 'ro:viewport'));
360
+ useResizeObserver(context.content, handleResize, subSlot(slot, 'ro:content'));
361
+
362
+ return createElement(Presence, {
363
+ present: forceMount || visible,
364
+ children: createElement(ScrollbarVisible, {
365
+ 'data-state': visible ? 'visible' : 'hidden',
366
+ ...scrollbarProps,
367
+ }),
368
+ });
369
+ }
370
+
371
+ function ScrollbarVisible(props: any): any {
372
+ const slot = S('ScrollArea.ScrollbarVisible');
373
+ const { orientation = 'vertical', ref: forwardedRef, ...scrollbarProps } = props;
374
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea);
375
+ const thumbRef = useRef<HTMLElement | null>(null, subSlot(slot, 'thumb'));
376
+ const pointerOffsetRef = useRef(0, subSlot(slot, 'offset'));
377
+ const [sizes, setSizes] = useState<Sizes>(
378
+ {
379
+ content: 0,
380
+ viewport: 0,
381
+ scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 },
382
+ },
383
+ subSlot(slot, 'sizes'),
384
+ );
385
+ const thumbRatio = getThumbRatio(sizes.viewport, sizes.content);
386
+
387
+ const commonProps = {
388
+ ...scrollbarProps,
389
+ sizes,
390
+ onSizesChange: setSizes,
391
+ hasThumb: Boolean(thumbRatio > 0 && thumbRatio < 1),
392
+ onThumbChange: (thumb: HTMLElement | null) => (thumbRef.current = thumb),
393
+ onThumbPointerUp: () => (pointerOffsetRef.current = 0),
394
+ onThumbPointerDown: (pointerPos: number) => (pointerOffsetRef.current = pointerPos),
395
+ };
396
+
397
+ function getScrollPosition(pointerPos: number, dir?: Direction): number {
398
+ return getScrollPositionFromPointer(pointerPos, pointerOffsetRef.current, sizes, dir);
399
+ }
400
+
401
+ if (orientation === 'horizontal') {
402
+ return createElement(ScrollbarX, {
403
+ ...commonProps,
404
+ ref: forwardedRef,
405
+ onThumbPositionChange: () => {
406
+ if (context.viewport && thumbRef.current) {
407
+ const scrollPos = context.viewport.scrollLeft;
408
+ const offset = getThumbOffsetFromScroll(scrollPos, sizes, context.dir);
409
+ thumbRef.current.style.transform = `translate3d(${offset}px, 0, 0)`;
410
+ }
411
+ },
412
+ onWheelScroll: (scrollPos: number) => {
413
+ if (context.viewport) context.viewport.scrollLeft = scrollPos;
414
+ },
415
+ onDragScroll: (pointerPos: number) => {
416
+ if (context.viewport) {
417
+ context.viewport.scrollLeft = getScrollPosition(pointerPos, context.dir);
418
+ }
419
+ },
420
+ });
421
+ }
422
+
423
+ if (orientation === 'vertical') {
424
+ return createElement(ScrollbarY, {
425
+ ...commonProps,
426
+ ref: forwardedRef,
427
+ onThumbPositionChange: () => {
428
+ if (context.viewport && thumbRef.current) {
429
+ const scrollPos = context.viewport.scrollTop;
430
+ const offset = getThumbOffsetFromScroll(scrollPos, sizes);
431
+ thumbRef.current.style.transform = `translate3d(0, ${offset}px, 0)`;
432
+ }
433
+ },
434
+ onWheelScroll: (scrollPos: number) => {
435
+ if (context.viewport) context.viewport.scrollTop = scrollPos;
436
+ },
437
+ onDragScroll: (pointerPos: number) => {
438
+ if (context.viewport) context.viewport.scrollTop = getScrollPosition(pointerPos);
439
+ },
440
+ });
441
+ }
442
+
443
+ return null;
444
+ }
445
+
446
+ function ScrollbarX(props: any): any {
447
+ const slot = S('ScrollArea.ScrollbarX');
448
+ const { sizes, onSizesChange, ref: forwardedRef, ...scrollbarProps } = props;
449
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea);
450
+ const [computedStyle, setComputedStyle] = useState<CSSStyleDeclaration | undefined>(
451
+ undefined,
452
+ subSlot(slot, 'style'),
453
+ );
454
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
455
+ const composeRefs = useComposedRefs(
456
+ forwardedRef,
457
+ ref,
458
+ context.onScrollbarXChange,
459
+ subSlot(slot, 'refs'),
460
+ );
461
+
462
+ useEffect(
463
+ () => {
464
+ if (ref.current) setComputedStyle(getComputedStyle(ref.current));
465
+ },
466
+ [ref],
467
+ subSlot(slot, 'e:style'),
468
+ );
469
+
470
+ return createElement(ScrollbarImpl, {
471
+ 'data-orientation': 'horizontal',
472
+ ...scrollbarProps,
473
+ __scopeScrollArea: props.__scopeScrollArea,
474
+ ref: composeRefs,
475
+ sizes,
476
+ style: {
477
+ bottom: 0,
478
+ left: context.dir === 'rtl' ? 'var(--radix-scroll-area-corner-width)' : 0,
479
+ right: context.dir === 'ltr' ? 'var(--radix-scroll-area-corner-width)' : 0,
480
+ '--radix-scroll-area-thumb-width': getThumbSize(sizes) + 'px',
481
+ ...props.style,
482
+ },
483
+ onThumbPointerDown: (pointerPos: { x: number; y: number }) =>
484
+ props.onThumbPointerDown(pointerPos.x),
485
+ onDragScroll: (pointerPos: { x: number; y: number }) => props.onDragScroll(pointerPos.x),
486
+ onWheelScroll: (event: WheelEvent, maxScrollPos: number) => {
487
+ if (context.viewport) {
488
+ const scrollPos = context.viewport.scrollLeft + event.deltaX;
489
+ props.onWheelScroll(scrollPos);
490
+ // prevent window scroll when wheeling on scrollbar
491
+ if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) {
492
+ event.preventDefault();
493
+ }
494
+ }
495
+ },
496
+ onResize: () => {
497
+ if (ref.current && context.viewport && computedStyle) {
498
+ onSizesChange({
499
+ content: context.viewport.scrollWidth,
500
+ viewport: context.viewport.offsetWidth,
501
+ scrollbar: {
502
+ size: ref.current.clientWidth,
503
+ paddingStart: toInt(computedStyle.paddingLeft),
504
+ paddingEnd: toInt(computedStyle.paddingRight),
505
+ },
506
+ });
507
+ }
508
+ },
509
+ });
510
+ }
511
+
512
+ function ScrollbarY(props: any): any {
513
+ const slot = S('ScrollArea.ScrollbarY');
514
+ const { sizes, onSizesChange, ref: forwardedRef, ...scrollbarProps } = props;
515
+ const context = useScrollAreaContext(SCROLLBAR_NAME, props.__scopeScrollArea);
516
+ const [computedStyle, setComputedStyle] = useState<CSSStyleDeclaration | undefined>(
517
+ undefined,
518
+ subSlot(slot, 'style'),
519
+ );
520
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
521
+ const composeRefs = useComposedRefs(
522
+ forwardedRef,
523
+ ref,
524
+ context.onScrollbarYChange,
525
+ subSlot(slot, 'refs'),
526
+ );
527
+
528
+ useEffect(
529
+ () => {
530
+ if (ref.current) setComputedStyle(getComputedStyle(ref.current));
531
+ },
532
+ [ref],
533
+ subSlot(slot, 'e:style'),
534
+ );
535
+
536
+ return createElement(ScrollbarImpl, {
537
+ 'data-orientation': 'vertical',
538
+ ...scrollbarProps,
539
+ __scopeScrollArea: props.__scopeScrollArea,
540
+ ref: composeRefs,
541
+ sizes,
542
+ style: {
543
+ top: 0,
544
+ right: context.dir === 'ltr' ? 0 : undefined,
545
+ left: context.dir === 'rtl' ? 0 : undefined,
546
+ bottom: 'var(--radix-scroll-area-corner-height)',
547
+ '--radix-scroll-area-thumb-height': getThumbSize(sizes) + 'px',
548
+ ...props.style,
549
+ },
550
+ onThumbPointerDown: (pointerPos: { x: number; y: number }) =>
551
+ props.onThumbPointerDown(pointerPos.y),
552
+ onDragScroll: (pointerPos: { x: number; y: number }) => props.onDragScroll(pointerPos.y),
553
+ onWheelScroll: (event: WheelEvent, maxScrollPos: number) => {
554
+ if (context.viewport) {
555
+ const scrollPos = context.viewport.scrollTop + event.deltaY;
556
+ props.onWheelScroll(scrollPos);
557
+ // prevent window scroll when wheeling on scrollbar
558
+ if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) {
559
+ event.preventDefault();
560
+ }
561
+ }
562
+ },
563
+ onResize: () => {
564
+ if (ref.current && context.viewport && computedStyle) {
565
+ onSizesChange({
566
+ content: context.viewport.scrollHeight,
567
+ viewport: context.viewport.offsetHeight,
568
+ scrollbar: {
569
+ size: ref.current.clientHeight,
570
+ paddingStart: toInt(computedStyle.paddingTop),
571
+ paddingEnd: toInt(computedStyle.paddingBottom),
572
+ },
573
+ });
574
+ }
575
+ },
576
+ });
577
+ }
578
+
579
+ interface ScrollbarContext {
580
+ hasThumb: boolean;
581
+ scrollbar: HTMLElement | null;
582
+ onThumbChange(thumb: HTMLElement | null): void;
583
+ onThumbPointerUp(): void;
584
+ onThumbPointerDown(pointerPos: { x: number; y: number }): void;
585
+ onThumbPositionChange(): void;
586
+ }
587
+
588
+ const [ScrollbarProvider, useScrollbarContext] =
589
+ createScrollAreaContext<ScrollbarContext>(SCROLLBAR_NAME);
590
+
591
+ function ScrollbarImpl(props: any): any {
592
+ const slot = S('ScrollArea.ScrollbarImpl');
593
+ const {
594
+ __scopeScrollArea,
595
+ sizes,
596
+ hasThumb,
597
+ onThumbChange,
598
+ onThumbPointerUp,
599
+ onThumbPointerDown,
600
+ onThumbPositionChange,
601
+ onDragScroll,
602
+ onWheelScroll,
603
+ onResize,
604
+ ref: forwardedRef,
605
+ ...scrollbarProps
606
+ } = props;
607
+ const context = useScrollAreaContext(SCROLLBAR_NAME, __scopeScrollArea);
608
+ const [scrollbar, setScrollbar] = useState<HTMLElement | null>(null, subSlot(slot, 'node'));
609
+ const composeRefs = useComposedRefs(forwardedRef, setScrollbar, subSlot(slot, 'refs'));
610
+ const rectRef = useRef<DOMRect | null>(null, subSlot(slot, 'rect'));
611
+ const prevWebkitUserSelectRef = useRef<string>('', subSlot(slot, 'select'));
612
+ const viewport = context.viewport;
613
+ const maxScrollPos = sizes.content - sizes.viewport;
614
+ const handleWheelScroll = useCallbackRef(onWheelScroll, subSlot(slot, 'wheel'));
615
+ const handleThumbPositionChange = useCallbackRef(onThumbPositionChange, subSlot(slot, 'thumb'));
616
+ const handleResize = useDebounceCallback(onResize, 10, subSlot(slot, 'resize'));
617
+
618
+ function handleDragScroll(event: PointerEvent): void {
619
+ if (rectRef.current) {
620
+ const x = event.clientX - rectRef.current.left;
621
+ const y = event.clientY - rectRef.current.top;
622
+ onDragScroll({ x, y });
623
+ }
624
+ }
625
+
626
+ // We bind the wheel event imperatively so we can switch off passive mode for the
627
+ // document wheel event to allow it to be prevented.
628
+ useEffect(
629
+ () => {
630
+ const handleWheel = (event: WheelEvent): void => {
631
+ const element = event.target as HTMLElement;
632
+ const isScrollbarWheel = scrollbar?.contains(element);
633
+ if (isScrollbarWheel) handleWheelScroll(event, maxScrollPos);
634
+ };
635
+ document.addEventListener('wheel', handleWheel, { passive: false });
636
+ return () => document.removeEventListener('wheel', handleWheel, { passive: false } as any);
637
+ },
638
+ [viewport, scrollbar, maxScrollPos, handleWheelScroll],
639
+ subSlot(slot, 'e:wheel'),
640
+ );
641
+
642
+ // Update thumb position on sizes change.
643
+ useEffect(handleThumbPositionChange, [sizes, handleThumbPositionChange], subSlot(slot, 'e:pos'));
644
+
645
+ useResizeObserver(scrollbar, handleResize, subSlot(slot, 'ro:scrollbar'));
646
+ useResizeObserver(context.content, handleResize, subSlot(slot, 'ro:content'));
647
+
648
+ return createElement(ScrollbarProvider, {
649
+ scope: __scopeScrollArea,
650
+ scrollbar,
651
+ hasThumb,
652
+ onThumbChange: useCallbackRef(onThumbChange, subSlot(slot, 'cb:thumb')),
653
+ onThumbPointerUp: useCallbackRef(onThumbPointerUp, subSlot(slot, 'cb:up')),
654
+ onThumbPositionChange: handleThumbPositionChange,
655
+ onThumbPointerDown: useCallbackRef(onThumbPointerDown, subSlot(slot, 'cb:down')),
656
+ children: createElement(Primitive.div, {
657
+ ...scrollbarProps,
658
+ ref: composeRefs,
659
+ style: { position: 'absolute', ...scrollbarProps.style },
660
+ onPointerDown: composeEventHandlers(props.onPointerDown, (event: PointerEvent) => {
661
+ const mainPointer = 0;
662
+ if (event.button === mainPointer) {
663
+ const element = event.target as HTMLElement;
664
+ element.setPointerCapture(event.pointerId);
665
+ rectRef.current = scrollbar!.getBoundingClientRect();
666
+ // pointer capture doesn't prevent text selection in Safari
667
+ // so we remove text selection manually when scrolling
668
+ prevWebkitUserSelectRef.current = (document.body.style as any).webkitUserSelect;
669
+ (document.body.style as any).webkitUserSelect = 'none';
670
+ if (context.viewport) context.viewport.style.scrollBehavior = 'auto';
671
+ handleDragScroll(event);
672
+ }
673
+ }),
674
+ onPointerMove: composeEventHandlers(props.onPointerMove, handleDragScroll),
675
+ onPointerUp: composeEventHandlers(props.onPointerUp, (event: PointerEvent) => {
676
+ const element = event.target as HTMLElement;
677
+ if (element.hasPointerCapture(event.pointerId)) {
678
+ element.releasePointerCapture(event.pointerId);
679
+ }
680
+ (document.body.style as any).webkitUserSelect = prevWebkitUserSelectRef.current;
681
+ if (context.viewport) context.viewport.style.scrollBehavior = '';
682
+ rectRef.current = null;
683
+ }),
684
+ }),
685
+ });
686
+ }
687
+
688
+ const THUMB_NAME = 'ScrollAreaThumb';
689
+
690
+ export function Thumb(props: any): any {
691
+ const { forceMount, ...thumbProps } = props ?? {};
692
+ const scrollbarContext = useScrollbarContext(THUMB_NAME, props?.__scopeScrollArea);
693
+ return createElement(Presence, {
694
+ present: forceMount || scrollbarContext.hasThumb,
695
+ children: createElement(ThumbImpl, thumbProps),
696
+ });
697
+ }
698
+
699
+ function ThumbImpl(props: any): any {
700
+ const slot = S('ScrollArea.ThumbImpl');
701
+ const { __scopeScrollArea, style, ref: forwardedRef, ...thumbProps } = props;
702
+ const scrollAreaContext = useScrollAreaContext(THUMB_NAME, __scopeScrollArea);
703
+ const scrollbarContext = useScrollbarContext(THUMB_NAME, __scopeScrollArea);
704
+ const { onThumbPositionChange } = scrollbarContext;
705
+ const composedRef = useComposedRefs(
706
+ forwardedRef,
707
+ scrollbarContext.onThumbChange,
708
+ subSlot(slot, 'refs'),
709
+ );
710
+ const removeUnlinkedScrollListenerRef = useRef<(() => void) | undefined>(
711
+ undefined,
712
+ subSlot(slot, 'unlinked'),
713
+ );
714
+ const debounceScrollEnd = useDebounceCallback(
715
+ () => {
716
+ if (removeUnlinkedScrollListenerRef.current) {
717
+ removeUnlinkedScrollListenerRef.current();
718
+ removeUnlinkedScrollListenerRef.current = undefined;
719
+ }
720
+ },
721
+ 100,
722
+ subSlot(slot, 'debounce'),
723
+ );
724
+
725
+ useEffect(
726
+ () => {
727
+ const viewport = scrollAreaContext.viewport;
728
+ if (viewport) {
729
+ // We only bind to the native scroll event so we know when scroll starts and
730
+ // ends. When scroll starts we start a requestAnimationFrame loop that checks
731
+ // for changes to scroll position — that rAF loop triggers our thumb position
732
+ // change when relevant (scroll-linked-effect avoidance). We cancel the loop
733
+ // when scroll ends.
734
+ const handleScroll = (): void => {
735
+ debounceScrollEnd();
736
+ if (!removeUnlinkedScrollListenerRef.current) {
737
+ const listener = addUnlinkedScrollListener(viewport, onThumbPositionChange);
738
+ removeUnlinkedScrollListenerRef.current = listener;
739
+ onThumbPositionChange();
740
+ }
741
+ };
742
+ onThumbPositionChange();
743
+ viewport.addEventListener('scroll', handleScroll);
744
+ return () => viewport.removeEventListener('scroll', handleScroll);
745
+ }
746
+ },
747
+ [scrollAreaContext.viewport, debounceScrollEnd, onThumbPositionChange],
748
+ subSlot(slot, 'e:scroll'),
749
+ );
750
+
751
+ return createElement(Primitive.div, {
752
+ 'data-state': scrollbarContext.hasThumb ? 'visible' : 'hidden',
753
+ ...thumbProps,
754
+ ref: composedRef,
755
+ style: {
756
+ width: 'var(--radix-scroll-area-thumb-width)',
757
+ height: 'var(--radix-scroll-area-thumb-height)',
758
+ ...style,
759
+ },
760
+ onPointerDownCapture: composeEventHandlers(
761
+ props.onPointerDownCapture,
762
+ (event: PointerEvent) => {
763
+ const thumb = event.target as HTMLElement;
764
+ const thumbRect = thumb.getBoundingClientRect();
765
+ const x = event.clientX - thumbRect.left;
766
+ const y = event.clientY - thumbRect.top;
767
+ scrollbarContext.onThumbPointerDown({ x, y });
768
+ },
769
+ ),
770
+ onPointerUp: composeEventHandlers(props.onPointerUp, scrollbarContext.onThumbPointerUp),
771
+ });
772
+ }
773
+
774
+ const CORNER_NAME = 'ScrollAreaCorner';
775
+
776
+ export function Corner(props: any): any {
777
+ const context = useScrollAreaContext(CORNER_NAME, props?.__scopeScrollArea);
778
+ const hasBothScrollbarsVisible = Boolean(context.scrollbarX && context.scrollbarY);
779
+ const hasCorner = context.type !== 'scroll' && hasBothScrollbarsVisible;
780
+ return hasCorner ? createElement(CornerImpl, props) : null;
781
+ }
782
+
783
+ function CornerImpl(props: any): any {
784
+ const slot = S('ScrollArea.CornerImpl');
785
+ const { __scopeScrollArea, ...cornerProps } = props;
786
+ const context = useScrollAreaContext(CORNER_NAME, __scopeScrollArea);
787
+ const [width, setWidth] = useState(0, subSlot(slot, 'w'));
788
+ const [height, setHeight] = useState(0, subSlot(slot, 'h'));
789
+ const hasSize = Boolean(width && height);
790
+
791
+ useResizeObserver(
792
+ context.scrollbarX,
793
+ () => {
794
+ const height = context.scrollbarX?.offsetHeight || 0;
795
+ context.onCornerHeightChange(height);
796
+ setHeight(height);
797
+ },
798
+ subSlot(slot, 'ro:x'),
799
+ );
800
+
801
+ useResizeObserver(
802
+ context.scrollbarY,
803
+ () => {
804
+ const width = context.scrollbarY?.offsetWidth || 0;
805
+ context.onCornerWidthChange(width);
806
+ setWidth(width);
807
+ },
808
+ subSlot(slot, 'ro:y'),
809
+ );
810
+
811
+ return hasSize
812
+ ? createElement(Primitive.div, {
813
+ ...cornerProps,
814
+ style: {
815
+ width,
816
+ height,
817
+ position: 'absolute',
818
+ right: context.dir === 'ltr' ? 0 : undefined,
819
+ left: context.dir === 'rtl' ? 0 : undefined,
820
+ bottom: 0,
821
+ ...props.style,
822
+ },
823
+ })
824
+ : null;
825
+ }
826
+
827
+ function toInt(value?: string): number {
828
+ return value ? parseInt(value, 10) : 0;
829
+ }
830
+
831
+ function getThumbRatio(viewportSize: number, contentSize: number): number {
832
+ const ratio = viewportSize / contentSize;
833
+ return isNaN(ratio) ? 0 : ratio;
834
+ }
835
+
836
+ function getThumbSize(sizes: Sizes): number {
837
+ const ratio = getThumbRatio(sizes.viewport, sizes.content);
838
+ const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd;
839
+ const thumbSize = (sizes.scrollbar.size - scrollbarPadding) * ratio;
840
+ // minimum of 18 matches macOS minimum
841
+ return Math.max(thumbSize, 18);
842
+ }
843
+
844
+ function getScrollPositionFromPointer(
845
+ pointerPos: number,
846
+ pointerOffset: number,
847
+ sizes: Sizes,
848
+ dir: Direction = 'ltr',
849
+ ): number {
850
+ const thumbSizePx = getThumbSize(sizes);
851
+ const thumbCenter = thumbSizePx / 2;
852
+ const offset = pointerOffset || thumbCenter;
853
+ const thumbOffsetFromEnd = thumbSizePx - offset;
854
+ const minPointerPos = sizes.scrollbar.paddingStart + offset;
855
+ const maxPointerPos = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - thumbOffsetFromEnd;
856
+ const maxScrollPos = sizes.content - sizes.viewport;
857
+ const scrollRange = dir === 'ltr' ? [0, maxScrollPos] : [maxScrollPos * -1, 0];
858
+ const interpolate = linearScale([minPointerPos, maxPointerPos], scrollRange as [number, number]);
859
+ return interpolate(pointerPos);
860
+ }
861
+
862
+ function getThumbOffsetFromScroll(scrollPos: number, sizes: Sizes, dir: Direction = 'ltr'): number {
863
+ const thumbSizePx = getThumbSize(sizes);
864
+ const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd;
865
+ const scrollbar = sizes.scrollbar.size - scrollbarPadding;
866
+ const maxScrollPos = sizes.content - sizes.viewport;
867
+ const maxThumbPos = scrollbar - thumbSizePx;
868
+ const scrollClampRange = dir === 'ltr' ? [0, maxScrollPos] : [maxScrollPos * -1, 0];
869
+ const scrollWithoutMomentum = clamp(scrollPos, scrollClampRange as [number, number]);
870
+ const interpolate = linearScale([0, maxScrollPos], [0, maxThumbPos]);
871
+ return interpolate(scrollWithoutMomentum);
872
+ }
873
+
874
+ // https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js
875
+ function linearScale(input: readonly [number, number], output: readonly [number, number]) {
876
+ return (value: number) => {
877
+ if (input[0] === input[1] || output[0] === output[1]) return output[0];
878
+ const ratio = (output[1] - output[0]) / (input[1] - input[0]);
879
+ return output[0] + ratio * (value - input[0]);
880
+ };
881
+ }
882
+
883
+ // @radix-ui/number's clamp, inlined (its only export).
884
+ function clamp(value: number, [min, max]: [number, number]): number {
885
+ return Math.min(max, Math.max(min, value));
886
+ }
887
+
888
+ function isScrollingWithinScrollbarBounds(scrollPos: number, maxScrollPos: number): boolean {
889
+ return scrollPos > 0 && scrollPos < maxScrollPos;
890
+ }
891
+
892
+ // Custom scroll handler to avoid scroll-linked effects
893
+ // https://developer.mozilla.org/en-US/docs/Mozilla/Performance/Scroll-linked_effects
894
+ const addUnlinkedScrollListener = (node: HTMLElement, handler = () => {}): (() => void) => {
895
+ let prevPosition = { left: node.scrollLeft, top: node.scrollTop };
896
+ let rAF = 0;
897
+ (function loop() {
898
+ const position = { left: node.scrollLeft, top: node.scrollTop };
899
+ const isHorizontalScroll = prevPosition.left !== position.left;
900
+ const isVerticalScroll = prevPosition.top !== position.top;
901
+ if (isHorizontalScroll || isVerticalScroll) handler();
902
+ prevPosition = position;
903
+ rAF = window.requestAnimationFrame(loop);
904
+ })();
905
+ return () => window.cancelAnimationFrame(rAF);
906
+ };
907
+
908
+ function useDebounceCallback(
909
+ callback: () => void,
910
+ delay: number,
911
+ slot: symbol | undefined,
912
+ ): () => void {
913
+ const handleCallback = useCallbackRef(callback, subSlot(slot, 'cb'));
914
+ const debounceTimerRef = useRef(0, subSlot(slot, 'timer'));
915
+ useEffect(
916
+ () => () => window.clearTimeout(debounceTimerRef.current),
917
+ [],
918
+ subSlot(slot, 'e:clear'),
919
+ );
920
+ return useCallback(
921
+ () => {
922
+ window.clearTimeout(debounceTimerRef.current);
923
+ debounceTimerRef.current = window.setTimeout(handleCallback, delay);
924
+ },
925
+ [handleCallback, delay],
926
+ subSlot(slot, 'memo'),
927
+ );
928
+ }
929
+
930
+ function useResizeObserver(
931
+ element: HTMLElement | null,
932
+ onResize: () => void,
933
+ slot: symbol | undefined,
934
+ ): void {
935
+ const handleResize = useCallbackRef(onResize, subSlot(slot, 'cb'));
936
+ useLayoutEffect(
937
+ () => {
938
+ let rAF = 0;
939
+ if (element) {
940
+ // jsdom guard (same as use-size.ts): no ResizeObserver in the test env.
941
+ if (typeof ResizeObserver === 'undefined') return;
942
+ // ResizeObserver can throw a benign "loop completed with undelivered
943
+ // notifications" — deliver via requestAnimationFrame to avoid it.
944
+ const resizeObserver = new ResizeObserver(() => {
945
+ cancelAnimationFrame(rAF);
946
+ rAF = window.requestAnimationFrame(handleResize);
947
+ });
948
+ resizeObserver.observe(element);
949
+ return () => {
950
+ window.cancelAnimationFrame(rAF);
951
+ resizeObserver.unobserve(element);
952
+ };
953
+ }
954
+ },
955
+ [element, handleResize],
956
+ subSlot(slot, 'e'),
957
+ );
958
+ }
959
+
960
+ export {
961
+ Root as ScrollArea,
962
+ Viewport as ScrollAreaViewport,
963
+ Scrollbar as ScrollAreaScrollbar,
964
+ Thumb as ScrollAreaThumb,
965
+ Corner as ScrollAreaCorner,
966
+ };