Skip to content

Commit 828cb97

Browse files
authored
[ScrollArea] Refactor to useRenderElement (#1869)
1 parent 7acd70d commit 828cb97

9 files changed

+640
-781
lines changed

packages/react/src/scroll-area/content/ScrollAreaContent.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
'use client';
22
import * as React from 'react';
33
import type { BaseUIComponentProps } from '../../utils/types';
4-
import { useComponentRenderer } from '../../utils/useComponentRenderer';
5-
import { mergeProps } from '../../merge-props';
64
import { useModernLayoutEffect } from '../../utils';
75
import { useScrollAreaViewportContext } from '../viewport/ScrollAreaViewportContext';
8-
9-
const state = {};
6+
import { useRenderElement } from '../../utils/useRenderElement';
107

118
/**
129
* A container for the content of the scroll area.
@@ -15,10 +12,10 @@ const state = {};
1512
* Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area)
1613
*/
1714
export const ScrollAreaContent = React.forwardRef(function ScrollAreaContent(
18-
props: ScrollAreaContent.Props,
15+
componentProps: ScrollAreaContent.Props,
1916
forwardedRef: React.ForwardedRef<HTMLDivElement>,
2017
) {
21-
const { render, className, ...otherProps } = props;
18+
const { render, className, ...elementProps } = componentProps;
2219

2320
const contentWrapperRef = React.useRef<HTMLDivElement | null>(null);
2421

@@ -40,17 +37,17 @@ export const ScrollAreaContent = React.forwardRef(function ScrollAreaContent(
4037
};
4138
}, [computeThumbPosition]);
4239

43-
const { renderElement } = useComponentRenderer({
44-
render: render ?? 'div',
45-
className,
40+
const renderElement = useRenderElement('div', componentProps, {
4641
ref: [forwardedRef, contentWrapperRef],
47-
state,
48-
extraProps: mergeProps<'div'>(otherProps, {
49-
role: 'presentation',
50-
style: {
51-
minWidth: 'fit-content',
42+
props: [
43+
{
44+
role: 'presentation',
45+
style: {
46+
minWidth: 'fit-content',
47+
},
5248
},
53-
}),
49+
elementProps,
50+
],
5451
});
5552

5653
return renderElement();

packages/react/src/scroll-area/corner/ScrollAreaCorner.tsx

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
'use client';
22
import * as React from 'react';
33
import type { BaseUIComponentProps } from '../../utils/types';
4-
import { useComponentRenderer } from '../../utils/useComponentRenderer';
5-
import { mergeProps } from '../../merge-props';
64
import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext';
7-
import { useForkRef } from '../../utils/useForkRef';
8-
9-
const state = {};
5+
import { useRenderElement } from '../../utils/useRenderElement';
106

117
/**
128
* A small rectangular area that appears at the intersection of horizontal and vertical scrollbars.
@@ -15,21 +11,16 @@ const state = {};
1511
* Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area)
1612
*/
1713
export const ScrollAreaCorner = React.forwardRef(function ScrollAreaCorner(
18-
props: ScrollAreaCorner.Props,
14+
componentProps: ScrollAreaCorner.Props,
1915
forwardedRef: React.ForwardedRef<HTMLDivElement>,
2016
) {
21-
const { render, className, ...otherProps } = props;
17+
const { render, className, ...elementProps } = componentProps;
2218

2319
const { cornerRef, cornerSize, hiddenState } = useScrollAreaRootContext();
2420

25-
const mergedRef = useForkRef(cornerRef, forwardedRef);
26-
27-
const { renderElement } = useComponentRenderer({
28-
render: render ?? 'div',
29-
ref: mergedRef,
30-
className,
31-
state,
32-
extraProps: mergeProps(
21+
const renderElement = useRenderElement('div', componentProps, {
22+
ref: [forwardedRef, cornerRef],
23+
props: [
3324
{
3425
style: {
3526
position: 'absolute',
@@ -39,8 +30,8 @@ export const ScrollAreaCorner = React.forwardRef(function ScrollAreaCorner(
3930
height: cornerSize.height,
4031
},
4132
},
42-
otherProps,
43-
),
33+
elementProps,
34+
],
4435
});
4536

4637
if (hiddenState.cornerHidden) {

packages/react/src/scroll-area/root/ScrollAreaRoot.tsx

Lines changed: 238 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
'use client';
22
import * as React from 'react';
3-
import type { BaseUIComponentProps } from '../../utils/types';
4-
import { useComponentRenderer } from '../../utils/useComponentRenderer';
3+
import type { BaseUIComponentProps, GenericHTMLProps } from '../../utils/types';
54
import { ScrollAreaRootContext } from './ScrollAreaRootContext';
6-
import { useScrollAreaRoot } from './useScrollAreaRoot';
5+
import { useRenderElement } from '../../utils/useRenderElement';
6+
import { ScrollAreaRootCssVars } from './ScrollAreaRootCssVars';
7+
import { useEventCallback } from '../../utils/useEventCallback';
8+
import { SCROLL_TIMEOUT } from '../constants';
9+
import { getOffset } from '../utils/getOffset';
10+
import { ScrollAreaScrollbarDataAttributes } from '../scrollbar/ScrollAreaScrollbarDataAttributes';
11+
import { useBaseUiId } from '../../utils/useBaseUiId';
12+
import { useTimeout } from '../../utils/useTimeout';
713

8-
const state = {};
14+
interface Size {
15+
width: number;
16+
height: number;
17+
}
918

1019
/**
1120
* Groups all parts of the scroll area.
@@ -14,25 +23,239 @@ const state = {};
1423
* Documentation: [Base UI Scroll Area](https://base-ui.com/react/components/scroll-area)
1524
*/
1625
export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
17-
props: ScrollAreaRoot.Props,
26+
componentProps: ScrollAreaRoot.Props,
1827
forwardedRef: React.ForwardedRef<HTMLDivElement>,
1928
) {
20-
const { render, className, ...otherProps } = props;
29+
const { render, className, ...elementProps } = componentProps;
30+
31+
const [hovering, setHovering] = React.useState(false);
32+
const [scrollingX, setScrollingX] = React.useState(false);
33+
const [scrollingY, setScrollingY] = React.useState(false);
34+
const [cornerSize, setCornerSize] = React.useState<Size>({ width: 0, height: 0 });
35+
const [thumbSize, setThumbSize] = React.useState<Size>({ width: 0, height: 0 });
36+
const [touchModality, setTouchModality] = React.useState(false);
37+
38+
const rootId = useBaseUiId();
39+
40+
const viewportRef = React.useRef<HTMLDivElement | null>(null);
41+
const scrollbarYRef = React.useRef<HTMLDivElement | null>(null);
42+
const scrollbarXRef = React.useRef<HTMLDivElement | null>(null);
43+
const thumbYRef = React.useRef<HTMLDivElement | null>(null);
44+
const thumbXRef = React.useRef<HTMLDivElement | null>(null);
45+
const cornerRef = React.useRef<HTMLDivElement | null>(null);
46+
47+
const thumbDraggingRef = React.useRef(false);
48+
const startYRef = React.useRef(0);
49+
const startXRef = React.useRef(0);
50+
const startScrollTopRef = React.useRef(0);
51+
const startScrollLeftRef = React.useRef(0);
52+
const currentOrientationRef = React.useRef<'vertical' | 'horizontal'>('vertical');
53+
const scrollYTimeout = useTimeout();
54+
const scrollXTimeout = useTimeout();
55+
const scrollPositionRef = React.useRef({ x: 0, y: 0 });
56+
57+
const [hiddenState, setHiddenState] = React.useState({
58+
scrollbarYHidden: false,
59+
scrollbarXHidden: false,
60+
cornerHidden: false,
61+
});
62+
63+
const handleScroll = useEventCallback((scrollPosition: { x: number; y: number }) => {
64+
const offsetX = scrollPosition.x - scrollPositionRef.current.x;
65+
const offsetY = scrollPosition.y - scrollPositionRef.current.y;
66+
scrollPositionRef.current = scrollPosition;
67+
68+
if (offsetY !== 0) {
69+
setScrollingY(true);
70+
71+
scrollYTimeout.start(SCROLL_TIMEOUT, () => {
72+
setScrollingY(false);
73+
});
74+
}
75+
76+
if (offsetX !== 0) {
77+
setScrollingX(true);
78+
79+
scrollXTimeout.start(SCROLL_TIMEOUT, () => {
80+
setScrollingX(false);
81+
});
82+
}
83+
});
84+
85+
const handlePointerDown = useEventCallback((event: React.PointerEvent) => {
86+
thumbDraggingRef.current = true;
87+
startYRef.current = event.clientY;
88+
startXRef.current = event.clientX;
89+
currentOrientationRef.current = event.currentTarget.getAttribute(
90+
ScrollAreaScrollbarDataAttributes.orientation,
91+
) as 'vertical' | 'horizontal';
92+
93+
if (viewportRef.current) {
94+
startScrollTopRef.current = viewportRef.current.scrollTop;
95+
startScrollLeftRef.current = viewportRef.current.scrollLeft;
96+
}
97+
if (thumbYRef.current && currentOrientationRef.current === 'vertical') {
98+
thumbYRef.current.setPointerCapture(event.pointerId);
99+
}
100+
if (thumbXRef.current && currentOrientationRef.current === 'horizontal') {
101+
thumbXRef.current.setPointerCapture(event.pointerId);
102+
}
103+
});
104+
105+
const handlePointerMove = useEventCallback((event: React.PointerEvent) => {
106+
if (!thumbDraggingRef.current) {
107+
return;
108+
}
21109

22-
const scrollAreaRoot = useScrollAreaRoot();
110+
const deltaY = event.clientY - startYRef.current;
111+
const deltaX = event.clientX - startXRef.current;
23112

24-
const { rootId } = scrollAreaRoot;
113+
if (viewportRef.current) {
114+
const scrollableContentHeight = viewportRef.current.scrollHeight;
115+
const viewportHeight = viewportRef.current.clientHeight;
116+
const scrollableContentWidth = viewportRef.current.scrollWidth;
117+
const viewportWidth = viewportRef.current.clientWidth;
25118

26-
const { renderElement } = useComponentRenderer({
27-
propGetter: scrollAreaRoot.getRootProps,
28-
render: render ?? 'div',
119+
if (
120+
thumbYRef.current &&
121+
scrollbarYRef.current &&
122+
currentOrientationRef.current === 'vertical'
123+
) {
124+
const scrollbarYOffset = getOffset(scrollbarYRef.current, 'padding', 'y');
125+
const thumbYOffset = getOffset(thumbYRef.current, 'margin', 'y');
126+
const thumbHeight = thumbYRef.current.offsetHeight;
127+
const maxThumbOffsetY =
128+
scrollbarYRef.current.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset;
129+
const scrollRatioY = deltaY / maxThumbOffsetY;
130+
viewportRef.current.scrollTop =
131+
startScrollTopRef.current + scrollRatioY * (scrollableContentHeight - viewportHeight);
132+
event.preventDefault();
133+
134+
setScrollingY(true);
135+
136+
scrollYTimeout.start(SCROLL_TIMEOUT, () => {
137+
setScrollingY(false);
138+
});
139+
}
140+
141+
if (
142+
thumbXRef.current &&
143+
scrollbarXRef.current &&
144+
currentOrientationRef.current === 'horizontal'
145+
) {
146+
const scrollbarXOffset = getOffset(scrollbarXRef.current, 'padding', 'x');
147+
const thumbXOffset = getOffset(thumbXRef.current, 'margin', 'x');
148+
const thumbWidth = thumbXRef.current.offsetWidth;
149+
const maxThumbOffsetX =
150+
scrollbarXRef.current.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset;
151+
const scrollRatioX = deltaX / maxThumbOffsetX;
152+
viewportRef.current.scrollLeft =
153+
startScrollLeftRef.current + scrollRatioX * (scrollableContentWidth - viewportWidth);
154+
event.preventDefault();
155+
156+
setScrollingX(true);
157+
158+
scrollXTimeout.start(SCROLL_TIMEOUT, () => {
159+
setScrollingX(false);
160+
});
161+
}
162+
}
163+
});
164+
165+
const handlePointerUp = useEventCallback((event: React.PointerEvent) => {
166+
thumbDraggingRef.current = false;
167+
168+
if (thumbYRef.current && currentOrientationRef.current === 'vertical') {
169+
thumbYRef.current.releasePointerCapture(event.pointerId);
170+
}
171+
if (thumbXRef.current && currentOrientationRef.current === 'horizontal') {
172+
thumbXRef.current.releasePointerCapture(event.pointerId);
173+
}
174+
});
175+
176+
function handlePointerEnterOrMove({ pointerType }: React.PointerEvent) {
177+
const isTouch = pointerType === 'touch';
178+
179+
setTouchModality(isTouch);
180+
181+
if (!isTouch) {
182+
setHovering(true);
183+
}
184+
}
185+
186+
const props: GenericHTMLProps = {
187+
role: 'presentation',
188+
onPointerEnter: handlePointerEnterOrMove,
189+
onPointerMove: handlePointerEnterOrMove,
190+
onPointerDown({ pointerType }) {
191+
setTouchModality(pointerType === 'touch');
192+
},
193+
onPointerLeave() {
194+
setHovering(false);
195+
},
196+
style: {
197+
position: 'relative',
198+
[ScrollAreaRootCssVars.scrollAreaCornerHeight as string]: `${cornerSize.height}px`,
199+
[ScrollAreaRootCssVars.scrollAreaCornerWidth as string]: `${cornerSize.width}px`,
200+
},
201+
};
202+
203+
const renderElement = useRenderElement('div', componentProps, {
29204
ref: forwardedRef,
30-
className,
31-
state,
32-
extraProps: otherProps,
205+
props: [props, elementProps],
33206
});
34207

35-
const contextValue = React.useMemo(() => scrollAreaRoot, [scrollAreaRoot]);
208+
const contextValue = React.useMemo(
209+
() => ({
210+
handlePointerDown,
211+
handlePointerMove,
212+
handlePointerUp,
213+
handleScroll,
214+
cornerSize,
215+
setCornerSize,
216+
thumbSize,
217+
setThumbSize,
218+
touchModality,
219+
cornerRef,
220+
scrollingX,
221+
setScrollingX,
222+
scrollingY,
223+
setScrollingY,
224+
hovering,
225+
setHovering,
226+
viewportRef,
227+
scrollbarYRef,
228+
scrollbarXRef,
229+
thumbYRef,
230+
thumbXRef,
231+
rootId,
232+
hiddenState,
233+
setHiddenState,
234+
}),
235+
[
236+
handlePointerDown,
237+
handlePointerMove,
238+
handlePointerUp,
239+
handleScroll,
240+
cornerSize,
241+
thumbSize,
242+
touchModality,
243+
cornerRef,
244+
scrollingX,
245+
setScrollingX,
246+
scrollingY,
247+
setScrollingY,
248+
hovering,
249+
setHovering,
250+
viewportRef,
251+
scrollbarYRef,
252+
scrollbarXRef,
253+
thumbYRef,
254+
thumbXRef,
255+
rootId,
256+
hiddenState,
257+
],
258+
);
36259

37260
const viewportId = `[data-id="${rootId}-viewport"]`;
38261

0 commit comments

Comments
 (0)