Skip to content

Commit bdd269c

Browse files
refactor: ScrollArea
1 parent c561e54 commit bdd269c

16 files changed

Lines changed: 10973 additions & 5743 deletions

File tree

apps/showcase/assets/apidoc/index.json

Lines changed: 10542 additions & 5621 deletions
Large diffs are not rendered by default.

apps/showcase/assets/menu/submenu/menu-headless.data.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,10 @@ export const headlessMenu = [
151151
name: 'usePanel',
152152
href: '/docs/headless/panel'
153153
},
154-
// {
155-
// name: 'useScrollArea',
156-
// href: '/docs/headless/scrollarea'
157-
// },
154+
{
155+
name: 'useScrollArea',
156+
href: '/docs/headless/scrollarea'
157+
},
158158
{
159159
name: 'useSplitter',
160160
href: '/docs/headless/splitter'

apps/showcase/assets/menu/submenu/menu-primitives.data.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,10 @@ export const primitivesMenu = [
151151
name: 'Panel',
152152
href: '/docs/primitives/panel'
153153
},
154-
// {
155-
// name: 'ScrollArea',
156-
// href: '/docs/primitives/scrollarea'
157-
// },
154+
{
155+
name: 'ScrollArea',
156+
href: '/docs/primitives/scrollarea'
157+
},
158158
{
159159
name: 'Splitter',
160160
href: '/docs/primitives/splitter'

apps/showcase/demo/headless/scrollarea/basic-demo.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,13 @@ const tags = [
5757
];
5858

5959
export default function BasicDemo() {
60-
const { rootProps, viewportProps, getScrollbarProps, getThumbProps, rootRef, viewportRef, scrollbarYRef, thumbYRef, hiddenState } =
61-
useScrollArea();
60+
const { rootProps, viewportProps, contentProps, getScrollbarProps, getThumbProps, hiddenState } = useScrollArea();
6261

6362
return (
6463
<div className="max-w-56 w-full mx-auto">
65-
<div
66-
{...rootProps}
67-
ref={rootRef}
68-
className="relative h-72 border border-surface-200 dark:border-surface-700 rounded has-[:focus-visible]:border-primary overflow-hidden"
69-
>
70-
<div {...viewportProps} ref={viewportRef} className="h-full w-full overflow-scroll outline-none" style={{ scrollbarWidth: 'none' }}>
71-
<div className="flex flex-col p-1">
64+
<div {...rootProps} className="relative h-72 border border-surface-200 dark:border-surface-700 rounded has-[:focus-visible]:border-primary overflow-hidden">
65+
<div {...viewportProps} className="h-full w-full overflow-scroll outline-none" style={{ scrollbarWidth: 'none' }}>
66+
<div {...contentProps} className="flex flex-col p-1">
7267
{tags.map((tag) => (
7368
<div key={tag} className="py-2 px-3 text-sm text-surface-700 dark:text-surface-0 rounded-md">
7469
{tag}
@@ -79,14 +74,12 @@ export default function BasicDemo() {
7974
{!hiddenState.y && (
8075
<div
8176
{...getScrollbarProps('vertical')}
82-
ref={scrollbarYRef}
8377
className="absolute top-0 right-0 w-2.5 h-full flex touch-none select-none"
8478
style={{ padding: '2px' }}
8579
>
8680
<div
8781
{...getThumbProps('vertical')}
88-
ref={thumbYRef}
89-
className="relative rounded-full bg-surface-300 dark:bg-surface-600 hover:bg-surface-400 dark:hover:bg-surface-500 transition-colors flex-1"
82+
className="relative rounded-full bg-primary hover:bg-primary-emphasis transition-colors flex-1"
9083
style={{
9184
height: 'var(--thumb-height, 40px)',
9285
transform: 'translate3d(0, var(--thumb-offset, 0), 0)'

apps/showcase/demo/primitives/scrollarea/basic-demo.module.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@
5353
position: relative;
5454
flex: 1;
5555
border-radius: 9999px;
56-
background-color: light-dark(var(--p-surface-300), var(--p-surface-600));
56+
background-color: var(--p-primary-color);
5757
height: var(--thumb-height, 40px);
5858
transform: translate3d(0, var(--thumb-offset, 0), 0);
5959
transition: background-color 150ms ease;
6060
}
6161

6262
.thumb:hover {
63-
background-color: light-dark(var(--p-surface-400), var(--p-surface-500));
63+
background-color: var(--p-primary-hover-color);
6464
}

apps/showcase/docs/headless/scrollarea/features.mdx

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@ component: scrollarea
88

99
## Usage
1010

11-
```tsx showLineNumbers {1,3,5-6,9-11}
11+
```tsx showLineNumbers {1,3,5-7,9-11}
1212
import { useScrollArea } from '@primereact/headless/scrollarea';
1313

14-
const { rootProps, viewportProps, getScrollbarProps, getThumbProps, cornerProps, state, rootRef, viewportRef, scrollbarYRef, thumbYRef, hiddenState } = useScrollArea();
14+
const { rootProps, viewportProps, contentProps, getScrollbarProps, getThumbProps, hiddenState } = useScrollArea();
1515

16-
<div {...rootProps} ref={rootRef}>
17-
<div {...viewportProps} ref={viewportRef} style={{ overflow: 'scroll', scrollbarWidth: 'none' }}>
16+
<div {...rootProps}>
17+
<div {...viewportProps} style={{ overflow: 'scroll', scrollbarWidth: 'none' }}>
18+
<div {...contentProps}></div>
1819
</div>
1920
{!hiddenState.y && (
20-
<div {...getScrollbarProps('vertical')} ref={scrollbarYRef}>
21-
<div {...getThumbProps('vertical')} ref={thumbYRef} />
21+
<div {...getScrollbarProps('vertical')}>
22+
<div {...getThumbProps('vertical')} />
2223
</div>
2324
)}
2425
</div>;
@@ -28,7 +29,7 @@ const { rootProps, viewportProps, getScrollbarProps, getThumbProps, cornerProps,
2829

2930
## Features
3031

31-
- Returns spread-ready prop objects and ref assignments for all scroll area elements
32+
- Returns spread-ready prop objects with built-in ref callbacks for all scroll area elements
3233
- Automatic thumb size calculation based on viewport-to-content ratio
3334
- Pointer-based drag handling for both scrollbar track and thumb
3435
- ResizeObserver-based recalculation on content changes
@@ -47,42 +48,69 @@ const scrollArea = useScrollArea({ variant: 'hover' });
4748

4849
Available values: `'auto'` (default), `'hover'`, `'scroll'`, `'always'`, `'hidden'`.
4950

51+
### Hidden State
52+
53+
`hiddenState` provides boolean flags that indicate whether each scrollbar and the corner should be rendered.
54+
55+
```tsx
56+
const { hiddenState } = useScrollArea();
57+
58+
// hiddenState.x → true when horizontal scrollbar is unnecessary
59+
// hiddenState.y → true when vertical scrollbar is unnecessary
60+
// hiddenState.corner → true when the corner element is unnecessary (at least one axis has no overflow)
61+
```
62+
63+
Use these flags for conditional rendering of scrollbar and corner elements.
64+
65+
### Corner Size
66+
67+
`cornerSize` exposes the measured dimensions of the corner area where both scrollbars intersect. The hook sets `--corner-width` and `--corner-height` CSS variables on the root automatically, but `cornerSize` provides direct access for layout calculations.
68+
69+
```tsx
70+
const { cornerSize } = useScrollArea();
71+
72+
// cornerSize.width → pixel width of the vertical scrollbar (0 when no corner)
73+
// cornerSize.height → pixel height of the horizontal scrollbar (0 when no corner)
74+
```
75+
5076
### Overflow Detection
5177

52-
The hook tracks whether content overflows the viewport in each direction via `hiddenState` and `state`.
78+
`state` provides fine-grained scroll position flags for building fade masks, shadow indicators, or loading triggers at content edges.
5379

5480
```tsx
55-
const { hiddenState, state } = useScrollArea();
81+
const { state } = useScrollArea();
5682

57-
// hiddenState.y → true when vertical scrollbar is unnecessary
5883
// state.hasOverflowX → true when content overflows horizontally
84+
// state.hasOverflowY → true when content overflows vertically
5985
// state.scrollYBefore → true when scrolled past the top edge
6086
// state.scrollYAfter → true when content remains below the viewport
87+
// state.scrollXBefore → true when scrolled past the left edge
88+
// state.scrollXAfter → true when content remains to the right
6189
```
6290

6391
### Both Scrollbars
6492

6593
For content that overflows in both directions, use `getScrollbarProps` and `getThumbProps` with both orientations. The corner element fills the intersection.
6694

6795
```tsx
68-
const { getScrollbarProps, getThumbProps, cornerProps, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, cornerRef, hiddenState } = useScrollArea();
96+
const { getScrollbarProps, getThumbProps, cornerProps, hiddenState } = useScrollArea();
6997

7098
{
7199
!hiddenState.y && (
72-
<div {...getScrollbarProps('vertical')} ref={scrollbarYRef}>
73-
<div {...getThumbProps('vertical')} ref={thumbYRef} />
100+
<div {...getScrollbarProps('vertical')}>
101+
<div {...getThumbProps('vertical')} />
74102
</div>
75103
);
76104
}
77105
{
78106
!hiddenState.x && (
79-
<div {...getScrollbarProps('horizontal')} ref={scrollbarXRef}>
80-
<div {...getThumbProps('horizontal')} ref={thumbXRef} />
107+
<div {...getScrollbarProps('horizontal')}>
108+
<div {...getThumbProps('horizontal')} />
81109
</div>
82110
);
83111
}
84112
{
85-
!hiddenState.corner && <div {...cornerProps} ref={cornerRef} />;
113+
!hiddenState.corner && <div {...cornerProps} />;
86114
}
87115
```
88116

apps/showcase/docs/primitives/scrollarea/features.mdx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { ScrollArea } from 'primereact/scrollarea';
3030
<ScrollArea.Viewport>
3131
<ScrollArea.Content></ScrollArea.Content>
3232
</ScrollArea.Viewport>
33-
<ScrollArea.Scrollbar orientation="vertical">
33+
<ScrollArea.Scrollbar>
3434
<ScrollArea.Thumb />
3535
</ScrollArea.Scrollbar>
3636
</ScrollArea.Root>
@@ -45,12 +45,18 @@ Use `as` on any sub-component to change the rendered HTML element.
4545
```tsx
4646
<ScrollArea.Root as="section">
4747
<ScrollArea.Viewport as="main">
48-
<ScrollArea.Content as="article">...</ScrollArea.Content>
48+
<ScrollArea.Content as="article"></ScrollArea.Content>
4949
</ScrollArea.Viewport>
5050
</ScrollArea.Root>
5151
```
5252

53-
Default elements: `Root`=`div`, `Viewport`=`div`, `Content`=`div`, `Scrollbar`=`div`, `Thumb`=`div`, `Corner`=`div`.
53+
### Render Function Children
54+
55+
Sub-components accept a render function as children, providing access to the component instance.
56+
57+
```tsx
58+
<ScrollArea.Root>{(instance) => <div>{instance.state.scrolling ? 'Scrolling...' : 'Idle'}</div>}</ScrollArea.Root>
59+
```
5460

5561
## API
5662

packages/@primereact/headless/src/scrollarea/useScrollArea.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,34 @@ export const useScrollArea = withHeadless({
4343
const scrollYTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
4444
const rafRef = React.useRef<number | null>(null);
4545

46+
const setRootRef = React.useCallback((node: HTMLDivElement | null) => {
47+
rootRef.current = node;
48+
}, []);
49+
50+
const setViewportRef = React.useCallback((node: HTMLDivElement | null) => {
51+
viewportRef.current = node;
52+
}, []);
53+
54+
const setScrollbarYRef = React.useCallback((node: HTMLDivElement | null) => {
55+
scrollbarYRef.current = node;
56+
}, []);
57+
58+
const setScrollbarXRef = React.useCallback((node: HTMLDivElement | null) => {
59+
scrollbarXRef.current = node;
60+
}, []);
61+
62+
const setThumbYRef = React.useCallback((node: HTMLDivElement | null) => {
63+
thumbYRef.current = node;
64+
}, []);
65+
66+
const setThumbXRef = React.useCallback((node: HTMLDivElement | null) => {
67+
thumbXRef.current = node;
68+
}, []);
69+
70+
const setCornerRef = React.useCallback((node: HTMLDivElement | null) => {
71+
cornerRef.current = node;
72+
}, []);
73+
4674
const computeThumb = React.useCallback(function computeThumb() {
4775
const vp = viewportRef.current;
4876

@@ -347,74 +375,73 @@ export const useScrollArea = withHeadless({
347375
() => ({
348376
'data-scope': 'scrollarea' as const,
349377
'data-part': 'root' as const,
378+
ref: setRootRef,
350379
role: 'presentation' as const,
351380
'data-variant': props.variant,
352381
onPointerEnter: onRootPointerEnter,
353382
onPointerLeave: onRootPointerLeave,
354-
onPointerDown: onRootPointerDown
383+
onPointerDown: onRootPointerDown,
384+
style: {
385+
position: 'relative' as const,
386+
'--corner-height': `${cornerSize.height}px`,
387+
'--corner-width': `${cornerSize.width}px`
388+
}
355389
}),
356-
[props.variant, onRootPointerEnter, onRootPointerLeave, onRootPointerDown]
390+
[props.variant, onRootPointerEnter, onRootPointerLeave, onRootPointerDown, cornerSize]
357391
);
358392

359393
const viewportProps = React.useMemo(
360394
() => ({
361395
'data-scope': 'scrollarea' as const,
362396
'data-part': 'viewport' as const,
397+
ref: setViewportRef,
363398
tabIndex: undefined as number | undefined,
364399
onScroll: handleScroll
365400
}),
366-
[handleScroll]
401+
[handleScroll, setViewportRef]
367402
);
368403

369404
const getScrollbarProps = React.useCallback(
370405
(orientation: 'vertical' | 'horizontal') => ({
371406
'data-scope': 'scrollarea' as const,
372407
'data-part': (orientation === 'vertical' ? 'scrollbar-y' : 'scrollbar-x') as 'scrollbar-y' | 'scrollbar-x',
373408
'data-orientation': orientation,
409+
ref: orientation === 'vertical' ? setScrollbarYRef : setScrollbarXRef,
374410
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => handleScrollbarPointerDown(event, orientation),
375411
onPointerMove: handleScrollbarPointerMove,
376412
onPointerUp: handleScrollbarPointerUp,
377413
onWheel: (event: React.WheelEvent<HTMLDivElement>) => handleScrollbarWheel(event, orientation)
378414
}),
379-
[handleScrollbarPointerDown, handleScrollbarPointerMove, handleScrollbarPointerUp, handleScrollbarWheel]
415+
[handleScrollbarPointerDown, handleScrollbarPointerMove, handleScrollbarPointerUp, handleScrollbarWheel, setScrollbarYRef, setScrollbarXRef]
380416
);
381417

382418
const getThumbProps = React.useCallback(
383419
(orientation: 'vertical' | 'horizontal') => ({
384420
'data-scope': 'scrollarea' as const,
385421
'data-part': (orientation === 'vertical' ? 'thumb-y' : 'thumb-x') as 'thumb-y' | 'thumb-x',
386422
'data-orientation': orientation,
423+
ref: orientation === 'vertical' ? setThumbYRef : setThumbXRef,
387424
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => handleThumbPointerDown(event, orientation)
388425
}),
389-
[handleThumbPointerDown]
426+
[handleThumbPointerDown, setThumbYRef, setThumbXRef]
390427
);
391428

392-
const contentProps = React.useMemo(
393-
() => ({
394-
'data-scope': 'scrollarea' as const,
395-
'data-part': 'content' as const
396-
}),
397-
[]
398-
);
429+
const contentProps = {
430+
'data-scope': 'scrollarea' as const,
431+
'data-part': 'content' as const
432+
};
399433

400434
const cornerProps = React.useMemo(
401435
() => ({
402436
'data-scope': 'scrollarea' as const,
403-
'data-part': 'corner' as const
437+
'data-part': 'corner' as const,
438+
ref: setCornerRef
404439
}),
405-
[]
440+
[setCornerRef]
406441
);
407442

408443
return {
409444
state,
410-
// element refs
411-
rootRef,
412-
viewportRef,
413-
scrollbarYRef,
414-
scrollbarXRef,
415-
thumbYRef,
416-
thumbXRef,
417-
cornerRef,
418445
cornerSize,
419446
hiddenState,
420447
// prop getters

0 commit comments

Comments
 (0)