Skip to content

Commit 947b12f

Browse files
authored
feat(base-ui): add FloatingSheet component (#498)
* feat(base-ui): add FloatingSheet component * ✨ feat(base-ui): replace FloatingSheet push mode with inline mode Inline mode embeds the sheet as a card in document flow with a fixed layout footprint (restingHeight); dragging the header up expands the sheet visually above its anchor without shifting sibling content below. Inline rounds all four corners while overlay keeps top-only rounding. * 🐛 fix(base-ui): correct FloatingSheet dampenValue to return positive resistance Previous formula 8*(log(v+1)-2) returned negative values for small overshoots, causing the sheet to jump in the wrong direction at snap boundaries. Replace with a proper rubber-band damping that is 0 at v=0 and grows logarithmically.
1 parent 2887f49 commit 947b12f

17 files changed

Lines changed: 2160 additions & 0 deletions
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { cx } from 'antd-style';
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3+
4+
import { FloatingSheetHeader } from './FloatingSheetHeader';
5+
import { clamp, dampenValue, resolveSize } from './helpers';
6+
import { styles } from './style';
7+
import type { FloatingSheetProps } from './type';
8+
import { useSheetDrag } from './useSheetDrag';
9+
import { useSnapPoints } from './useSnapPoints';
10+
11+
const ANIMATION_MS = 300;
12+
13+
export function FloatingSheet({
14+
open: openProp,
15+
onOpenChange,
16+
defaultOpen = false,
17+
snapPoints: snapPointsProp,
18+
activeSnapPoint: activeSnapPointProp,
19+
onSnapPointChange,
20+
minHeight: minHeightProp = 200,
21+
maxHeight: maxHeightProp = 0.8,
22+
restingHeight: restingHeightProp,
23+
mode = 'overlay',
24+
variant = 'elevated',
25+
width = '100%',
26+
title,
27+
headerActions,
28+
dismissible = true,
29+
closeThreshold = 0.25,
30+
children,
31+
className,
32+
}: FloatingSheetProps) {
33+
const s = styles;
34+
35+
// Controlled / uncontrolled open state
36+
const isControlled = openProp !== undefined;
37+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
38+
const isOpen = isControlled ? openProp : internalOpen;
39+
40+
const setOpen = useCallback(
41+
(value: boolean) => {
42+
if (!isControlled) setInternalOpen(value);
43+
onOpenChange?.(value);
44+
},
45+
[isControlled, onOpenChange],
46+
);
47+
48+
// Container measurement via ResizeObserver
49+
const containerRef = useRef<HTMLElement | null>(null);
50+
const sheetRef = useRef<HTMLDivElement>(null);
51+
const [containerHeight, setContainerHeight] = useState(0);
52+
53+
useEffect(() => {
54+
const parent = sheetRef.current?.parentElement;
55+
if (!parent) return;
56+
containerRef.current = parent;
57+
58+
const observer = new ResizeObserver((entries) => {
59+
for (const entry of entries) {
60+
setContainerHeight(entry.contentRect.height);
61+
}
62+
});
63+
observer.observe(parent);
64+
setContainerHeight(parent.getBoundingClientRect().height);
65+
66+
return () => observer.disconnect();
67+
}, []);
68+
69+
// Resolve min/max to px
70+
const minHeightPx = useMemo(
71+
() => resolveSize(minHeightProp, containerHeight),
72+
[minHeightProp, containerHeight],
73+
);
74+
const maxHeightPx = useMemo(
75+
() => resolveSize(maxHeightProp, containerHeight),
76+
[maxHeightProp, containerHeight],
77+
);
78+
const restingHeightPx = useMemo(
79+
() =>
80+
restingHeightProp !== undefined
81+
? clamp(resolveSize(restingHeightProp, containerHeight), minHeightPx, maxHeightPx)
82+
: minHeightPx,
83+
[restingHeightProp, containerHeight, minHeightPx, maxHeightPx],
84+
);
85+
86+
// Snap points
87+
const hasSnapPoints = !!snapPointsProp && snapPointsProp.length > 0;
88+
const { snapPointHeights, findActiveIndex, getSnapRelease } = useSnapPoints({
89+
closeThreshold,
90+
containerHeight,
91+
containerRef,
92+
maxHeightPx,
93+
minHeightPx,
94+
snapPoints: snapPointsProp ?? [],
95+
});
96+
97+
// Compute the "resting" height for the current open + snap state
98+
const restingHeight = useMemo(() => {
99+
if (!containerHeight) return 0;
100+
if (hasSnapPoints && activeSnapPointProp !== undefined) {
101+
const resolved = resolveSize(activeSnapPointProp, containerHeight);
102+
return clamp(resolved, minHeightPx, maxHeightPx);
103+
}
104+
if (hasSnapPoints && snapPointHeights.length > 0) {
105+
return snapPointHeights[0];
106+
}
107+
return restingHeightPx;
108+
}, [
109+
containerHeight,
110+
hasSnapPoints,
111+
activeSnapPointProp,
112+
snapPointHeights,
113+
minHeightPx,
114+
maxHeightPx,
115+
restingHeightPx,
116+
]);
117+
118+
const [height, setHeight] = useState(isOpen ? restingHeight : 0);
119+
const [isAnimating, setIsAnimating] = useState(false);
120+
// Keeps sheet visible during close animation (height → 0)
121+
const [isClosing, setIsClosing] = useState(false);
122+
const heightBeforeDrag = useRef(0);
123+
const prevOpenRef = useRef(isOpen);
124+
125+
// Handle open/close transitions
126+
useEffect(() => {
127+
const wasOpen = prevOpenRef.current;
128+
prevOpenRef.current = isOpen;
129+
130+
if (isOpen && !wasOpen) {
131+
// Opening: animate from 0 → resting height
132+
setIsClosing(false);
133+
setIsAnimating(true);
134+
setHeight(restingHeight);
135+
const timer = setTimeout(() => setIsAnimating(false), ANIMATION_MS);
136+
return () => clearTimeout(timer);
137+
}
138+
139+
if (!isOpen && wasOpen) {
140+
// Closing: animate from current height → 0, then hide
141+
setIsClosing(true);
142+
setIsAnimating(true);
143+
setHeight(0);
144+
const timer = setTimeout(() => {
145+
setIsAnimating(false);
146+
setIsClosing(false);
147+
}, ANIMATION_MS);
148+
return () => clearTimeout(timer);
149+
}
150+
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
151+
152+
// Sync height when resting height changes (container resize, snap point change)
153+
useEffect(() => {
154+
if (isOpen && !isDragging) {
155+
setHeight(restingHeight);
156+
}
157+
}, [restingHeight]); // eslint-disable-line react-hooks/exhaustive-deps
158+
159+
// Drag handlers
160+
const onDragChange = useCallback(
161+
(draggedDistance: number) => {
162+
const newHeight = heightBeforeDrag.current + draggedDistance;
163+
164+
if (hasSnapPoints) {
165+
const highest = snapPointHeights.at(-1) ?? maxHeightPx;
166+
const lowest = snapPointHeights[0] ?? minHeightPx;
167+
168+
if (newHeight > highest) {
169+
const overshoot = newHeight - highest;
170+
setHeight(highest + dampenValue(overshoot));
171+
} else if (newHeight < lowest) {
172+
const undershoot = lowest - newHeight;
173+
setHeight(Math.max(0, lowest - dampenValue(undershoot)));
174+
} else {
175+
setHeight(newHeight);
176+
}
177+
} else {
178+
setHeight(clamp(newHeight, 0, maxHeightPx));
179+
}
180+
},
181+
[hasSnapPoints, snapPointHeights, maxHeightPx, minHeightPx],
182+
);
183+
184+
const onDragEnd = useCallback(
185+
(draggedDistance: number, velocity: number) => {
186+
setIsAnimating(true);
187+
const currentHeight = heightBeforeDrag.current + draggedDistance;
188+
189+
if (hasSnapPoints) {
190+
const activeIndex = findActiveIndex(heightBeforeDrag.current);
191+
const result = getSnapRelease({
192+
activeIndex,
193+
currentHeight,
194+
dismissible,
195+
draggedDistance,
196+
velocity,
197+
});
198+
199+
if (result.type === 'dismiss') {
200+
setIsClosing(true);
201+
setHeight(0);
202+
const timer = setTimeout(() => {
203+
setOpen(false);
204+
setIsAnimating(false);
205+
setIsClosing(false);
206+
}, ANIMATION_MS);
207+
return () => clearTimeout(timer);
208+
}
209+
210+
setHeight(result.height);
211+
const originalSnapValue = snapPointsProp?.find(
212+
(sp) =>
213+
resolveSize(sp, containerHeight) === result.height ||
214+
clamp(resolveSize(sp, containerHeight), minHeightPx, maxHeightPx) === result.height,
215+
);
216+
if (originalSnapValue !== undefined) {
217+
onSnapPointChange?.(originalSnapValue);
218+
}
219+
} else {
220+
if (dismissible && currentHeight < minHeightPx * closeThreshold) {
221+
setIsClosing(true);
222+
setHeight(0);
223+
const timer = setTimeout(() => {
224+
setOpen(false);
225+
setIsAnimating(false);
226+
setIsClosing(false);
227+
}, ANIMATION_MS);
228+
return () => clearTimeout(timer);
229+
}
230+
setHeight(clamp(currentHeight, minHeightPx, maxHeightPx));
231+
}
232+
233+
setTimeout(() => setIsAnimating(false), ANIMATION_MS);
234+
},
235+
[
236+
hasSnapPoints,
237+
findActiveIndex,
238+
getSnapRelease,
239+
dismissible,
240+
snapPointsProp,
241+
containerHeight,
242+
minHeightPx,
243+
maxHeightPx,
244+
closeThreshold,
245+
setOpen,
246+
onSnapPointChange,
247+
],
248+
);
249+
250+
const { isDragging, handleProps } = useSheetDrag({
251+
enabled: isOpen ?? false,
252+
onDragChange,
253+
onDragEnd,
254+
});
255+
256+
// Record height at drag start
257+
useEffect(() => {
258+
if (isDragging) {
259+
heightBeforeDrag.current = height;
260+
}
261+
}, [isDragging]); // eslint-disable-line react-hooks/exhaustive-deps
262+
263+
const isVisible = isOpen || isClosing || height > 0;
264+
const shouldAnimate = !isDragging && isAnimating;
265+
const inlineOverflowUp =
266+
mode === 'inline' && isVisible ? Math.max(0, height - restingHeightPx) : 0;
267+
268+
return (
269+
<div
270+
data-floating-sheet=""
271+
data-state={isOpen ? 'open' : 'closed'}
272+
ref={sheetRef}
273+
className={cx(
274+
s.root,
275+
variant === 'embedded' ? s.embedded : s.elevated,
276+
mode === 'overlay' ? s.overlay : s.inline,
277+
mode === 'overlay' ? s.overlayRadius : s.inlineRadius,
278+
shouldAnimate && s.transition,
279+
!isVisible && s.hidden,
280+
className,
281+
)}
282+
style={{
283+
height: isVisible ? height : 0,
284+
marginTop: inlineOverflowUp ? -inlineOverflowUp : undefined,
285+
width,
286+
}}
287+
>
288+
<FloatingSheetHeader
289+
handleProps={handleProps}
290+
headerActions={headerActions}
291+
isDragging={isDragging}
292+
title={title}
293+
/>
294+
<div className={s.content}>{children}</div>
295+
</div>
296+
);
297+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { cx } from 'antd-style';
2+
import { type ReactNode } from 'react';
3+
4+
import { styles } from './style';
5+
6+
interface FloatingSheetHeaderProps {
7+
handleProps: {
8+
onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
9+
};
10+
headerActions?: ReactNode;
11+
isDragging: boolean;
12+
title?: ReactNode;
13+
}
14+
15+
export function FloatingSheetHeader({
16+
title,
17+
headerActions,
18+
isDragging,
19+
handleProps,
20+
}: FloatingSheetHeaderProps) {
21+
const s = styles;
22+
23+
return (
24+
<div className={cx(s.header, isDragging && s.headerDragging)} {...handleProps}>
25+
<div className={s.handle} />
26+
<div className={s.headerContent}>
27+
{title && <div className={s.headerTitle}>{title}</div>}
28+
{headerActions && (
29+
<div className={s.headerActions} data-no-drag="">
30+
{headerActions}
31+
</div>
32+
)}
33+
</div>
34+
</div>
35+
);
36+
}

0 commit comments

Comments
 (0)