@12-apps/ui 1.4.0 → 1.5.0
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.
- package/eslint.config.js +12 -0
- package/package.json +5 -4
- package/src/components/data-display/AlertDialog/AlertDialog.test.stories.tsx +16 -6
- package/src/components/data-display/Banner/Banner.test.stories.tsx +26 -18
- package/src/components/data-display/Carousel/Carousel.chrome.tsx +83 -0
- package/src/components/data-display/Carousel/Carousel.hooks.ts +88 -0
- package/src/components/data-display/Carousel/Carousel.parts.tsx +346 -0
- package/src/components/data-display/Carousel/Carousel.slide.tsx +190 -0
- package/src/components/data-display/Carousel/Carousel.styles.ts +86 -0
- package/src/components/data-display/Carousel/Carousel.test.stories.tsx +15 -5
- package/src/components/data-display/Carousel/Carousel.tsx +84 -732
- package/src/components/data-display/HoverCard/HoverCard.animations.ts +73 -0
- package/src/components/data-display/HoverCard/HoverCard.content.tsx +143 -0
- package/src/components/data-display/HoverCard/HoverCard.hooks.ts +180 -0
- package/src/components/data-display/HoverCard/HoverCard.styles.ts +178 -0
- package/src/components/data-display/HoverCard/HoverCard.tsx +117 -631
- package/src/components/data-display/Sheet/Sheet.animations.ts +44 -0
- package/src/components/data-display/Sheet/Sheet.body.tsx +124 -0
- package/src/components/data-display/Sheet/Sheet.drag.ts +203 -0
- package/src/components/data-display/Sheet/Sheet.hooks.ts +318 -0
- package/src/components/data-display/Sheet/Sheet.parts.tsx +224 -0
- package/src/components/data-display/Sheet/Sheet.styles.ts +257 -0
- package/src/components/data-display/Sheet/Sheet.test.stories.tsx +25 -21
- package/src/components/data-display/Sheet/Sheet.tsx +125 -948
- package/src/components/feedback/Dialog/Dialog.test.stories.tsx +37 -28
- package/src/components/feedback/Modal/Modal.styles.ts +126 -0
- package/src/components/feedback/Modal/Modal.test.stories.tsx +5 -5
- package/src/components/feedback/Modal/Modal.transitions.tsx +40 -0
- package/src/components/feedback/Modal/Modal.tsx +37 -242
- package/src/components/feedback/StackedModal/StackedModal.test.stories.tsx +9 -11
- package/src/components/form/InputOTP/InputOTP.hooks.ts +104 -0
- package/src/components/form/InputOTP/InputOTP.styles.ts +97 -0
- package/src/components/form/InputOTP/InputOTP.tsx +74 -221
- package/src/components/form/Label/Label.parts.tsx +75 -0
- package/src/components/form/Label/Label.styles.ts +206 -0
- package/src/components/form/Label/Label.test.stories.tsx +20 -14
- package/src/components/form/Label/Label.tsx +73 -325
- package/src/components/form/Menubar/Menubar.hooks.ts +41 -0
- package/src/components/form/Menubar/Menubar.item.tsx +121 -0
- package/src/components/form/Menubar/Menubar.separator.tsx +25 -0
- package/src/components/form/Menubar/Menubar.styles.ts +110 -0
- package/src/components/form/Menubar/Menubar.toolbar.tsx +103 -0
- package/src/components/form/Menubar/Menubar.tsx +64 -347
- package/src/components/form/RadioGroup/RadioGroup.animations.ts +48 -0
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +305 -0
- package/src/components/form/RadioGroup/RadioGroup.tsx +81 -651
- package/src/components/form/RadioGroup/RadioGroup.variants.tsx +291 -0
- package/src/components/form/Slider/Slider.animations.ts +40 -0
- package/src/components/form/Slider/Slider.styles.ts +292 -0
- package/src/components/form/Slider/Slider.tsx +106 -357
- package/src/components/form/Switch/Switch.animations.ts +62 -0
- package/src/components/form/Switch/Switch.parts.tsx +213 -0
- package/src/components/form/Switch/Switch.styles.ts +355 -0
- package/src/components/form/Switch/Switch.tsx +87 -566
- package/src/components/form/ToggleGroup/ToggleGroup.test.stories.tsx +26 -29
- package/src/components/layout/Resizable/Resizable.test.stories.tsx +11 -7
- package/src/components/layout/Spacer/Spacer.test.stories.tsx +11 -9
- package/src/components/typography/Blockquote/Blockquote.test.stories.tsx +7 -7
- package/src/components/typography/Heading/Heading.styles.ts +101 -0
- package/src/components/typography/Heading/Heading.tsx +48 -164
- package/src/components/typography/Text/Text.test.stories.tsx +27 -30
- package/src/components/utility/AnimatedIcon/AnimatedIcon.animations.ts +274 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +231 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.tsx +39 -559
- package/src/utils/index.ts +1 -0
- package/src/utils/withDefaults.ts +20 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { keyframes } from '@mui/material';
|
|
2
|
+
|
|
3
|
+
/** Spring physics used by the draggable variant's snap-point animation. */
|
|
4
|
+
export const SPRING_CONFIG = {
|
|
5
|
+
tension: 200,
|
|
6
|
+
friction: 25,
|
|
7
|
+
velocity: 0,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Velocity threshold for snap detection (pixels per millisecond). */
|
|
11
|
+
export const DEFAULT_VELOCITY_THRESHOLD = 0.5;
|
|
12
|
+
|
|
13
|
+
/** Drag resistance factor applied past the first and last snap points. */
|
|
14
|
+
export const DEFAULT_DRAG_RESISTANCE = 0.3;
|
|
15
|
+
|
|
16
|
+
export const pulseAnimation = keyframes`
|
|
17
|
+
0% {
|
|
18
|
+
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0.4);
|
|
19
|
+
}
|
|
20
|
+
70% {
|
|
21
|
+
box-shadow: 0 0 0 20px rgba(var(--pulse-color), 0);
|
|
22
|
+
}
|
|
23
|
+
100% {
|
|
24
|
+
box-shadow: 0 0 0 0 rgba(var(--pulse-color), 0);
|
|
25
|
+
}
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export const shimmerAnimation = keyframes`
|
|
29
|
+
0% {
|
|
30
|
+
background-position: -1000px 0;
|
|
31
|
+
}
|
|
32
|
+
100% {
|
|
33
|
+
background-position: 1000px 0;
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
export const glowAnimation = keyframes`
|
|
38
|
+
0%, 100% {
|
|
39
|
+
box-shadow: 0 0 20px 5px rgba(var(--glow-color), 0.3);
|
|
40
|
+
}
|
|
41
|
+
50% {
|
|
42
|
+
box-shadow: 0 0 35px 10px rgba(var(--glow-color), 0.5);
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Box, CircularProgress } from '@mui/material';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
import { SheetContent, SheetFooter, SheetHeader } from './Sheet.parts';
|
|
5
|
+
import type { SheetProps } from './Sheet.types';
|
|
6
|
+
|
|
7
|
+
export interface SheetBodyProps extends Pick<
|
|
8
|
+
SheetProps,
|
|
9
|
+
| 'title'
|
|
10
|
+
| 'description'
|
|
11
|
+
| 'showCloseButton'
|
|
12
|
+
| 'showHandle'
|
|
13
|
+
| 'header'
|
|
14
|
+
| 'footer'
|
|
15
|
+
| 'loading'
|
|
16
|
+
| 'color'
|
|
17
|
+
| 'children'
|
|
18
|
+
| 'onClick'
|
|
19
|
+
| 'onFocus'
|
|
20
|
+
| 'onBlur'
|
|
21
|
+
> {
|
|
22
|
+
testId: string;
|
|
23
|
+
isDragging: boolean;
|
|
24
|
+
isVerticalSheet: boolean;
|
|
25
|
+
isDraggableVariant: boolean;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
onDragStart: (e: React.MouseEvent | React.TouchEvent) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The header earns its place if it would show anything at all — a grab handle,
|
|
32
|
+
* either piece of text, the close button, or caller-supplied content.
|
|
33
|
+
*/
|
|
34
|
+
const needsHeader = (props: SheetBodyProps) =>
|
|
35
|
+
Boolean(
|
|
36
|
+
props.showHandle ||
|
|
37
|
+
props.title ||
|
|
38
|
+
props.description ||
|
|
39
|
+
props.showCloseButton ||
|
|
40
|
+
props.header,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** The centred spinner shown in place of the children while `loading`. */
|
|
44
|
+
const SheetLoading: React.FC<Pick<SheetBodyProps, 'color'>> = ({ color }) => (
|
|
45
|
+
<Box
|
|
46
|
+
sx={{
|
|
47
|
+
display: 'flex',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
minHeight: 200,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<CircularProgress color={color} />
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the sheet contains, independent of which drawer is carrying it. The
|
|
59
|
+
* swipeable and plain branches render exactly this — they differ only in the
|
|
60
|
+
* MUI component and its swipe props, so the contents are built once.
|
|
61
|
+
*/
|
|
62
|
+
export const SheetBody: React.FC<SheetBodyProps> = (props) => {
|
|
63
|
+
const {
|
|
64
|
+
title,
|
|
65
|
+
description,
|
|
66
|
+
showCloseButton,
|
|
67
|
+
showHandle,
|
|
68
|
+
header,
|
|
69
|
+
footer,
|
|
70
|
+
loading,
|
|
71
|
+
color,
|
|
72
|
+
children,
|
|
73
|
+
onClick,
|
|
74
|
+
onFocus,
|
|
75
|
+
onBlur,
|
|
76
|
+
testId,
|
|
77
|
+
isDragging,
|
|
78
|
+
isVerticalSheet,
|
|
79
|
+
isDraggableVariant,
|
|
80
|
+
onClose,
|
|
81
|
+
onDragStart,
|
|
82
|
+
} = props;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Box
|
|
86
|
+
onClick={onClick}
|
|
87
|
+
onFocus={onFocus}
|
|
88
|
+
onBlur={onBlur}
|
|
89
|
+
data-testid={testId}
|
|
90
|
+
sx={{
|
|
91
|
+
height: '100%',
|
|
92
|
+
display: 'flex',
|
|
93
|
+
flexDirection: 'column',
|
|
94
|
+
position: 'relative',
|
|
95
|
+
userSelect: isDragging ? 'none' : 'auto',
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{needsHeader(props) && (
|
|
99
|
+
<SheetHeader
|
|
100
|
+
title={title}
|
|
101
|
+
description={description}
|
|
102
|
+
showCloseButton={showCloseButton}
|
|
103
|
+
onClose={onClose}
|
|
104
|
+
showHandle={showHandle && isVerticalSheet}
|
|
105
|
+
isDraggable={isDraggableVariant && isVerticalSheet}
|
|
106
|
+
onDragStart={onDragStart}
|
|
107
|
+
dataTestId={`${testId}-header`}
|
|
108
|
+
>
|
|
109
|
+
{header}
|
|
110
|
+
</SheetHeader>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<SheetContent padded dataTestId={`${testId}-content`}>
|
|
114
|
+
{loading ? <SheetLoading color={color} /> : children}
|
|
115
|
+
</SheetContent>
|
|
116
|
+
|
|
117
|
+
{footer && (
|
|
118
|
+
<SheetFooter sticky divider dataTestId={`${testId}-footer`}>
|
|
119
|
+
{footer}
|
|
120
|
+
</SheetFooter>
|
|
121
|
+
)}
|
|
122
|
+
</Box>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { SheetDragInput, SnapGeometry } from './Sheet.hooks';
|
|
4
|
+
|
|
5
|
+
type AnyPointerEvent =
|
|
6
|
+
| React.MouseEvent
|
|
7
|
+
| React.TouchEvent
|
|
8
|
+
| globalThis.MouseEvent
|
|
9
|
+
| globalThis.TouchEvent;
|
|
10
|
+
|
|
11
|
+
/** Reads the pointer's y from either a mouse or a touch event. */
|
|
12
|
+
const clientYOf = (e: AnyPointerEvent) =>
|
|
13
|
+
'touches' in e ? (e.touches[0]?.clientY ?? 0) : e.clientY;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A drag continues wherever the pointer goes, so the move and end listeners
|
|
17
|
+
* belong on the document rather than the panel.
|
|
18
|
+
*/
|
|
19
|
+
const useDragListeners = (
|
|
20
|
+
isDragging: boolean,
|
|
21
|
+
onMove: (e: globalThis.MouseEvent | globalThis.TouchEvent) => void,
|
|
22
|
+
onEnd: (e: globalThis.MouseEvent | globalThis.TouchEvent) => void,
|
|
23
|
+
) => {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isDragging) return;
|
|
26
|
+
|
|
27
|
+
document.addEventListener('mousemove', onMove);
|
|
28
|
+
document.addEventListener('mouseup', onEnd);
|
|
29
|
+
document.addEventListener('touchmove', onMove, { passive: false });
|
|
30
|
+
document.addEventListener('touchend', onEnd);
|
|
31
|
+
|
|
32
|
+
return () => {
|
|
33
|
+
document.removeEventListener('mousemove', onMove);
|
|
34
|
+
document.removeEventListener('mouseup', onEnd);
|
|
35
|
+
document.removeEventListener('touchmove', onMove);
|
|
36
|
+
document.removeEventListener('touchend', onEnd);
|
|
37
|
+
};
|
|
38
|
+
}, [isDragging, onMove, onEnd]);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pointer velocity across a drag, in snap-point units per millisecond. Kept in
|
|
43
|
+
* refs rather than state: it is read once at drag end and must not re-render
|
|
44
|
+
* the panel on every pointer move.
|
|
45
|
+
*/
|
|
46
|
+
const useVelocityTracker = () => {
|
|
47
|
+
const velocityRef = useRef(0);
|
|
48
|
+
const lastYRef = useRef(0);
|
|
49
|
+
const lastTimeRef = useRef(0);
|
|
50
|
+
|
|
51
|
+
const begin = useCallback((clientY: number) => {
|
|
52
|
+
lastYRef.current = clientY;
|
|
53
|
+
lastTimeRef.current = globalThis.performance.now();
|
|
54
|
+
velocityRef.current = 0;
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const track = useCallback((clientY: number) => {
|
|
58
|
+
const now = globalThis.performance.now();
|
|
59
|
+
const deltaTime = now - lastTimeRef.current;
|
|
60
|
+
if (deltaTime > 0) {
|
|
61
|
+
velocityRef.current = (clientY - lastYRef.current) / deltaTime;
|
|
62
|
+
}
|
|
63
|
+
lastYRef.current = clientY;
|
|
64
|
+
lastTimeRef.current = now;
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const read = useCallback(() => velocityRef.current || 0, []);
|
|
68
|
+
|
|
69
|
+
return { begin, track, read };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The two conversions between pointer travel and snap points: how far a pointer
|
|
74
|
+
* at `clientY` has moved the sheet, and what happens past the end stops.
|
|
75
|
+
*/
|
|
76
|
+
const useDragMath = (input: SheetDragInput, dragStartY: number) => {
|
|
77
|
+
const { isBottomSheet, minSnapPoint, maxSnapPoint, dragResistance } = input;
|
|
78
|
+
|
|
79
|
+
/** Snap-point delta for a pointer at `clientY`, sign-corrected for the anchor. */
|
|
80
|
+
const snapDeltaFor = useCallback(
|
|
81
|
+
(clientY: number) =>
|
|
82
|
+
(isBottomSheet ? dragStartY - clientY : clientY - dragStartY) / window.innerHeight,
|
|
83
|
+
[isBottomSheet, dragStartY],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Past either end the sheet still moves, but only by a fraction of the drag,
|
|
88
|
+
* so the boundary is felt rather than hit.
|
|
89
|
+
*/
|
|
90
|
+
const withResistance = useCallback(
|
|
91
|
+
(snapPoint: number) => {
|
|
92
|
+
if (snapPoint < minSnapPoint) {
|
|
93
|
+
return minSnapPoint - (minSnapPoint - snapPoint) * dragResistance;
|
|
94
|
+
}
|
|
95
|
+
if (snapPoint > maxSnapPoint) {
|
|
96
|
+
return maxSnapPoint + (snapPoint - maxSnapPoint) * dragResistance;
|
|
97
|
+
}
|
|
98
|
+
return snapPoint;
|
|
99
|
+
},
|
|
100
|
+
[minSnapPoint, maxSnapPoint, dragResistance],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return { snapDeltaFor, withResistance };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface InFlightInput {
|
|
107
|
+
isDragging: boolean;
|
|
108
|
+
dragStartY: number;
|
|
109
|
+
stopDragging: () => void;
|
|
110
|
+
velocity: ReturnType<typeof useVelocityTracker>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The two handlers that run while a drag is live: one per pointer move, and one
|
|
115
|
+
* at release that hands the resting position to the geometry.
|
|
116
|
+
*/
|
|
117
|
+
const useDragInFlight = (
|
|
118
|
+
input: SheetDragInput,
|
|
119
|
+
geometry: SnapGeometry,
|
|
120
|
+
{ isDragging, dragStartY, stopDragging, velocity }: InFlightInput,
|
|
121
|
+
) => {
|
|
122
|
+
const { isVerticalSheet, isBottomSheet, onDragEnd } = input;
|
|
123
|
+
const { sheetRef, currentSnapPoint, updateSheetHeight, settleAt } = geometry;
|
|
124
|
+
const { snapDeltaFor, withResistance } = useDragMath(input, dragStartY);
|
|
125
|
+
|
|
126
|
+
const handleDragMove = useCallback(
|
|
127
|
+
(e: globalThis.MouseEvent | globalThis.TouchEvent) => {
|
|
128
|
+
if (!isDragging || !sheetRef.current || !isVerticalSheet) return;
|
|
129
|
+
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const clientY = clientYOf(e);
|
|
132
|
+
velocity.track(clientY);
|
|
133
|
+
updateSheetHeight(withResistance(currentSnapPoint + snapDeltaFor(clientY)));
|
|
134
|
+
},
|
|
135
|
+
[isDragging, sheetRef, isVerticalSheet, currentSnapPoint, snapDeltaFor, withResistance, updateSheetHeight, velocity],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const handleDragEnd = useCallback(
|
|
139
|
+
(e: globalThis.MouseEvent | globalThis.TouchEvent) => {
|
|
140
|
+
if (!isDragging || !isVerticalSheet) return;
|
|
141
|
+
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
stopDragging();
|
|
144
|
+
|
|
145
|
+
const position = currentSnapPoint + snapDeltaFor(clientYOf(e));
|
|
146
|
+
// A bottom sheet grows as the pointer moves up, so its velocity reads
|
|
147
|
+
// negative for the direction the panel is actually travelling.
|
|
148
|
+
const finalVelocity = isBottomSheet ? -velocity.read() : velocity.read();
|
|
149
|
+
|
|
150
|
+
const target = settleAt(position, finalVelocity);
|
|
151
|
+
if (target !== undefined) {
|
|
152
|
+
onDragEnd?.(target);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[isDragging, isVerticalSheet, isBottomSheet, currentSnapPoint, snapDeltaFor, settleAt, stopDragging, velocity, onDragEnd],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return { handleDragMove, handleDragEnd };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The pointer half of the draggable variant: tracks one drag from press to
|
|
163
|
+
* release, converts its travel into a snap-point delta, and hands the result to
|
|
164
|
+
* the geometry to settle.
|
|
165
|
+
*/
|
|
166
|
+
export const useDragGestures = (input: SheetDragInput, geometry: SnapGeometry) => {
|
|
167
|
+
const { isDraggableVariant, isVerticalSheet, onDragStart } = input;
|
|
168
|
+
const { isAnimating, cancelAnimation } = geometry;
|
|
169
|
+
|
|
170
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
171
|
+
const [dragStartY, setDragStartY] = useState(0);
|
|
172
|
+
const velocity = useVelocityTracker();
|
|
173
|
+
|
|
174
|
+
const handleDragStart = useCallback(
|
|
175
|
+
(e: React.MouseEvent | React.TouchEvent) => {
|
|
176
|
+
if (!isDraggableVariant || !isVerticalSheet || isAnimating) return;
|
|
177
|
+
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
|
|
181
|
+
const clientY = clientYOf(e);
|
|
182
|
+
setIsDragging(true);
|
|
183
|
+
setDragStartY(clientY);
|
|
184
|
+
velocity.begin(clientY);
|
|
185
|
+
|
|
186
|
+
cancelAnimation();
|
|
187
|
+
onDragStart?.();
|
|
188
|
+
},
|
|
189
|
+
[isDraggableVariant, isVerticalSheet, isAnimating, velocity, cancelAnimation, onDragStart],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const stopDragging = useCallback(() => setIsDragging(false), []);
|
|
193
|
+
const { handleDragMove, handleDragEnd } = useDragInFlight(input, geometry, {
|
|
194
|
+
isDragging,
|
|
195
|
+
dragStartY,
|
|
196
|
+
stopDragging,
|
|
197
|
+
velocity,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
useDragListeners(isDragging, handleDragMove, handleDragEnd);
|
|
201
|
+
|
|
202
|
+
return { isDragging, handleDragStart };
|
|
203
|
+
};
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { SPRING_CONFIG } from './Sheet.animations';
|
|
4
|
+
import { useDragGestures } from './Sheet.drag';
|
|
5
|
+
import type { SheetProps } from './Sheet.types';
|
|
6
|
+
|
|
7
|
+
export interface SheetDragInput {
|
|
8
|
+
isDraggableVariant: boolean;
|
|
9
|
+
isVerticalSheet: boolean;
|
|
10
|
+
isBottomSheet: boolean;
|
|
11
|
+
isTopSheet: boolean;
|
|
12
|
+
snapPoints: number[];
|
|
13
|
+
defaultSnapPoint: number;
|
|
14
|
+
minSnapPoint: number;
|
|
15
|
+
maxSnapPoint: number;
|
|
16
|
+
velocityThreshold: number;
|
|
17
|
+
dragResistance: number;
|
|
18
|
+
animationConfig: NonNullable<SheetProps['animationConfig']>;
|
|
19
|
+
onSnapPointChange?: (snapPoint: number) => void;
|
|
20
|
+
onDragStart?: () => void;
|
|
21
|
+
onDragEnd?: (snapPoint: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Which snap point a drag ending at `position` should land on.
|
|
26
|
+
*
|
|
27
|
+
* A flick past the velocity threshold advances one point in the direction of
|
|
28
|
+
* travel rather than landing on whichever is nearest — that is what makes a
|
|
29
|
+
* short fast drag feel like a throw. Anything slower simply snaps to the
|
|
30
|
+
* closest point.
|
|
31
|
+
*/
|
|
32
|
+
const closestSnapPoint = (
|
|
33
|
+
points: number[],
|
|
34
|
+
position: number,
|
|
35
|
+
from: number,
|
|
36
|
+
velocity: number,
|
|
37
|
+
velocityThreshold: number,
|
|
38
|
+
) => {
|
|
39
|
+
if (Math.abs(velocity) > velocityThreshold) {
|
|
40
|
+
const index = points.findIndex((point) => Math.abs(point - from) < 0.01);
|
|
41
|
+
|
|
42
|
+
if (velocity > 0 && index < points.length - 1) return points[index + 1];
|
|
43
|
+
if (velocity < 0 && index > 0) return points[index - 1];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return points.reduce(
|
|
47
|
+
(closest, point) =>
|
|
48
|
+
Math.abs(position - point) < Math.abs(position - closest) ? point : closest,
|
|
49
|
+
points[0] ?? from,
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Owns the panel element and the height writes that realise a snap point. */
|
|
54
|
+
const useSheetHeight = (input: Pick<SheetDragInput, 'isVerticalSheet' | 'isBottomSheet' | 'isTopSheet'>) => {
|
|
55
|
+
const { isVerticalSheet, isBottomSheet, isTopSheet } = input;
|
|
56
|
+
const [currentHeight, setCurrentHeight] = useState<number | null>(null);
|
|
57
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
|
|
59
|
+
const updateSheetHeight = useCallback(
|
|
60
|
+
(snapPoint: number) => {
|
|
61
|
+
if (!sheetRef.current || !isVerticalSheet) return;
|
|
62
|
+
|
|
63
|
+
const height = Math.round(window.innerHeight * snapPoint);
|
|
64
|
+
|
|
65
|
+
if (isBottomSheet) {
|
|
66
|
+
sheetRef.current.style.height = `${height}px`;
|
|
67
|
+
sheetRef.current.style.transform = 'translateY(0)';
|
|
68
|
+
} else if (isTopSheet) {
|
|
69
|
+
sheetRef.current.style.height = `${height}px`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
setCurrentHeight(height);
|
|
73
|
+
},
|
|
74
|
+
[isBottomSheet, isTopSheet, isVerticalSheet],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return { sheetRef, currentHeight, updateSheetHeight };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
interface SnapAnimationInput {
|
|
81
|
+
isVerticalSheet: boolean;
|
|
82
|
+
animationConfig: NonNullable<SheetProps['animationConfig']>;
|
|
83
|
+
updateSheetHeight: (snapPoint: number) => void;
|
|
84
|
+
onSettled: (snapPoint: number) => void;
|
|
85
|
+
enabled: () => boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The spring that carries the panel from one snap point to another. */
|
|
89
|
+
const useSnapAnimation = ({
|
|
90
|
+
isVerticalSheet,
|
|
91
|
+
animationConfig,
|
|
92
|
+
updateSheetHeight,
|
|
93
|
+
onSettled,
|
|
94
|
+
enabled,
|
|
95
|
+
}: SnapAnimationInput) => {
|
|
96
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
97
|
+
const animationFrameRef = useRef<number | undefined>(undefined);
|
|
98
|
+
|
|
99
|
+
const animateToSnapPoint = useCallback(
|
|
100
|
+
(from: number, to: number, velocity = 0) => {
|
|
101
|
+
if (!enabled() || !isVerticalSheet) return;
|
|
102
|
+
|
|
103
|
+
setIsAnimating(true);
|
|
104
|
+
const distance = to - from;
|
|
105
|
+
const startTime = globalThis.performance.now();
|
|
106
|
+
|
|
107
|
+
const { tension, friction } = { ...SPRING_CONFIG, ...animationConfig };
|
|
108
|
+
let currentVelocity = velocity * 1000; // px per second
|
|
109
|
+
|
|
110
|
+
const animate = () => {
|
|
111
|
+
const elapsed = (globalThis.performance.now() - startTime) / 1000;
|
|
112
|
+
|
|
113
|
+
const springForce = -tension * (from - to);
|
|
114
|
+
const dampingForce = -friction * currentVelocity;
|
|
115
|
+
currentVelocity += (springForce + dampingForce) * 0.016; // 60fps timestep
|
|
116
|
+
|
|
117
|
+
const progress = Math.min(1, elapsed * 4);
|
|
118
|
+
updateSheetHeight(from + distance * progress);
|
|
119
|
+
|
|
120
|
+
if (progress < 1) {
|
|
121
|
+
animationFrameRef.current = window.requestAnimationFrame(animate);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
setIsAnimating(false);
|
|
126
|
+
onSettled(to);
|
|
127
|
+
updateSheetHeight(to);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
animate();
|
|
131
|
+
},
|
|
132
|
+
[isVerticalSheet, animationConfig, updateSheetHeight, onSettled, enabled],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const cancelAnimation = useCallback(() => {
|
|
136
|
+
if (animationFrameRef.current) {
|
|
137
|
+
window.cancelAnimationFrame(animationFrameRef.current);
|
|
138
|
+
}
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
return { isAnimating, animateToSnapPoint, cancelAnimation };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Where the sheet rests and how it gets there. Knows nothing about pointers —
|
|
146
|
+
* `useDragGestures` supplies the positions.
|
|
147
|
+
*/
|
|
148
|
+
const useSnapGeometry = (input: SheetDragInput) => {
|
|
149
|
+
const {
|
|
150
|
+
isVerticalSheet,
|
|
151
|
+
snapPoints,
|
|
152
|
+
defaultSnapPoint,
|
|
153
|
+
minSnapPoint,
|
|
154
|
+
maxSnapPoint,
|
|
155
|
+
velocityThreshold,
|
|
156
|
+
animationConfig,
|
|
157
|
+
onSnapPointChange,
|
|
158
|
+
} = input;
|
|
159
|
+
|
|
160
|
+
const [currentSnapPoint, setCurrentSnapPoint] = useState(defaultSnapPoint);
|
|
161
|
+
const { sheetRef, currentHeight, updateSheetHeight } = useSheetHeight(input);
|
|
162
|
+
|
|
163
|
+
const sortedSnapPoints = useMemo(
|
|
164
|
+
() =>
|
|
165
|
+
[...snapPoints]
|
|
166
|
+
.sort((a, b) => a - b)
|
|
167
|
+
.filter((point) => point >= minSnapPoint && point <= maxSnapPoint),
|
|
168
|
+
[snapPoints, minSnapPoint, maxSnapPoint],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const onSettled = useCallback(
|
|
172
|
+
(snapPoint: number) => {
|
|
173
|
+
setCurrentSnapPoint(snapPoint);
|
|
174
|
+
onSnapPointChange?.(snapPoint);
|
|
175
|
+
},
|
|
176
|
+
[onSnapPointChange],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const hasPanel = useCallback(() => Boolean(sheetRef.current), [sheetRef]);
|
|
180
|
+
|
|
181
|
+
const { isAnimating, animateToSnapPoint, cancelAnimation } = useSnapAnimation({
|
|
182
|
+
isVerticalSheet,
|
|
183
|
+
animationConfig,
|
|
184
|
+
updateSheetHeight,
|
|
185
|
+
onSettled,
|
|
186
|
+
enabled: hasPanel,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const settleAt = useCallback(
|
|
190
|
+
(position: number, velocity: number) => {
|
|
191
|
+
const target = closestSnapPoint(
|
|
192
|
+
sortedSnapPoints,
|
|
193
|
+
position,
|
|
194
|
+
currentSnapPoint,
|
|
195
|
+
velocity,
|
|
196
|
+
velocityThreshold,
|
|
197
|
+
);
|
|
198
|
+
if (target === undefined) return undefined;
|
|
199
|
+
|
|
200
|
+
animateToSnapPoint(currentSnapPoint, target, velocity);
|
|
201
|
+
return target;
|
|
202
|
+
},
|
|
203
|
+
[sortedSnapPoints, currentSnapPoint, velocityThreshold, animateToSnapPoint],
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
/** Re-seat the panel at its default snap point each time it opens. */
|
|
207
|
+
const resetToDefaultSnapPoint = useCallback(() => {
|
|
208
|
+
setCurrentSnapPoint(defaultSnapPoint);
|
|
209
|
+
updateSheetHeight(defaultSnapPoint);
|
|
210
|
+
}, [defaultSnapPoint, updateSheetHeight]);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
sheetRef,
|
|
214
|
+
currentSnapPoint,
|
|
215
|
+
currentHeight,
|
|
216
|
+
isAnimating,
|
|
217
|
+
updateSheetHeight,
|
|
218
|
+
settleAt,
|
|
219
|
+
resetToDefaultSnapPoint,
|
|
220
|
+
cancelAnimation,
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export type SnapGeometry = ReturnType<typeof useSnapGeometry>;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The draggable variant's whole state machine. It lives apart from the component
|
|
228
|
+
* because none of it is about what the sheet renders — the component only needs
|
|
229
|
+
* the ref to attach, the current height to size with, and the two flags that
|
|
230
|
+
* suppress the CSS transition mid-drag.
|
|
231
|
+
*/
|
|
232
|
+
export const useSheetDrag = (input: SheetDragInput) => {
|
|
233
|
+
const geometry = useSnapGeometry(input);
|
|
234
|
+
const { isDragging, handleDragStart } = useDragGestures(input, geometry);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
sheetRef: geometry.sheetRef,
|
|
238
|
+
currentHeight: geometry.currentHeight,
|
|
239
|
+
isAnimating: geometry.isAnimating,
|
|
240
|
+
resetToDefaultSnapPoint: geometry.resetToDefaultSnapPoint,
|
|
241
|
+
isDragging,
|
|
242
|
+
handleDragStart,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
interface SheetOpenInput {
|
|
247
|
+
open: boolean;
|
|
248
|
+
persistent: boolean;
|
|
249
|
+
disabled: boolean;
|
|
250
|
+
closeOnEscape: boolean;
|
|
251
|
+
closeOnOverlayClick: boolean;
|
|
252
|
+
isDraggableVariant: boolean;
|
|
253
|
+
isVerticalSheet: boolean;
|
|
254
|
+
resetToDefaultSnapPoint: () => void;
|
|
255
|
+
onOpenChange?: (open: boolean) => void;
|
|
256
|
+
onClose?: () => void;
|
|
257
|
+
onOpen?: () => void;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Open/closed state and the three ways it changes: the `open` prop, the Escape
|
|
262
|
+
* key, and a click on the overlay. `persistent` and `disabled` block every
|
|
263
|
+
* close path, so they are checked once here rather than at each caller.
|
|
264
|
+
*/
|
|
265
|
+
export const useSheetOpen = (input: SheetOpenInput) => {
|
|
266
|
+
const {
|
|
267
|
+
open, persistent, disabled, closeOnEscape, closeOnOverlayClick,
|
|
268
|
+
isDraggableVariant, isVerticalSheet, resetToDefaultSnapPoint,
|
|
269
|
+
onOpenChange, onClose, onOpen,
|
|
270
|
+
} = input;
|
|
271
|
+
|
|
272
|
+
const [isOpen, setIsOpen] = useState(open);
|
|
273
|
+
|
|
274
|
+
const handleClose = useCallback(() => {
|
|
275
|
+
if (!persistent && !disabled) {
|
|
276
|
+
setIsOpen(false);
|
|
277
|
+
onOpenChange?.(false);
|
|
278
|
+
onClose?.();
|
|
279
|
+
}
|
|
280
|
+
}, [persistent, disabled, onOpenChange, onClose]);
|
|
281
|
+
|
|
282
|
+
const handleOverlayClick = useCallback(() => {
|
|
283
|
+
if (closeOnOverlayClick && !persistent) {
|
|
284
|
+
handleClose();
|
|
285
|
+
}
|
|
286
|
+
}, [closeOnOverlayClick, persistent, handleClose]);
|
|
287
|
+
|
|
288
|
+
const handleSwipeOpen = useCallback(() => {
|
|
289
|
+
setIsOpen(true);
|
|
290
|
+
onOpenChange?.(true);
|
|
291
|
+
onOpen?.();
|
|
292
|
+
}, [onOpenChange, onOpen]);
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
setIsOpen(open);
|
|
296
|
+
if (open) {
|
|
297
|
+
onOpen?.();
|
|
298
|
+
if (isDraggableVariant && isVerticalSheet) {
|
|
299
|
+
resetToDefaultSnapPoint();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}, [open, onOpen, isDraggableVariant, isVerticalSheet, resetToDefaultSnapPoint]);
|
|
303
|
+
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (!isOpen) return;
|
|
306
|
+
|
|
307
|
+
const handleEscape = (e: globalThis.KeyboardEvent) => {
|
|
308
|
+
if (closeOnEscape && e.key === 'Escape') {
|
|
309
|
+
handleClose();
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
document.addEventListener('keydown', handleEscape);
|
|
314
|
+
return () => document.removeEventListener('keydown', handleEscape);
|
|
315
|
+
}, [isOpen, closeOnEscape, handleClose]);
|
|
316
|
+
|
|
317
|
+
return { isOpen, handleClose, handleOverlayClick, handleSwipeOpen };
|
|
318
|
+
};
|