@patternmode/stacksheet 1.3.3
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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/Drag/DragConstants.d.ts +9 -0
- package/dist/Drag/DragConstants.d.ts.map +1 -0
- package/dist/Drag/DragGeometry.d.ts +34 -0
- package/dist/Drag/DragGeometry.d.ts.map +1 -0
- package/dist/Drag/DragTypes.d.ts +34 -0
- package/dist/Drag/DragTypes.d.ts.map +1 -0
- package/dist/Drag/index.d.ts +3 -0
- package/dist/Drag/index.d.ts.map +1 -0
- package/dist/Drag/useDrag.d.ts +20 -0
- package/dist/Drag/useDrag.d.ts.map +1 -0
- package/dist/SheetPanel/DefaultHeader.d.ts +5 -0
- package/dist/SheetPanel/DefaultHeader.d.ts.map +1 -0
- package/dist/SheetPanel/SheetPanel.d.ts +3 -0
- package/dist/SheetPanel/SheetPanel.d.ts.map +1 -0
- package/dist/SheetPanel/SheetPanelContent.d.ts +13 -0
- package/dist/SheetPanel/SheetPanelContent.d.ts.map +1 -0
- package/dist/SheetPanel/SheetPanelFocus.d.ts +8 -0
- package/dist/SheetPanel/SheetPanelFocus.d.ts.map +1 -0
- package/dist/SheetPanel/SheetPanelHandles.d.ts +10 -0
- package/dist/SheetPanel/SheetPanelHandles.d.ts.map +1 -0
- package/dist/SheetPanel/SheetPanelTypes.d.ts +31 -0
- package/dist/SheetPanel/SheetPanelTypes.d.ts.map +1 -0
- package/dist/SheetPanel/index.d.ts +3 -0
- package/dist/SheetPanel/index.d.ts.map +1 -0
- package/dist/SheetParts/SheetBack.d.ts +3 -0
- package/dist/SheetParts/SheetBack.d.ts.map +1 -0
- package/dist/SheetParts/SheetBody.d.ts +3 -0
- package/dist/SheetParts/SheetBody.d.ts.map +1 -0
- package/dist/SheetParts/SheetClose.d.ts +3 -0
- package/dist/SheetParts/SheetClose.d.ts.map +1 -0
- package/dist/SheetParts/SheetDescription.d.ts +3 -0
- package/dist/SheetParts/SheetDescription.d.ts.map +1 -0
- package/dist/SheetParts/SheetFooter.d.ts +3 -0
- package/dist/SheetParts/SheetFooter.d.ts.map +1 -0
- package/dist/SheetParts/SheetHandle.d.ts +3 -0
- package/dist/SheetParts/SheetHandle.d.ts.map +1 -0
- package/dist/SheetParts/SheetHeader.d.ts +3 -0
- package/dist/SheetParts/SheetHeader.d.ts.map +1 -0
- package/dist/SheetParts/SheetPartTypes.d.ts +22 -0
- package/dist/SheetParts/SheetPartTypes.d.ts.map +1 -0
- package/dist/SheetParts/SheetParts.d.ts +28 -0
- package/dist/SheetParts/SheetParts.d.ts.map +1 -0
- package/dist/SheetParts/SheetTitle.d.ts +3 -0
- package/dist/SheetParts/SheetTitle.d.ts.map +1 -0
- package/dist/SheetParts/index.d.ts +11 -0
- package/dist/SheetParts/index.d.ts.map +1 -0
- package/dist/Store/StoreArgs.d.ts +15 -0
- package/dist/Store/StoreArgs.d.ts.map +1 -0
- package/dist/Store/StoreTypes.d.ts +21 -0
- package/dist/Store/StoreTypes.d.ts.map +1 -0
- package/dist/Store/createSheetStore.d.ts +15 -0
- package/dist/Store/createSheetStore.d.ts.map +1 -0
- package/dist/Store/index.d.ts +3 -0
- package/dist/Store/index.d.ts.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/create.d.ts +33 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/icons.d.ts +4 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1732 -0
- package/dist/index.mjs.map +1 -0
- package/dist/media.d.ts +9 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/panel-context.d.ts +26 -0
- package/dist/panel-context.d.ts.map +1 -0
- package/dist/parts.d.ts +11 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/renderer-effects.d.ts +6 -0
- package/dist/renderer-effects.d.ts.map +1 -0
- package/dist/renderer-helpers.d.ts +53 -0
- package/dist/renderer-helpers.d.ts.map +1 -0
- package/dist/renderer.d.ts +29 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/sheet-panel.d.ts +3 -0
- package/dist/sheet-panel.d.ts.map +1 -0
- package/dist/snap-points.d.ts +22 -0
- package/dist/snap-points.d.ts.map +1 -0
- package/dist/springs.d.ts +26 -0
- package/dist/springs.d.ts.map +1 -0
- package/dist/stacking.d.ts +38 -0
- package/dist/stacking.d.ts.map +1 -0
- package/dist/store.d.ts +3 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/types.d.ts +232 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/use-drag.d.ts +3 -0
- package/dist/use-drag.d.ts.map +1 -0
- package/package.json +90 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Portal } from "@radix-ui/react-portal";
|
|
3
|
+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createStore, useStore } from "zustand";
|
|
5
|
+
import { useShallow } from "zustand/react/shallow";
|
|
6
|
+
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
7
|
+
import { RemoveScroll } from "react-remove-scroll";
|
|
8
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
import { FocusTrap } from "focus-trap-react";
|
|
10
|
+
import { joinClassNames } from "@patternmode/system";
|
|
11
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
12
|
+
import { Root, Scrollbar, Thumb, Viewport } from "@radix-ui/react-scroll-area";
|
|
13
|
+
//#region src/springs.ts
|
|
14
|
+
/**
|
|
15
|
+
* Spring presets inspired by iOS animation feel.
|
|
16
|
+
*
|
|
17
|
+
* - `subtle` — Barely noticeable bounce, professional.
|
|
18
|
+
* - `snappy` — Quick, responsive for interactions.
|
|
19
|
+
* - `stiff` — Very quick, controlled. Panels, drawers. **(default)**
|
|
20
|
+
*/
|
|
21
|
+
const springs = {
|
|
22
|
+
subtle: {
|
|
23
|
+
stiffness: 300,
|
|
24
|
+
damping: 30,
|
|
25
|
+
mass: 1
|
|
26
|
+
},
|
|
27
|
+
snappy: {
|
|
28
|
+
stiffness: 400,
|
|
29
|
+
damping: 28,
|
|
30
|
+
mass: .8
|
|
31
|
+
},
|
|
32
|
+
stiff: {
|
|
33
|
+
stiffness: 400,
|
|
34
|
+
damping: 40,
|
|
35
|
+
mass: 1
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/config.ts
|
|
40
|
+
const DEFAULT_STACKING = {
|
|
41
|
+
scaleStep: .04,
|
|
42
|
+
offsetStep: 16,
|
|
43
|
+
opacityStep: 0,
|
|
44
|
+
radius: 12,
|
|
45
|
+
renderThreshold: 3
|
|
46
|
+
};
|
|
47
|
+
const DEFAULT_SIDE = {
|
|
48
|
+
desktop: "right",
|
|
49
|
+
mobile: "bottom"
|
|
50
|
+
};
|
|
51
|
+
/** Normalize a string side to a responsive object, merging with defaults. */
|
|
52
|
+
function resolveSide(side) {
|
|
53
|
+
if (typeof side === "string") return {
|
|
54
|
+
desktop: side,
|
|
55
|
+
mobile: side
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
...DEFAULT_SIDE,
|
|
59
|
+
...side
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Resolve a preset name to a SpringConfig, or merge partial config with defaults. */
|
|
63
|
+
function resolveSpring(spring) {
|
|
64
|
+
if (typeof spring === "string") return springs[spring];
|
|
65
|
+
return {
|
|
66
|
+
...springs.stiff,
|
|
67
|
+
...spring
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Merge user-provided config with defaults. Resolves union types (side, spring) to concrete values. */
|
|
71
|
+
function resolveConfig(config = {}) {
|
|
72
|
+
return {
|
|
73
|
+
maxDepth: config.maxDepth ?? Number.POSITIVE_INFINITY,
|
|
74
|
+
closeOnEscape: config.closeOnEscape ?? true,
|
|
75
|
+
closeOnBackdrop: config.closeOnBackdrop ?? true,
|
|
76
|
+
showOverlay: config.showOverlay ?? true,
|
|
77
|
+
lockScroll: config.lockScroll ?? true,
|
|
78
|
+
width: config.width ?? 420,
|
|
79
|
+
maxWidth: config.maxWidth ?? "90vw",
|
|
80
|
+
breakpoint: config.breakpoint ?? 768,
|
|
81
|
+
side: resolveSide(config.side),
|
|
82
|
+
stacking: {
|
|
83
|
+
...DEFAULT_STACKING,
|
|
84
|
+
...config.stacking
|
|
85
|
+
},
|
|
86
|
+
spring: resolveSpring(config.spring),
|
|
87
|
+
zIndex: config.zIndex ?? 100,
|
|
88
|
+
ariaLabel: config.ariaLabel ?? "Sheet dialog",
|
|
89
|
+
onOpenComplete: config.onOpenComplete,
|
|
90
|
+
onCloseComplete: config.onCloseComplete,
|
|
91
|
+
snapPoints: config.snapPoints ?? [],
|
|
92
|
+
snapPointIndex: config.snapPointIndex,
|
|
93
|
+
onSnapPointChange: config.onSnapPointChange,
|
|
94
|
+
snapToSequentialPoints: config.snapToSequentialPoints ?? false,
|
|
95
|
+
drag: config.drag ?? true,
|
|
96
|
+
closeThreshold: config.closeThreshold ?? .25,
|
|
97
|
+
velocityThreshold: config.velocityThreshold ?? .5,
|
|
98
|
+
dismissible: config.dismissible ?? true,
|
|
99
|
+
modal: config.modal ?? true,
|
|
100
|
+
shouldScaleBackground: config.shouldScaleBackground ?? false,
|
|
101
|
+
scaleBackgroundAmount: config.scaleBackgroundAmount ?? .97
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/media.ts
|
|
106
|
+
/**
|
|
107
|
+
* Returns true when viewport width is at or below the breakpoint.
|
|
108
|
+
* SSR-safe: defaults to false (desktop).
|
|
109
|
+
*/
|
|
110
|
+
function useIsMobile(breakpoint) {
|
|
111
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
|
|
114
|
+
setIsMobile(mql.matches);
|
|
115
|
+
const handler = (e) => setIsMobile(e.matches);
|
|
116
|
+
mql.addEventListener("change", handler);
|
|
117
|
+
return () => mql.removeEventListener("change", handler);
|
|
118
|
+
}, [breakpoint]);
|
|
119
|
+
return isMobile;
|
|
120
|
+
}
|
|
121
|
+
/** Resolve the current side from config + viewport. */
|
|
122
|
+
function useResolvedSide(config) {
|
|
123
|
+
return useIsMobile(config.breakpoint) ? config.side.mobile : config.side.desktop;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/renderer-effects.ts
|
|
127
|
+
function usePanelHeight(panelRef, hasSnapPoints) {
|
|
128
|
+
const [height, setHeight] = useState(0);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const el = panelRef.current;
|
|
131
|
+
if (!(el && hasSnapPoints)) return;
|
|
132
|
+
setHeight(el.offsetHeight);
|
|
133
|
+
const observer = new ResizeObserver(([entry]) => {
|
|
134
|
+
if (entry) setHeight(entry.contentRect.height);
|
|
135
|
+
});
|
|
136
|
+
observer.observe(el);
|
|
137
|
+
return () => observer.disconnect();
|
|
138
|
+
}, [panelRef, hasSnapPoints]);
|
|
139
|
+
return height;
|
|
140
|
+
}
|
|
141
|
+
function useViewportHeight(active) {
|
|
142
|
+
const getHeight = useCallback(() => typeof window === "undefined" ? 0 : window.visualViewport?.height ?? window.innerHeight, []);
|
|
143
|
+
const [height, setHeight] = useState(() => getHeight());
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!active || typeof window === "undefined") return;
|
|
146
|
+
const update = () => setHeight(getHeight());
|
|
147
|
+
update();
|
|
148
|
+
window.addEventListener("resize", update);
|
|
149
|
+
window.visualViewport?.addEventListener("resize", update);
|
|
150
|
+
return () => {
|
|
151
|
+
window.removeEventListener("resize", update);
|
|
152
|
+
window.visualViewport?.removeEventListener("resize", update);
|
|
153
|
+
};
|
|
154
|
+
}, [active, getHeight]);
|
|
155
|
+
return height;
|
|
156
|
+
}
|
|
157
|
+
function useBodyScale(config, isOpen, prefersReducedMotion) {
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!config.shouldScaleBackground || prefersReducedMotion) return;
|
|
160
|
+
const wrapper = document.querySelector("[data-stacksheet-wrapper]");
|
|
161
|
+
if (!(wrapper && wrapper instanceof HTMLElement)) return;
|
|
162
|
+
if (isOpen) {
|
|
163
|
+
const scale = config.scaleBackgroundAmount;
|
|
164
|
+
wrapper.style.transition = "transform 500ms cubic-bezier(0.32, 0.72, 0, 1), border-radius 500ms cubic-bezier(0.32, 0.72, 0, 1)";
|
|
165
|
+
wrapper.style.transform = `scale(${scale})`;
|
|
166
|
+
wrapper.style.borderRadius = "8px";
|
|
167
|
+
wrapper.style.overflow = "hidden";
|
|
168
|
+
wrapper.style.transformOrigin = "center top";
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
wrapper.style.transform = "";
|
|
172
|
+
wrapper.style.borderRadius = "";
|
|
173
|
+
const handleEnd = () => {
|
|
174
|
+
wrapper.style.transition = "";
|
|
175
|
+
wrapper.style.overflow = "";
|
|
176
|
+
wrapper.style.transformOrigin = "";
|
|
177
|
+
};
|
|
178
|
+
wrapper.addEventListener("transitionend", handleEnd, { once: true });
|
|
179
|
+
return () => wrapper.removeEventListener("transitionend", handleEnd);
|
|
180
|
+
}, [
|
|
181
|
+
isOpen,
|
|
182
|
+
config.shouldScaleBackground,
|
|
183
|
+
config.scaleBackgroundAmount,
|
|
184
|
+
prefersReducedMotion
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/snap-points.ts
|
|
189
|
+
const SNAP_POINT_RE = /^(\d+(?:\.\d+)?)(px|rem|em|vh|%)$/;
|
|
190
|
+
/**
|
|
191
|
+
* Resolve a snap point value to pixels given the viewport height.
|
|
192
|
+
* - number 0-1: fraction of viewport (0.5 → 50% of vh)
|
|
193
|
+
* - number > 1: pixel value (300 → 300px)
|
|
194
|
+
* - string: parsed via regex for CSS unit support
|
|
195
|
+
*/
|
|
196
|
+
function resolveSnapPointPx(point, viewportHeight) {
|
|
197
|
+
if (typeof point === "number") return point <= 1 ? point * viewportHeight : point;
|
|
198
|
+
if (typeof point === "string") {
|
|
199
|
+
const match = point.match(SNAP_POINT_RE);
|
|
200
|
+
if (!match?.[1]) return 0;
|
|
201
|
+
const value = Number.parseFloat(match[1]);
|
|
202
|
+
switch (match[2]) {
|
|
203
|
+
case "px": return value;
|
|
204
|
+
case "rem":
|
|
205
|
+
case "em": return value * (typeof document === "undefined" ? 16 : Number.parseFloat(getComputedStyle(document.documentElement).fontSize));
|
|
206
|
+
case "vh":
|
|
207
|
+
case "%": return value / 100 * viewportHeight;
|
|
208
|
+
default: return 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Resolve all snap points to sorted pixel heights (ascending).
|
|
215
|
+
* Returns an array of heights in px that the drawer should snap to.
|
|
216
|
+
*/
|
|
217
|
+
function resolveSnapPoints(points, viewportHeight) {
|
|
218
|
+
if (points.length === 0) return [];
|
|
219
|
+
const resolved = points.map((p) => resolveSnapPointPx(p, viewportHeight)).filter((px) => px > 0);
|
|
220
|
+
resolved.sort((a, b) => a - b);
|
|
221
|
+
const deduped = [];
|
|
222
|
+
for (const px of resolved) {
|
|
223
|
+
const last = deduped.at(-1);
|
|
224
|
+
if (last === void 0 || Math.abs(px - last) > 1) deduped.push(px);
|
|
225
|
+
}
|
|
226
|
+
return deduped;
|
|
227
|
+
}
|
|
228
|
+
/** Velocity threshold (px/ms) for skipping intermediate snap points */
|
|
229
|
+
const SNAP_VELOCITY_THRESHOLD = .4;
|
|
230
|
+
/** Max velocity to consider for snap offset calculation */
|
|
231
|
+
const MAX_SNAP_VELOCITY = 2;
|
|
232
|
+
/** Multiplier to convert velocity to pixel offset for snap target */
|
|
233
|
+
const SNAP_VELOCITY_MULTIPLIER = 150;
|
|
234
|
+
/**
|
|
235
|
+
* Given the current drag offset (from fully open), resolved snap heights,
|
|
236
|
+
* the panel height, and release velocity, find the best snap point index.
|
|
237
|
+
*
|
|
238
|
+
* `dragOffset` is positive in the dismiss direction (downward for bottom sheets).
|
|
239
|
+
* Snap heights are "how tall the drawer should be" (ascending order).
|
|
240
|
+
*
|
|
241
|
+
* Returns -1 if the gesture indicates full dismissal.
|
|
242
|
+
*/
|
|
243
|
+
function findSnapTarget(dragOffset, panelHeight, snapHeights, velocity, currentIndex, sequential) {
|
|
244
|
+
if (snapHeights.length === 0) return -1;
|
|
245
|
+
const snapOffsets = snapHeights.map((h) => panelHeight - h);
|
|
246
|
+
const currentPos = dragOffset;
|
|
247
|
+
if (sequential) {
|
|
248
|
+
const nextIndex = currentIndex - (velocity > 0 ? 1 : -1);
|
|
249
|
+
if (nextIndex < 0) return -1;
|
|
250
|
+
if (nextIndex >= snapHeights.length) return snapHeights.length - 1;
|
|
251
|
+
return nextIndex;
|
|
252
|
+
}
|
|
253
|
+
const projectedPos = currentPos + (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD ? Math.min(Math.max(velocity, -2), MAX_SNAP_VELOCITY) * SNAP_VELOCITY_MULTIPLIER : 0);
|
|
254
|
+
const first = snapOffsets[0] ?? 0;
|
|
255
|
+
let bestIndex = 0;
|
|
256
|
+
let bestDist = Math.abs(projectedPos - first);
|
|
257
|
+
for (let i = 1; i < snapOffsets.length; i++) {
|
|
258
|
+
const offset = snapOffsets[i] ?? 0;
|
|
259
|
+
const dist = Math.abs(projectedPos - offset);
|
|
260
|
+
if (dist < bestDist) {
|
|
261
|
+
bestDist = dist;
|
|
262
|
+
bestIndex = i;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (Math.abs(projectedPos - panelHeight) <= bestDist) return -1;
|
|
266
|
+
return bestIndex;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Get the Y offset for a snap point relative to fully open (0 offset).
|
|
270
|
+
* Returns the number of pixels the drawer should be translated down from fully open.
|
|
271
|
+
*/
|
|
272
|
+
function getSnapOffset(snapIndex, snapHeights, panelHeight) {
|
|
273
|
+
if (snapIndex < 0 || snapIndex >= snapHeights.length) return 0;
|
|
274
|
+
return panelHeight - (snapHeights[snapIndex] ?? 0);
|
|
275
|
+
}
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/renderer-helpers.ts
|
|
278
|
+
const EMPTY_CLASSNAMES = {
|
|
279
|
+
backdrop: "",
|
|
280
|
+
panel: "",
|
|
281
|
+
header: ""
|
|
282
|
+
};
|
|
283
|
+
function resolveClassNames(cn) {
|
|
284
|
+
if (!cn) return EMPTY_CLASSNAMES;
|
|
285
|
+
return {
|
|
286
|
+
backdrop: cn.backdrop ?? "",
|
|
287
|
+
panel: cn.panel ?? "",
|
|
288
|
+
header: cn.header ?? ""
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function buildAriaProps(isTop, isModal, isComposable, ariaLabel, panelId, hasDescription) {
|
|
292
|
+
if (!isTop) return {};
|
|
293
|
+
const props = { role: "dialog" };
|
|
294
|
+
if (isModal) props["aria-modal"] = "true";
|
|
295
|
+
if (isComposable) {
|
|
296
|
+
props["aria-labelledby"] = `${panelId}-title`;
|
|
297
|
+
if (hasDescription) props["aria-describedby"] = `${panelId}-desc`;
|
|
298
|
+
} else props["aria-label"] = ariaLabel;
|
|
299
|
+
return props;
|
|
300
|
+
}
|
|
301
|
+
function getDragTransform(side, offset) {
|
|
302
|
+
if (offset === 0) return {};
|
|
303
|
+
switch (side) {
|
|
304
|
+
case "right": return { x: offset };
|
|
305
|
+
case "left": return { x: -offset };
|
|
306
|
+
case "bottom": return { y: offset };
|
|
307
|
+
default: return {};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const VISUAL_TWEEN = {
|
|
311
|
+
type: "tween",
|
|
312
|
+
duration: .25,
|
|
313
|
+
ease: "easeOut"
|
|
314
|
+
};
|
|
315
|
+
function buildPanelStyle(panelStyles, isTop, hasPanelClass, isDragging) {
|
|
316
|
+
return {
|
|
317
|
+
...panelStyles,
|
|
318
|
+
pointerEvents: isTop ? "auto" : "none",
|
|
319
|
+
...isTop ? {} : { contain: "layout style paint" },
|
|
320
|
+
...isDragging ? { transition: "none" } : {},
|
|
321
|
+
...hasPanelClass ? {} : {
|
|
322
|
+
background: "var(--background, #fff)",
|
|
323
|
+
borderColor: "var(--border, transparent)"
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function buildPanelTransition(isDragging, isTop, spring, stackSpring) {
|
|
328
|
+
if (isDragging) return {
|
|
329
|
+
type: "tween",
|
|
330
|
+
duration: 0
|
|
331
|
+
};
|
|
332
|
+
return {
|
|
333
|
+
...isTop ? spring : stackSpring,
|
|
334
|
+
borderRadius: VISUAL_TWEEN,
|
|
335
|
+
boxShadow: VISUAL_TWEEN
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function computeSnapYOffset(side, snapHeights, activeSnapIndex, measuredHeight) {
|
|
339
|
+
if (side !== "bottom" || snapHeights.length === 0 || measuredHeight <= 0) return 0;
|
|
340
|
+
return getSnapOffset(activeSnapIndex, snapHeights, measuredHeight);
|
|
341
|
+
}
|
|
342
|
+
function getBottomSlideDistance(measuredHeight) {
|
|
343
|
+
if (measuredHeight > 0) return measuredHeight;
|
|
344
|
+
if (typeof window !== "undefined") return window.innerHeight;
|
|
345
|
+
return 1e3;
|
|
346
|
+
}
|
|
347
|
+
function resolveSlideFrom(side, slideFrom, measuredHeight) {
|
|
348
|
+
if (side !== "bottom") return slideFrom;
|
|
349
|
+
return { y: getBottomSlideDistance(measuredHeight) };
|
|
350
|
+
}
|
|
351
|
+
function buildAnimateTarget(slideTarget, stackOffset, dragOffset, transform, animatedRadius, transition, snapYOffset, isTop) {
|
|
352
|
+
const base = {
|
|
353
|
+
...slideTarget,
|
|
354
|
+
...stackOffset,
|
|
355
|
+
...dragOffset,
|
|
356
|
+
scale: transform.scale,
|
|
357
|
+
opacity: transform.opacity,
|
|
358
|
+
...animatedRadius,
|
|
359
|
+
boxShadow: getShadow(!isTop),
|
|
360
|
+
transition
|
|
361
|
+
};
|
|
362
|
+
if (snapYOffset > 0) return {
|
|
363
|
+
...base,
|
|
364
|
+
y: (dragOffset.y ?? 0) + snapYOffset
|
|
365
|
+
};
|
|
366
|
+
return base;
|
|
367
|
+
}
|
|
368
|
+
function getInitialRadius(side) {
|
|
369
|
+
if (side === "bottom") return {
|
|
370
|
+
borderTopLeftRadius: 0,
|
|
371
|
+
borderTopRightRadius: 0,
|
|
372
|
+
borderBottomLeftRadius: 0,
|
|
373
|
+
borderBottomRightRadius: 0
|
|
374
|
+
};
|
|
375
|
+
return { borderRadius: 0 };
|
|
376
|
+
}
|
|
377
|
+
const SHADOW_SM = "0px 1px 3px 0px rgba(0,0,0,0.06), 0px 6px 12px 0px rgba(0,0,0,0.06)";
|
|
378
|
+
const SHADOW_LG = "0px 8px 24px 0px rgba(0,0,0,0.06), 0px 24px 48px 0px rgba(0,0,0,0.04), 0px 48px 96px 0px rgba(0,0,0,0.03)";
|
|
379
|
+
function getShadow(isNested) {
|
|
380
|
+
return isNested ? SHADOW_SM : SHADOW_LG;
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/Drag/DragConstants.ts
|
|
384
|
+
/** Elements that should never initiate a drag */
|
|
385
|
+
const INTERACTIVE_TAGS = new Set([
|
|
386
|
+
"INPUT",
|
|
387
|
+
"TEXTAREA",
|
|
388
|
+
"SELECT",
|
|
389
|
+
"BUTTON",
|
|
390
|
+
"A"
|
|
391
|
+
]);
|
|
392
|
+
/** Rubber-band resistance factor for dragging past resting position */
|
|
393
|
+
const RUBBER_BAND_FACTOR = .6;
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/Drag/DragGeometry.ts
|
|
396
|
+
function isInteractiveElement(el) {
|
|
397
|
+
if (INTERACTIVE_TAGS.has(el.tagName)) return true;
|
|
398
|
+
if (el.isContentEditable) return true;
|
|
399
|
+
if (el.closest("button, a, input, textarea, select, [contenteditable]")) return true;
|
|
400
|
+
if (el.closest("[data-stacksheet-no-drag]")) return true;
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Walk up from `el` to find the nearest scrollable ancestor.
|
|
405
|
+
* Returns null if nothing is scrollable in the dismiss axis.
|
|
406
|
+
*/
|
|
407
|
+
function findScrollableAncestor(el, axis) {
|
|
408
|
+
let current = el;
|
|
409
|
+
while (current) {
|
|
410
|
+
if (current instanceof HTMLElement) {
|
|
411
|
+
const style = getComputedStyle(current);
|
|
412
|
+
const overflow = axis === "y" ? style.overflowY : style.overflowX;
|
|
413
|
+
if (overflow === "auto" || overflow === "scroll") {
|
|
414
|
+
if (axis === "y" ? current.scrollHeight > current.clientHeight : current.scrollWidth > current.clientWidth) return current;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
current = current.parentElement;
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Check if a scrollable element is at its edge in the dismiss direction.
|
|
423
|
+
* For bottom sheets (sign=1, axis=y), "at edge" means scrolled to top.
|
|
424
|
+
* For left panels (sign=-1, axis=x), "at edge" means scrolled to right end.
|
|
425
|
+
*/
|
|
426
|
+
function isAtScrollEdge(el, axis, sign) {
|
|
427
|
+
if (axis === "y") return sign === 1 ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
|
428
|
+
return sign === 1 ? el.scrollLeft <= 0 : el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Get the dismiss direction axis and sign for a given side.
|
|
432
|
+
* - right panel → dismiss by dragging right (+x)
|
|
433
|
+
* - left panel → dismiss by dragging left (-x)
|
|
434
|
+
* - bottom panel → dismiss by dragging down (+y)
|
|
435
|
+
*/
|
|
436
|
+
function getDismissAxis(side) {
|
|
437
|
+
switch (side) {
|
|
438
|
+
case "right": return {
|
|
439
|
+
axis: "x",
|
|
440
|
+
sign: 1
|
|
441
|
+
};
|
|
442
|
+
case "left": return {
|
|
443
|
+
axis: "x",
|
|
444
|
+
sign: -1
|
|
445
|
+
};
|
|
446
|
+
case "bottom": return {
|
|
447
|
+
axis: "y",
|
|
448
|
+
sign: 1
|
|
449
|
+
};
|
|
450
|
+
default: return {
|
|
451
|
+
axis: "x",
|
|
452
|
+
sign: 1
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Decide whether a gesture past the dead zone qualifies as a dismiss drag.
|
|
458
|
+
* Returns "drag" if it's a valid dismiss gesture, "none" if it's off-axis
|
|
459
|
+
* or moving in the wrong direction.
|
|
460
|
+
*/
|
|
461
|
+
function classifyGesture(dx, dy, axis, sign) {
|
|
462
|
+
const absDx = Math.abs(dx);
|
|
463
|
+
const absDy = Math.abs(dy);
|
|
464
|
+
let angleDeg;
|
|
465
|
+
if (axis === "y") angleDeg = absDy === 0 ? 90 : Math.atan(absDx / absDy) * 180 / Math.PI;
|
|
466
|
+
else angleDeg = absDx === 0 ? 90 : Math.atan(absDy / absDx) * 180 / Math.PI;
|
|
467
|
+
if (angleDeg > 35) return "none";
|
|
468
|
+
if ((axis === "x" ? dx : dy) * sign < 0) return "none";
|
|
469
|
+
return "drag";
|
|
470
|
+
}
|
|
471
|
+
/** Decide whether a pointer gesture commits as a drag or should be ignored. */
|
|
472
|
+
function commitGesture(dx, dy, axis, sign, scrollEl) {
|
|
473
|
+
if (classifyGesture(dx, dy, axis, sign) === "none") return "none";
|
|
474
|
+
if (scrollEl && !isAtScrollEdge(scrollEl, axis, sign)) return "none";
|
|
475
|
+
return "drag";
|
|
476
|
+
}
|
|
477
|
+
function getPanelDimension(panel, axis) {
|
|
478
|
+
if (!panel) return 300;
|
|
479
|
+
return axis === "x" ? panel.offsetWidth : panel.offsetHeight;
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/Drag/useDrag.ts
|
|
483
|
+
/**
|
|
484
|
+
* Hook that manages drag gestures that can dismiss a sheet panel.
|
|
485
|
+
*
|
|
486
|
+
* Gesture pipeline:
|
|
487
|
+
* 1. Dead zone (10px) — ignores micro-movements
|
|
488
|
+
* 2. Angle check (35°) — must be roughly aligned with dismiss axis
|
|
489
|
+
* 3. Scroll conflict — yields to scrollable containers not at edge
|
|
490
|
+
* 4. Commit — drag is active, applies offset via `onDragUpdate`
|
|
491
|
+
* 5. Release — velocity + threshold determine close/snap/bounce-back
|
|
492
|
+
*
|
|
493
|
+
* Opposite-direction drag uses √(offset) damping for elastic
|
|
494
|
+
* rubber-band resistance (same physics as iOS over-scroll).
|
|
495
|
+
*
|
|
496
|
+
* When `snapHeights` is provided, release targeting uses
|
|
497
|
+
* `findSnapTarget()` instead of the simple threshold check.
|
|
498
|
+
*/
|
|
499
|
+
function useDrag(panelRef, config, onDragUpdate) {
|
|
500
|
+
const startRef = useRef(null);
|
|
501
|
+
const committedRef = useRef(null);
|
|
502
|
+
const offsetRef = useRef(0);
|
|
503
|
+
const scrollTargetRef = useRef(null);
|
|
504
|
+
const { axis, sign } = getDismissAxis(config.side);
|
|
505
|
+
const handlePointerDown = useCallback((e) => {
|
|
506
|
+
if (!config.enabled) return;
|
|
507
|
+
if (e.button !== 0) return;
|
|
508
|
+
const target = e.target;
|
|
509
|
+
if (!target) return;
|
|
510
|
+
const isHandle = !!target.closest("[data-stacksheet-handle]");
|
|
511
|
+
if (!isHandle && isInteractiveElement(target)) return;
|
|
512
|
+
scrollTargetRef.current = isHandle ? null : findScrollableAncestor(target, axis);
|
|
513
|
+
startRef.current = {
|
|
514
|
+
x: e.clientX,
|
|
515
|
+
y: e.clientY,
|
|
516
|
+
time: Date.now()
|
|
517
|
+
};
|
|
518
|
+
committedRef.current = null;
|
|
519
|
+
offsetRef.current = 0;
|
|
520
|
+
e.currentTarget?.setPointerCapture?.(e.pointerId);
|
|
521
|
+
}, [config.enabled, axis]);
|
|
522
|
+
const handlePointerMove = useCallback((e) => {
|
|
523
|
+
if (!startRef.current) return;
|
|
524
|
+
const dx = e.clientX - startRef.current.x;
|
|
525
|
+
const dy = e.clientY - startRef.current.y;
|
|
526
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
527
|
+
if (committedRef.current === null && dist < 10) return;
|
|
528
|
+
if (committedRef.current === null) {
|
|
529
|
+
committedRef.current = commitGesture(dx, dy, axis, sign, scrollTargetRef.current);
|
|
530
|
+
if (committedRef.current !== "drag") {
|
|
531
|
+
startRef.current = null;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (committedRef.current !== "drag") return;
|
|
536
|
+
const directional = (axis === "x" ? dx : dy) * sign;
|
|
537
|
+
const clampedOffset = directional >= 0 ? directional : -Math.sqrt(Math.abs(directional)) * RUBBER_BAND_FACTOR;
|
|
538
|
+
offsetRef.current = clampedOffset;
|
|
539
|
+
onDragUpdate({
|
|
540
|
+
offset: clampedOffset,
|
|
541
|
+
isDragging: true
|
|
542
|
+
});
|
|
543
|
+
e.preventDefault();
|
|
544
|
+
}, [
|
|
545
|
+
axis,
|
|
546
|
+
sign,
|
|
547
|
+
onDragUpdate
|
|
548
|
+
]);
|
|
549
|
+
const dismiss = useCallback(() => {
|
|
550
|
+
if (config.isNested) config.onPop();
|
|
551
|
+
else config.onClose();
|
|
552
|
+
}, [config]);
|
|
553
|
+
const handlePointerUp = useCallback((_e) => {
|
|
554
|
+
if (!startRef.current || committedRef.current !== "drag") {
|
|
555
|
+
startRef.current = null;
|
|
556
|
+
committedRef.current = null;
|
|
557
|
+
scrollTargetRef.current = null;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const offset = Math.max(0, offsetRef.current);
|
|
561
|
+
const elapsed = Date.now() - startRef.current.time;
|
|
562
|
+
const velocity = elapsed > 0 ? offset / elapsed : 0;
|
|
563
|
+
startRef.current = null;
|
|
564
|
+
committedRef.current = null;
|
|
565
|
+
offsetRef.current = 0;
|
|
566
|
+
scrollTargetRef.current = null;
|
|
567
|
+
const panelSize = getPanelDimension(panelRef.current, axis);
|
|
568
|
+
if (config.snapHeights.length > 0) {
|
|
569
|
+
const targetIndex = findSnapTarget(offset, panelSize, config.snapHeights, velocity, config.activeSnapIndex, config.sequential);
|
|
570
|
+
if (targetIndex === -1) dismiss();
|
|
571
|
+
else {
|
|
572
|
+
config.onSnap(targetIndex);
|
|
573
|
+
onDragUpdate({
|
|
574
|
+
offset: 0,
|
|
575
|
+
isDragging: false
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const pastThreshold = offset / panelSize > config.closeThreshold;
|
|
581
|
+
const fastEnough = velocity > config.velocityThreshold;
|
|
582
|
+
if (pastThreshold || fastEnough) dismiss();
|
|
583
|
+
else onDragUpdate({
|
|
584
|
+
offset: 0,
|
|
585
|
+
isDragging: false
|
|
586
|
+
});
|
|
587
|
+
}, [
|
|
588
|
+
panelRef,
|
|
589
|
+
axis,
|
|
590
|
+
config,
|
|
591
|
+
onDragUpdate,
|
|
592
|
+
dismiss
|
|
593
|
+
]);
|
|
594
|
+
const handlePointerCancel = useCallback(() => {
|
|
595
|
+
startRef.current = null;
|
|
596
|
+
committedRef.current = null;
|
|
597
|
+
offsetRef.current = 0;
|
|
598
|
+
scrollTargetRef.current = null;
|
|
599
|
+
onDragUpdate({
|
|
600
|
+
offset: 0,
|
|
601
|
+
isDragging: false
|
|
602
|
+
});
|
|
603
|
+
}, [onDragUpdate]);
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
const el = panelRef.current;
|
|
606
|
+
if (!(el && config.enabled)) return;
|
|
607
|
+
el.addEventListener("pointerdown", handlePointerDown);
|
|
608
|
+
el.addEventListener("pointermove", handlePointerMove);
|
|
609
|
+
el.addEventListener("pointerup", handlePointerUp);
|
|
610
|
+
el.addEventListener("pointercancel", handlePointerCancel);
|
|
611
|
+
return () => {
|
|
612
|
+
el.removeEventListener("pointerdown", handlePointerDown);
|
|
613
|
+
el.removeEventListener("pointermove", handlePointerMove);
|
|
614
|
+
el.removeEventListener("pointerup", handlePointerUp);
|
|
615
|
+
el.removeEventListener("pointercancel", handlePointerCancel);
|
|
616
|
+
};
|
|
617
|
+
}, [
|
|
618
|
+
panelRef,
|
|
619
|
+
config.enabled,
|
|
620
|
+
handlePointerDown,
|
|
621
|
+
handlePointerMove,
|
|
622
|
+
handlePointerUp,
|
|
623
|
+
handlePointerCancel
|
|
624
|
+
]);
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/panel-context.tsx
|
|
628
|
+
const SheetPanelContext = createContext(null);
|
|
629
|
+
/**
|
|
630
|
+
* Access the current sheet panel's context.
|
|
631
|
+
* Must be called inside a component rendered by the sheet stack.
|
|
632
|
+
*/
|
|
633
|
+
function useSheetPanel() {
|
|
634
|
+
const ctx = useContext(SheetPanelContext);
|
|
635
|
+
if (!ctx) throw new Error("Sheet.* components must be used inside a sheet panel. They should be rendered by a component opened via actions.open(), push(), etc.");
|
|
636
|
+
return ctx;
|
|
637
|
+
}
|
|
638
|
+
//#endregion
|
|
639
|
+
//#region src/stacking.ts
|
|
640
|
+
/**
|
|
641
|
+
* Compute visual transforms for a panel at a given depth.
|
|
642
|
+
* depth=0 is the top (foreground) panel.
|
|
643
|
+
* Panels beyond renderThreshold are clamped to the edge position and faded out.
|
|
644
|
+
*/
|
|
645
|
+
function getStackTransform(depth, stacking) {
|
|
646
|
+
if (depth <= 0) return {
|
|
647
|
+
scale: 1,
|
|
648
|
+
offset: 0,
|
|
649
|
+
opacity: 1,
|
|
650
|
+
borderRadius: 0
|
|
651
|
+
};
|
|
652
|
+
const beyondThreshold = depth >= stacking.renderThreshold;
|
|
653
|
+
const visualDepth = beyondThreshold ? stacking.renderThreshold - 1 : depth;
|
|
654
|
+
return {
|
|
655
|
+
scale: Math.max(.5, 1 - visualDepth * stacking.scaleStep),
|
|
656
|
+
offset: visualDepth * stacking.offsetStep,
|
|
657
|
+
opacity: beyondThreshold ? 0 : Math.max(0, 1 - visualDepth * stacking.opacityStep),
|
|
658
|
+
borderRadius: stacking.radius
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Border radius values for the animate target.
|
|
663
|
+
* Must be animated (not static CSS) so Motion applies scale correction
|
|
664
|
+
* when panels are scaled. See: https://motion.dev/docs/react-layout-animations#scale-correction
|
|
665
|
+
*/
|
|
666
|
+
function getAnimatedBorderRadius(side, depth, stacking) {
|
|
667
|
+
if (side === "bottom") {
|
|
668
|
+
const radius = depth > 0 ? stacking.radius : 16;
|
|
669
|
+
return {
|
|
670
|
+
borderTopLeftRadius: radius,
|
|
671
|
+
borderTopRightRadius: radius,
|
|
672
|
+
borderBottomLeftRadius: 0,
|
|
673
|
+
borderBottomRightRadius: 0
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
if (depth > 0) return { borderRadius: stacking.radius };
|
|
677
|
+
return { borderRadius: 0 };
|
|
678
|
+
}
|
|
679
|
+
/** Motion initial/exit values for sliding from the given side. */
|
|
680
|
+
function getSlideFrom(side) {
|
|
681
|
+
switch (side) {
|
|
682
|
+
case "right": return { x: "100%" };
|
|
683
|
+
case "left": return { x: "-100%" };
|
|
684
|
+
case "bottom": return { y: "100%" };
|
|
685
|
+
default: return { x: "100%" };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/** Motion animate target — the resting position. */
|
|
689
|
+
function getSlideTarget() {
|
|
690
|
+
return {
|
|
691
|
+
x: 0,
|
|
692
|
+
y: 0
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
/** Translate offset that pushes stacked panels away from the stack edge. */
|
|
696
|
+
function getStackOffset(side, offset) {
|
|
697
|
+
if (offset === 0) return {};
|
|
698
|
+
switch (side) {
|
|
699
|
+
case "right": return { x: -offset };
|
|
700
|
+
case "left": return { x: offset };
|
|
701
|
+
case "bottom": return { y: -offset };
|
|
702
|
+
default: return {};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** Opposite-side origin so stacked panels recede away from the stack edge. */
|
|
706
|
+
function getTransformOrigin(side) {
|
|
707
|
+
if (side === "right") return "left center";
|
|
708
|
+
if (side === "left") return "right center";
|
|
709
|
+
return "center top";
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Fixed-position styles for a panel, accounting for side, width, and depth.
|
|
713
|
+
*/
|
|
714
|
+
function getPanelStyles(side, config, index) {
|
|
715
|
+
const { width, maxWidth, zIndex } = config;
|
|
716
|
+
const base = {
|
|
717
|
+
position: "fixed",
|
|
718
|
+
zIndex: zIndex + 10 + index,
|
|
719
|
+
display: "flex",
|
|
720
|
+
flexDirection: "column",
|
|
721
|
+
willChange: "transform",
|
|
722
|
+
transformOrigin: getTransformOrigin(side)
|
|
723
|
+
};
|
|
724
|
+
if (side === "bottom") return {
|
|
725
|
+
...base,
|
|
726
|
+
left: 0,
|
|
727
|
+
right: 0,
|
|
728
|
+
bottom: 0,
|
|
729
|
+
height: "85dvh",
|
|
730
|
+
maxHeight: "85dvh"
|
|
731
|
+
};
|
|
732
|
+
const sideStyles = side === "right" ? {
|
|
733
|
+
top: 0,
|
|
734
|
+
right: 0,
|
|
735
|
+
bottom: 0
|
|
736
|
+
} : {
|
|
737
|
+
top: 0,
|
|
738
|
+
left: 0,
|
|
739
|
+
bottom: 0
|
|
740
|
+
};
|
|
741
|
+
return {
|
|
742
|
+
...base,
|
|
743
|
+
...sideStyles,
|
|
744
|
+
width,
|
|
745
|
+
maxWidth
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/icons.tsx
|
|
750
|
+
/** Inline SVG icons — no external dependency */
|
|
751
|
+
function ArrowLeftIcon() {
|
|
752
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
753
|
+
"aria-hidden": "true",
|
|
754
|
+
fill: "none",
|
|
755
|
+
height: 16,
|
|
756
|
+
stroke: "currentColor",
|
|
757
|
+
strokeLinecap: "round",
|
|
758
|
+
strokeLinejoin: "round",
|
|
759
|
+
strokeWidth: 2,
|
|
760
|
+
viewBox: "0 0 24 24",
|
|
761
|
+
width: 16,
|
|
762
|
+
children: /* @__PURE__ */ jsx("path", { d: "M19 12H5M12 19l-7-7 7-7" })
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function XIcon() {
|
|
766
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
767
|
+
"aria-hidden": "true",
|
|
768
|
+
fill: "none",
|
|
769
|
+
height: 16,
|
|
770
|
+
stroke: "currentColor",
|
|
771
|
+
strokeLinecap: "round",
|
|
772
|
+
strokeLinejoin: "round",
|
|
773
|
+
strokeWidth: 2,
|
|
774
|
+
viewBox: "0 0 24 24",
|
|
775
|
+
width: 16,
|
|
776
|
+
children: /* @__PURE__ */ jsx("path", { d: "M18 6L6 18M6 6l12 12" })
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/SheetPanel/DefaultHeader.tsx
|
|
781
|
+
function DefaultHeader({ isNested, onBack, onClose, className }) {
|
|
782
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
783
|
+
className: `flex shrink-0 items-center justify-between px-4 pt-4 pb-2 ${className ?? ""}`,
|
|
784
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
785
|
+
className: "flex items-center gap-2",
|
|
786
|
+
children: isNested && /* @__PURE__ */ jsx("button", {
|
|
787
|
+
"aria-label": "Back",
|
|
788
|
+
className: "flex min-h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-black/5 p-0 text-inherit opacity-70 transition-opacity duration-150 hover:opacity-100",
|
|
789
|
+
onClick: onBack,
|
|
790
|
+
type: "button",
|
|
791
|
+
children: /* @__PURE__ */ jsx(ArrowLeftIcon, {})
|
|
792
|
+
})
|
|
793
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
794
|
+
"aria-label": "Close",
|
|
795
|
+
className: "flex min-h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-black/5 p-0 text-inherit opacity-70 transition-opacity duration-150 hover:opacity-100",
|
|
796
|
+
onClick: onClose,
|
|
797
|
+
type: "button",
|
|
798
|
+
children: /* @__PURE__ */ jsx(XIcon, {})
|
|
799
|
+
})]
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
//#endregion
|
|
803
|
+
//#region src/SheetPanel/SheetPanelContent.tsx
|
|
804
|
+
const PanelInnerContent = memo(function PanelInnerContent({ isComposable, shouldRender, Content, data, renderHeader, headerProps, headerClassName }) {
|
|
805
|
+
if (isComposable) return shouldRender && Content ? /* @__PURE__ */ jsx(Content, { ...data }) : null;
|
|
806
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [renderHeader ? renderHeader(headerProps) : /* @__PURE__ */ jsx(DefaultHeader, {
|
|
807
|
+
...headerProps,
|
|
808
|
+
className: headerClassName
|
|
809
|
+
}), shouldRender && Content && /* @__PURE__ */ jsx("div", {
|
|
810
|
+
className: "min-h-0 flex-1 overflow-y-auto overscroll-contain",
|
|
811
|
+
"data-stacksheet-no-drag": "",
|
|
812
|
+
children: /* @__PURE__ */ jsx(Content, { ...data })
|
|
813
|
+
})] });
|
|
814
|
+
});
|
|
815
|
+
PanelInnerContent.displayName = "PanelInnerContent";
|
|
816
|
+
function resolvePanelLayout(layout, renderHeader) {
|
|
817
|
+
if (layout) return layout;
|
|
818
|
+
return renderHeader === false ? "composable" : "classic";
|
|
819
|
+
}
|
|
820
|
+
//#endregion
|
|
821
|
+
//#region src/SheetPanel/SheetPanelFocus.tsx
|
|
822
|
+
const LAYERED_MODAL_SELECTORS = [
|
|
823
|
+
"[role=\"dialog\"][data-state=\"open\"]",
|
|
824
|
+
"[role=\"alertdialog\"][data-state=\"open\"]",
|
|
825
|
+
"[data-radix-popper-content-wrapper]",
|
|
826
|
+
"[data-radix-focus-guard]"
|
|
827
|
+
].join(", ");
|
|
828
|
+
function ModalFocusTrap({ enabled, active, fallbackRef, children }) {
|
|
829
|
+
const paused = useLayeredModalFocused(enabled && active);
|
|
830
|
+
if (!enabled) return children;
|
|
831
|
+
return /* @__PURE__ */ jsx(FocusTrap, {
|
|
832
|
+
active,
|
|
833
|
+
focusTrapOptions: {
|
|
834
|
+
initialFocus: false,
|
|
835
|
+
returnFocusOnDeactivate: true,
|
|
836
|
+
escapeDeactivates: false,
|
|
837
|
+
allowOutsideClick: true,
|
|
838
|
+
checkCanFocusTrap: () => new Promise((resolve) => requestAnimationFrame(() => resolve())),
|
|
839
|
+
fallbackFocus: () => {
|
|
840
|
+
if (fallbackRef.current) return fallbackRef.current;
|
|
841
|
+
return document.body;
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
paused,
|
|
845
|
+
children
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
function useLayeredModalFocused(active) {
|
|
849
|
+
const [layered, setLayered] = useState(false);
|
|
850
|
+
useEffect(() => {
|
|
851
|
+
if (!active) {
|
|
852
|
+
setLayered(false);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const evaluate = () => {
|
|
856
|
+
const target = document.activeElement;
|
|
857
|
+
if (!target || target === document.body) {
|
|
858
|
+
setLayered(false);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
setLayered(target instanceof Element && target.closest(LAYERED_MODAL_SELECTORS) !== null);
|
|
862
|
+
};
|
|
863
|
+
evaluate();
|
|
864
|
+
const handler = () => evaluate();
|
|
865
|
+
document.addEventListener("focusin", handler, true);
|
|
866
|
+
return () => document.removeEventListener("focusin", handler, true);
|
|
867
|
+
}, [active]);
|
|
868
|
+
return layered;
|
|
869
|
+
}
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/SheetPanel/SheetPanelHandles.tsx
|
|
872
|
+
function BottomHandle({ onDismiss }) {
|
|
873
|
+
return /* @__PURE__ */ jsx("button", {
|
|
874
|
+
"aria-label": "Dismiss",
|
|
875
|
+
className: "absolute inset-x-0 top-0 z-10 flex w-full cursor-grab touch-none items-center justify-center border-none bg-transparent pt-2.5 pb-2",
|
|
876
|
+
"data-stacksheet-handle": "",
|
|
877
|
+
onClick: onDismiss,
|
|
878
|
+
type: "button",
|
|
879
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
880
|
+
"aria-hidden": "true",
|
|
881
|
+
className: "h-[5px] w-9 rounded-full bg-current/15"
|
|
882
|
+
})
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
function SideHandle({ side, isHovered, onDismiss }) {
|
|
886
|
+
const position = side === "right" ? { right: "100%" } : { left: "100%" };
|
|
887
|
+
return /* @__PURE__ */ jsx(motion.div, {
|
|
888
|
+
animate: { opacity: isHovered ? 1 : 0 },
|
|
889
|
+
"aria-label": "Dismiss",
|
|
890
|
+
className: "absolute top-0 bottom-0 flex w-6 cursor-grab touch-none items-center justify-center",
|
|
891
|
+
"data-stacksheet-handle": "",
|
|
892
|
+
onClick: onDismiss,
|
|
893
|
+
onKeyDown: (e) => {
|
|
894
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
895
|
+
e.preventDefault();
|
|
896
|
+
onDismiss?.();
|
|
897
|
+
}
|
|
898
|
+
},
|
|
899
|
+
role: "button",
|
|
900
|
+
style: position,
|
|
901
|
+
tabIndex: 0,
|
|
902
|
+
transition: {
|
|
903
|
+
duration: isHovered ? .15 : .4,
|
|
904
|
+
ease: "easeOut"
|
|
905
|
+
},
|
|
906
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
907
|
+
"aria-hidden": "true",
|
|
908
|
+
className: "h-8 w-1 rounded-full bg-current/20"
|
|
909
|
+
})
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
//#endregion
|
|
913
|
+
//#region src/SheetPanel/SheetPanel.tsx
|
|
914
|
+
const SheetPanel = memo(function SheetPanel({ item, index, depth, isTop, isNested, side, config, classNames, Content, shouldRender, pop, close, swipeClose, swipePop, snapHeights, activeSnapIndex, onSnap, layout, renderHeader, slideFrom, slideTarget, spring, stackSpring, prefersReducedMotion }) {
|
|
915
|
+
const panelRef = useRef(null);
|
|
916
|
+
const hasEnteredRef = useRef(false);
|
|
917
|
+
const [dragState, setDragState] = useState({
|
|
918
|
+
offset: 0,
|
|
919
|
+
isDragging: false
|
|
920
|
+
});
|
|
921
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
922
|
+
const measuredHeight = usePanelHeight(panelRef, snapHeights.length > 0);
|
|
923
|
+
const transform = getStackTransform(depth, config.stacking);
|
|
924
|
+
const panelStyles = getPanelStyles(side, config, index);
|
|
925
|
+
useEffect(() => {
|
|
926
|
+
if (!isTop) hasEnteredRef.current = false;
|
|
927
|
+
}, [isTop]);
|
|
928
|
+
const handleAnimationComplete = useCallback(() => {
|
|
929
|
+
if (isTop && !hasEnteredRef.current) {
|
|
930
|
+
hasEnteredRef.current = true;
|
|
931
|
+
config.onOpenComplete?.();
|
|
932
|
+
}
|
|
933
|
+
}, [isTop, config]);
|
|
934
|
+
useDrag(panelRef, {
|
|
935
|
+
enabled: isTop && config.drag && config.dismissible && !prefersReducedMotion,
|
|
936
|
+
closeThreshold: config.closeThreshold,
|
|
937
|
+
velocityThreshold: config.velocityThreshold,
|
|
938
|
+
side,
|
|
939
|
+
onClose: swipeClose,
|
|
940
|
+
onPop: swipePop,
|
|
941
|
+
isNested,
|
|
942
|
+
snapHeights,
|
|
943
|
+
activeSnapIndex,
|
|
944
|
+
onSnap,
|
|
945
|
+
sequential: config.snapToSequentialPoints
|
|
946
|
+
}, setDragState);
|
|
947
|
+
const ariaLabel = item.ariaLabel ?? (typeof item.data?.__ariaLabel === "string" ? item.data.__ariaLabel : void 0) ?? config.ariaLabel;
|
|
948
|
+
const panelId = `stacksheet-${item.id}`;
|
|
949
|
+
const [hasDescription, setHasDescription] = useState(false);
|
|
950
|
+
const registerDescription = useCallback(() => {
|
|
951
|
+
setHasDescription(true);
|
|
952
|
+
return () => setHasDescription(false);
|
|
953
|
+
}, []);
|
|
954
|
+
const panelContext = useMemo(() => ({
|
|
955
|
+
close,
|
|
956
|
+
back: pop,
|
|
957
|
+
isNested,
|
|
958
|
+
isTop,
|
|
959
|
+
panelId,
|
|
960
|
+
side,
|
|
961
|
+
hasDescription,
|
|
962
|
+
registerDescription
|
|
963
|
+
}), [
|
|
964
|
+
close,
|
|
965
|
+
pop,
|
|
966
|
+
isNested,
|
|
967
|
+
isTop,
|
|
968
|
+
panelId,
|
|
969
|
+
side,
|
|
970
|
+
hasDescription,
|
|
971
|
+
registerDescription
|
|
972
|
+
]);
|
|
973
|
+
const isComposable = resolvePanelLayout(layout, renderHeader) === "composable";
|
|
974
|
+
const hasPanelClass = classNames.panel !== "";
|
|
975
|
+
const dragOffset = getDragTransform(side, dragState.offset);
|
|
976
|
+
const panelStyle = buildPanelStyle(panelStyles, isTop, hasPanelClass, dragState.isDragging);
|
|
977
|
+
const headerProps = useMemo(() => ({
|
|
978
|
+
isNested,
|
|
979
|
+
onBack: pop,
|
|
980
|
+
onClose: close,
|
|
981
|
+
side
|
|
982
|
+
}), [
|
|
983
|
+
close,
|
|
984
|
+
isNested,
|
|
985
|
+
pop,
|
|
986
|
+
side
|
|
987
|
+
]);
|
|
988
|
+
const ariaProps = buildAriaProps(isTop, config.modal, isComposable, ariaLabel, panelId, hasDescription);
|
|
989
|
+
const transition = buildPanelTransition(dragState.isDragging, isTop, spring, stackSpring);
|
|
990
|
+
const animatedRadius = getAnimatedBorderRadius(side, depth, config.stacking);
|
|
991
|
+
const snapYOffset = computeSnapYOffset(side, snapHeights, activeSnapIndex, measuredHeight);
|
|
992
|
+
const resolvedSlideFrom = resolveSlideFrom(side, slideFrom, measuredHeight);
|
|
993
|
+
const animateTarget = buildAnimateTarget(slideTarget, getStackOffset(side, transform.offset), dragOffset, transform, animatedRadius, transition, snapYOffset, isTop);
|
|
994
|
+
const initialRadius = getInitialRadius(side);
|
|
995
|
+
const showSideHandle = isTop && side !== "bottom";
|
|
996
|
+
const showBottomHandle = isTop && side === "bottom";
|
|
997
|
+
const panelContent = /* @__PURE__ */ jsxs(motion.div, {
|
|
998
|
+
animate: animateTarget,
|
|
999
|
+
className: classNames.panel || void 0,
|
|
1000
|
+
exit: {
|
|
1001
|
+
...resolvedSlideFrom,
|
|
1002
|
+
opacity: .6,
|
|
1003
|
+
boxShadow: getShadow(false),
|
|
1004
|
+
transition: {
|
|
1005
|
+
type: "tween",
|
|
1006
|
+
duration: prefersReducedMotion ? 0 : .24,
|
|
1007
|
+
ease: "easeOut",
|
|
1008
|
+
boxShadow: VISUAL_TWEEN
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
initial: {
|
|
1012
|
+
...resolvedSlideFrom,
|
|
1013
|
+
opacity: .8,
|
|
1014
|
+
...initialRadius,
|
|
1015
|
+
boxShadow: getShadow(false)
|
|
1016
|
+
},
|
|
1017
|
+
onAnimationComplete: handleAnimationComplete,
|
|
1018
|
+
onBlur: showSideHandle ? () => setIsHovered(false) : void 0,
|
|
1019
|
+
onFocus: showSideHandle ? () => setIsHovered(true) : void 0,
|
|
1020
|
+
onMouseEnter: showSideHandle ? () => setIsHovered(true) : void 0,
|
|
1021
|
+
onMouseLeave: showSideHandle ? () => setIsHovered(false) : void 0,
|
|
1022
|
+
ref: panelRef,
|
|
1023
|
+
style: panelStyle,
|
|
1024
|
+
tabIndex: isTop ? -1 : void 0,
|
|
1025
|
+
...isTop ? {} : {
|
|
1026
|
+
"aria-hidden": "true",
|
|
1027
|
+
inert: true
|
|
1028
|
+
},
|
|
1029
|
+
...ariaProps,
|
|
1030
|
+
children: [showSideHandle && /* @__PURE__ */ jsx(SideHandle, {
|
|
1031
|
+
isHovered,
|
|
1032
|
+
onDismiss: isNested ? pop : close,
|
|
1033
|
+
side
|
|
1034
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1035
|
+
className: "relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[inherit]",
|
|
1036
|
+
children: [showBottomHandle && /* @__PURE__ */ jsx(BottomHandle, { onDismiss: isNested ? pop : close }), /* @__PURE__ */ jsx(PanelInnerContent, {
|
|
1037
|
+
Content,
|
|
1038
|
+
data: item.data,
|
|
1039
|
+
headerClassName: classNames.header || void 0,
|
|
1040
|
+
headerProps,
|
|
1041
|
+
isComposable,
|
|
1042
|
+
renderHeader,
|
|
1043
|
+
shouldRender
|
|
1044
|
+
})]
|
|
1045
|
+
})]
|
|
1046
|
+
}, item.id);
|
|
1047
|
+
return /* @__PURE__ */ jsx(SheetPanelContext.Provider, {
|
|
1048
|
+
value: panelContext,
|
|
1049
|
+
children: /* @__PURE__ */ jsx(ModalFocusTrap, {
|
|
1050
|
+
active: isTop,
|
|
1051
|
+
enabled: config.modal,
|
|
1052
|
+
fallbackRef: panelRef,
|
|
1053
|
+
children: panelContent
|
|
1054
|
+
})
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
//#endregion
|
|
1058
|
+
//#region src/renderer.tsx
|
|
1059
|
+
/**
|
|
1060
|
+
* Root renderer component — manages the backdrop, scroll lock, snap points,
|
|
1061
|
+
* close reasons, focus restoration, keyboard/CloseWatcher dismissal, and
|
|
1062
|
+
* delegates per-panel rendering to `SheetPanel`.
|
|
1063
|
+
*
|
|
1064
|
+
* Mounted inside a Portal by `StacksheetProvider`.
|
|
1065
|
+
*/
|
|
1066
|
+
function SheetRenderer({ store, config, sheets, componentMap, classNames: classNamesProp, layout, renderHeader }) {
|
|
1067
|
+
const isOpen = useStore(store, (s) => s.isOpen);
|
|
1068
|
+
const stack = useStore(store, (s) => s.stack);
|
|
1069
|
+
const rawClose = useStore(store, (s) => s.close);
|
|
1070
|
+
const rawPop = useStore(store, (s) => s.pop);
|
|
1071
|
+
const side = useResolvedSide(config);
|
|
1072
|
+
const prefersReducedMotion = useReducedMotion() ?? false;
|
|
1073
|
+
const classNames = useMemo(() => resolveClassNames(classNamesProp), [classNamesProp]);
|
|
1074
|
+
const viewportHeight = useViewportHeight(isOpen && side === "bottom" && config.snapPoints.length > 0);
|
|
1075
|
+
const snapHeights = useMemo(() => side === "bottom" && config.snapPoints.length > 0 ? resolveSnapPoints(config.snapPoints, viewportHeight) : [], [
|
|
1076
|
+
side,
|
|
1077
|
+
config.snapPoints,
|
|
1078
|
+
viewportHeight
|
|
1079
|
+
]);
|
|
1080
|
+
const [internalSnapIndex, setInternalSnapIndex] = useState(snapHeights.length > 0 ? snapHeights.length - 1 : 0);
|
|
1081
|
+
const activeSnapIndex = config.snapPointIndex ?? internalSnapIndex;
|
|
1082
|
+
const handleSnap = useCallback((index) => {
|
|
1083
|
+
setInternalSnapIndex(index);
|
|
1084
|
+
config.onSnapPointChange?.(index);
|
|
1085
|
+
}, [config.onSnapPointChange, config]);
|
|
1086
|
+
useEffect(() => {
|
|
1087
|
+
if (isOpen && snapHeights.length > 0) setInternalSnapIndex(config.snapPointIndex ?? snapHeights.length - 1);
|
|
1088
|
+
}, [
|
|
1089
|
+
isOpen,
|
|
1090
|
+
snapHeights.length,
|
|
1091
|
+
config.snapPointIndex
|
|
1092
|
+
]);
|
|
1093
|
+
const closeReasonRef = useRef("programmatic");
|
|
1094
|
+
const closeWith = useCallback((reason) => {
|
|
1095
|
+
closeReasonRef.current = reason;
|
|
1096
|
+
rawClose();
|
|
1097
|
+
}, [rawClose]);
|
|
1098
|
+
const popWith = useCallback((reason) => {
|
|
1099
|
+
closeReasonRef.current = reason;
|
|
1100
|
+
rawPop();
|
|
1101
|
+
}, [rawPop]);
|
|
1102
|
+
const close = useCallback(() => closeWith("programmatic"), [closeWith]);
|
|
1103
|
+
const pop = useCallback(() => popWith("programmatic"), [popWith]);
|
|
1104
|
+
useBodyScale(config, isOpen, prefersReducedMotion);
|
|
1105
|
+
const triggerRef = useRef(null);
|
|
1106
|
+
const wasOpenRef = useRef(false);
|
|
1107
|
+
useEffect(() => {
|
|
1108
|
+
if (isOpen && !wasOpenRef.current) triggerRef.current = document.activeElement;
|
|
1109
|
+
else if (!isOpen && wasOpenRef.current) {
|
|
1110
|
+
const el = triggerRef.current;
|
|
1111
|
+
if (el && el instanceof HTMLElement && el !== document.body && el.tagName !== "BODY") el.focus();
|
|
1112
|
+
triggerRef.current = null;
|
|
1113
|
+
}
|
|
1114
|
+
wasOpenRef.current = isOpen;
|
|
1115
|
+
}, [isOpen]);
|
|
1116
|
+
const stackLengthRef = useRef(stack.length);
|
|
1117
|
+
useEffect(() => {
|
|
1118
|
+
stackLengthRef.current = stack.length;
|
|
1119
|
+
}, [stack.length]);
|
|
1120
|
+
useEffect(() => {
|
|
1121
|
+
if (!(isOpen && config.closeOnEscape && config.dismissible)) return;
|
|
1122
|
+
function handleKeyDown(e) {
|
|
1123
|
+
if (e.key === "Escape") {
|
|
1124
|
+
e.preventDefault();
|
|
1125
|
+
if (stackLengthRef.current > 1) popWith("escape");
|
|
1126
|
+
else closeWith("escape");
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
1130
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
1131
|
+
}, [
|
|
1132
|
+
isOpen,
|
|
1133
|
+
config.closeOnEscape,
|
|
1134
|
+
config.dismissible,
|
|
1135
|
+
popWith,
|
|
1136
|
+
closeWith
|
|
1137
|
+
]);
|
|
1138
|
+
useEffect(() => {
|
|
1139
|
+
if (!(isOpen && config.dismissible) || typeof globalThis.CloseWatcher === "undefined") return;
|
|
1140
|
+
const watcher = new globalThis.CloseWatcher();
|
|
1141
|
+
watcher.onclose = () => {
|
|
1142
|
+
if (stackLengthRef.current > 1) popWith("escape");
|
|
1143
|
+
else closeWith("escape");
|
|
1144
|
+
};
|
|
1145
|
+
return () => watcher.destroy();
|
|
1146
|
+
}, [
|
|
1147
|
+
isOpen,
|
|
1148
|
+
config.dismissible,
|
|
1149
|
+
popWith,
|
|
1150
|
+
closeWith
|
|
1151
|
+
]);
|
|
1152
|
+
const slideFrom = useMemo(() => getSlideFrom(side), [side]);
|
|
1153
|
+
const slideTarget = useMemo(() => getSlideTarget(), []);
|
|
1154
|
+
const spring = useMemo(() => prefersReducedMotion ? {
|
|
1155
|
+
type: "tween",
|
|
1156
|
+
duration: 0
|
|
1157
|
+
} : {
|
|
1158
|
+
type: "spring",
|
|
1159
|
+
damping: config.spring.damping,
|
|
1160
|
+
stiffness: config.spring.stiffness,
|
|
1161
|
+
mass: config.spring.mass
|
|
1162
|
+
}, [
|
|
1163
|
+
prefersReducedMotion,
|
|
1164
|
+
config.spring.damping,
|
|
1165
|
+
config.spring.stiffness,
|
|
1166
|
+
config.spring.mass
|
|
1167
|
+
]);
|
|
1168
|
+
const stackSpring = spring;
|
|
1169
|
+
const isModal = config.modal;
|
|
1170
|
+
const showOverlay = isModal && config.showOverlay;
|
|
1171
|
+
const hasBackdropClass = classNames.backdrop !== "";
|
|
1172
|
+
const backdropStyle = {
|
|
1173
|
+
zIndex: config.zIndex,
|
|
1174
|
+
willChange: "opacity",
|
|
1175
|
+
cursor: config.closeOnBackdrop && config.dismissible ? "pointer" : void 0,
|
|
1176
|
+
...hasBackdropClass ? {} : { background: "var(--overlay, rgba(0, 0, 0, 0.15))" }
|
|
1177
|
+
};
|
|
1178
|
+
const handleExitComplete = useCallback(() => {
|
|
1179
|
+
if (stack.length === 0) config.onCloseComplete?.(closeReasonRef.current);
|
|
1180
|
+
}, [stack.length, config]);
|
|
1181
|
+
const handleBackdropExitComplete = useCallback(() => {
|
|
1182
|
+
requestAnimationFrame(() => {
|
|
1183
|
+
document.body.offsetHeight;
|
|
1184
|
+
});
|
|
1185
|
+
}, []);
|
|
1186
|
+
const swipeClose = useCallback(() => closeWith("swipe"), [closeWith]);
|
|
1187
|
+
const swipePop = useCallback(() => popWith("swipe"), [popWith]);
|
|
1188
|
+
const shouldLockScroll = isOpen && isModal && config.lockScroll;
|
|
1189
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [showOverlay && /* @__PURE__ */ jsx(AnimatePresence, {
|
|
1190
|
+
onExitComplete: handleBackdropExitComplete,
|
|
1191
|
+
children: isOpen && /* @__PURE__ */ jsx(motion.div, {
|
|
1192
|
+
animate: { opacity: 1 },
|
|
1193
|
+
className: `fixed inset-0 ${classNames.backdrop || ""}`,
|
|
1194
|
+
exit: { opacity: 0 },
|
|
1195
|
+
initial: { opacity: 0 },
|
|
1196
|
+
onClick: config.closeOnBackdrop && config.dismissible ? () => closeWith("backdrop") : void 0,
|
|
1197
|
+
style: backdropStyle,
|
|
1198
|
+
transition: spring
|
|
1199
|
+
}, "stacksheet-backdrop")
|
|
1200
|
+
}), /* @__PURE__ */ jsx(RemoveScroll, {
|
|
1201
|
+
enabled: shouldLockScroll,
|
|
1202
|
+
forwardProps: true,
|
|
1203
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1204
|
+
className: "pointer-events-none fixed inset-0 overflow-hidden",
|
|
1205
|
+
style: { zIndex: config.zIndex + 1 },
|
|
1206
|
+
children: /* @__PURE__ */ jsx(AnimatePresence, {
|
|
1207
|
+
onExitComplete: handleExitComplete,
|
|
1208
|
+
children: stack.map((item, index) => {
|
|
1209
|
+
const depth = stack.length - 1 - index;
|
|
1210
|
+
const isTop = depth === 0;
|
|
1211
|
+
const isNested = index > 0;
|
|
1212
|
+
const shouldRender = depth <= config.stacking.renderThreshold;
|
|
1213
|
+
return /* @__PURE__ */ jsx(SheetPanel, {
|
|
1214
|
+
activeSnapIndex,
|
|
1215
|
+
Content: componentMap.get(item.type) ?? sheets[item.type],
|
|
1216
|
+
classNames,
|
|
1217
|
+
close,
|
|
1218
|
+
config,
|
|
1219
|
+
depth,
|
|
1220
|
+
index,
|
|
1221
|
+
isNested,
|
|
1222
|
+
isTop,
|
|
1223
|
+
item,
|
|
1224
|
+
layout,
|
|
1225
|
+
onSnap: handleSnap,
|
|
1226
|
+
pop,
|
|
1227
|
+
prefersReducedMotion,
|
|
1228
|
+
renderHeader,
|
|
1229
|
+
shouldRender,
|
|
1230
|
+
side,
|
|
1231
|
+
slideFrom,
|
|
1232
|
+
slideTarget,
|
|
1233
|
+
snapHeights,
|
|
1234
|
+
spring,
|
|
1235
|
+
stackSpring,
|
|
1236
|
+
swipeClose,
|
|
1237
|
+
swipePop
|
|
1238
|
+
}, item.id);
|
|
1239
|
+
})
|
|
1240
|
+
})
|
|
1241
|
+
})
|
|
1242
|
+
})] });
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/Store/StoreArgs.ts
|
|
1246
|
+
function resolvePresentationOptions(value) {
|
|
1247
|
+
if (!(value && typeof value === "object")) return;
|
|
1248
|
+
const candidate = value;
|
|
1249
|
+
if (candidate.ariaLabel !== void 0 && typeof candidate.ariaLabel !== "string") return;
|
|
1250
|
+
return candidate;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Dev-mode warning: detect likely inline arrow functions passed as ad-hoc components.
|
|
1254
|
+
* When a new component reference has the same displayName/name as an existing one,
|
|
1255
|
+
* it's almost always an inline arrow being re-created every render.
|
|
1256
|
+
*/
|
|
1257
|
+
function warnInlineComponent(component, componentRegistry, warnedNames) {
|
|
1258
|
+
if (typeof process === "undefined" || process?.env?.NODE_ENV === "production") return;
|
|
1259
|
+
const name = component.displayName || component.name;
|
|
1260
|
+
if (!name) return;
|
|
1261
|
+
if (warnedNames.has(name)) return;
|
|
1262
|
+
for (const [existing, key] of componentRegistry) if ((existing.displayName || existing.name) === name) {
|
|
1263
|
+
warnedNames.add(name);
|
|
1264
|
+
console.warn(`[stacksheet] A new component reference with name "${name}" was registered (key: ${key}), but a different reference with the same name already exists. This usually means you're passing an inline arrow function (e.g. open(() => <MySheet />)). Define the component outside of render to avoid memory leaks and broken navigate() same-type detection.`);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* If `first` is a function (component), register it and return { type, id, data }.
|
|
1270
|
+
* Otherwise, pass through the string-based (type, id, data) args unchanged.
|
|
1271
|
+
*/
|
|
1272
|
+
function resolveArgs(componentRegistry, componentMap, getNextKey, warnedNames, first, second, third, fourth) {
|
|
1273
|
+
if (typeof first === "function") {
|
|
1274
|
+
const component = first;
|
|
1275
|
+
let typeKey = componentRegistry.get(component);
|
|
1276
|
+
if (!typeKey) {
|
|
1277
|
+
warnInlineComponent(component, componentRegistry, warnedNames);
|
|
1278
|
+
typeKey = getNextKey();
|
|
1279
|
+
componentRegistry.set(component, typeKey);
|
|
1280
|
+
componentMap.set(typeKey, component);
|
|
1281
|
+
}
|
|
1282
|
+
if (typeof second === "string") return {
|
|
1283
|
+
ariaLabel: resolvePresentationOptions(fourth)?.ariaLabel,
|
|
1284
|
+
type: typeKey,
|
|
1285
|
+
id: second,
|
|
1286
|
+
data: third ?? {}
|
|
1287
|
+
};
|
|
1288
|
+
return {
|
|
1289
|
+
ariaLabel: resolvePresentationOptions(third)?.ariaLabel,
|
|
1290
|
+
type: typeKey,
|
|
1291
|
+
id: crypto.randomUUID(),
|
|
1292
|
+
data: second ?? {}
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
ariaLabel: resolvePresentationOptions(fourth)?.ariaLabel,
|
|
1297
|
+
type: first,
|
|
1298
|
+
id: second,
|
|
1299
|
+
data: third ?? {}
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/Store/createSheetStore.ts
|
|
1304
|
+
/**
|
|
1305
|
+
* Create an isolated Zustand store for a sheet stack instance.
|
|
1306
|
+
*
|
|
1307
|
+
* Returns a store bundle containing the Zustand store plus two maps
|
|
1308
|
+
* that track ad-hoc (component-direct) registrations:
|
|
1309
|
+
* - `componentRegistry` — maps `ComponentType` → generated type key (dedup)
|
|
1310
|
+
* - `componentMap` — maps generated type key → `ComponentType` (renderer lookup)
|
|
1311
|
+
*
|
|
1312
|
+
* The ad-hoc counter is scoped per instance to prevent identity leaks across
|
|
1313
|
+
* multiple `createStacksheet()` calls or test runs.
|
|
1314
|
+
*/
|
|
1315
|
+
function createSheetStore(config) {
|
|
1316
|
+
const componentRegistry = /* @__PURE__ */ new Map();
|
|
1317
|
+
const componentMap = /* @__PURE__ */ new Map();
|
|
1318
|
+
let adhocCounter = 0;
|
|
1319
|
+
function getNextKey() {
|
|
1320
|
+
return `__adhoc_${adhocCounter++}`;
|
|
1321
|
+
}
|
|
1322
|
+
const warnedNames = /* @__PURE__ */ new Set();
|
|
1323
|
+
function resolve(first, second, third, fourth) {
|
|
1324
|
+
return resolveArgs(componentRegistry, componentMap, getNextKey, warnedNames, first, second, third, fourth);
|
|
1325
|
+
}
|
|
1326
|
+
/** Remove registry entries for type keys no longer in the stack */
|
|
1327
|
+
function pruneRegistry(remainingStack) {
|
|
1328
|
+
const usedTypes = new Set(remainingStack.map((item) => item.type));
|
|
1329
|
+
for (const [component, typeKey] of componentRegistry) if (!usedTypes.has(typeKey)) {
|
|
1330
|
+
componentRegistry.delete(component);
|
|
1331
|
+
componentMap.delete(typeKey);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
store: createStore()((set, get) => {
|
|
1336
|
+
function _openResolved({ type, id, data, ariaLabel }) {
|
|
1337
|
+
set({
|
|
1338
|
+
stack: [{
|
|
1339
|
+
id,
|
|
1340
|
+
type,
|
|
1341
|
+
data,
|
|
1342
|
+
ariaLabel
|
|
1343
|
+
}],
|
|
1344
|
+
isOpen: true
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
function _pushResolved({ type, id, data, ariaLabel }) {
|
|
1348
|
+
set((state) => {
|
|
1349
|
+
const item = {
|
|
1350
|
+
id,
|
|
1351
|
+
type,
|
|
1352
|
+
data,
|
|
1353
|
+
ariaLabel
|
|
1354
|
+
};
|
|
1355
|
+
if (Number.isFinite(config.maxDepth) && state.stack.length >= config.maxDepth) return {
|
|
1356
|
+
stack: [...state.stack.slice(0, -1), item],
|
|
1357
|
+
isOpen: true
|
|
1358
|
+
};
|
|
1359
|
+
return {
|
|
1360
|
+
stack: [...state.stack, item],
|
|
1361
|
+
isOpen: true
|
|
1362
|
+
};
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
function _replaceResolved({ type, id, data, ariaLabel }) {
|
|
1366
|
+
set((state) => {
|
|
1367
|
+
const item = {
|
|
1368
|
+
id,
|
|
1369
|
+
type,
|
|
1370
|
+
data,
|
|
1371
|
+
ariaLabel
|
|
1372
|
+
};
|
|
1373
|
+
if (state.stack.length === 0) return {
|
|
1374
|
+
stack: [item],
|
|
1375
|
+
isOpen: true
|
|
1376
|
+
};
|
|
1377
|
+
return {
|
|
1378
|
+
stack: [...state.stack.slice(0, -1), item],
|
|
1379
|
+
isOpen: true
|
|
1380
|
+
};
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
return {
|
|
1384
|
+
stack: [],
|
|
1385
|
+
isOpen: false,
|
|
1386
|
+
open(first, second, third, fourth) {
|
|
1387
|
+
_openResolved(resolve(first, second, third, fourth));
|
|
1388
|
+
},
|
|
1389
|
+
push(first, second, third, fourth) {
|
|
1390
|
+
_pushResolved(resolve(first, second, third, fourth));
|
|
1391
|
+
},
|
|
1392
|
+
replace(first, second, third, fourth) {
|
|
1393
|
+
_replaceResolved(resolve(first, second, third, fourth));
|
|
1394
|
+
},
|
|
1395
|
+
swap(first, second, third) {
|
|
1396
|
+
let type;
|
|
1397
|
+
let data;
|
|
1398
|
+
const ariaLabel = resolvePresentationOptions(third)?.ariaLabel;
|
|
1399
|
+
if (typeof first === "function") {
|
|
1400
|
+
const component = first;
|
|
1401
|
+
let typeKey = componentRegistry.get(component);
|
|
1402
|
+
if (!typeKey) {
|
|
1403
|
+
warnInlineComponent(component, componentRegistry, warnedNames);
|
|
1404
|
+
typeKey = getNextKey();
|
|
1405
|
+
componentRegistry.set(component, typeKey);
|
|
1406
|
+
componentMap.set(typeKey, component);
|
|
1407
|
+
}
|
|
1408
|
+
type = typeKey;
|
|
1409
|
+
data = second ?? {};
|
|
1410
|
+
} else {
|
|
1411
|
+
type = first;
|
|
1412
|
+
data = second ?? {};
|
|
1413
|
+
}
|
|
1414
|
+
set((state) => {
|
|
1415
|
+
const top = state.stack.at(-1);
|
|
1416
|
+
if (!top) return state;
|
|
1417
|
+
const newStack = [...state.stack];
|
|
1418
|
+
newStack[newStack.length - 1] = {
|
|
1419
|
+
id: top.id,
|
|
1420
|
+
type,
|
|
1421
|
+
data,
|
|
1422
|
+
ariaLabel: ariaLabel ?? top.ariaLabel
|
|
1423
|
+
};
|
|
1424
|
+
return { stack: newStack };
|
|
1425
|
+
});
|
|
1426
|
+
},
|
|
1427
|
+
navigate(first, second, third, fourth) {
|
|
1428
|
+
const resolved = resolve(first, second, third, fourth);
|
|
1429
|
+
const { stack } = get();
|
|
1430
|
+
const top = stack.at(-1);
|
|
1431
|
+
if (stack.length === 0) {
|
|
1432
|
+
_openResolved(resolved);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
let isSameType = top?.type === resolved.type;
|
|
1436
|
+
if (!isSameType && typeof first === "function") isSameType = componentMap.get(top?.type ?? "") === first;
|
|
1437
|
+
if (isSameType) {
|
|
1438
|
+
_replaceResolved(resolved);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
_pushResolved(resolved);
|
|
1442
|
+
},
|
|
1443
|
+
setData(first, second, third) {
|
|
1444
|
+
const { id, data } = resolve(first, second, third);
|
|
1445
|
+
set((state) => {
|
|
1446
|
+
const idx = state.stack.findIndex((item) => item.id === id);
|
|
1447
|
+
if (idx === -1) return state;
|
|
1448
|
+
const updated = [...state.stack];
|
|
1449
|
+
updated[idx] = {
|
|
1450
|
+
...updated[idx],
|
|
1451
|
+
data
|
|
1452
|
+
};
|
|
1453
|
+
return { stack: updated };
|
|
1454
|
+
});
|
|
1455
|
+
},
|
|
1456
|
+
remove(id) {
|
|
1457
|
+
set((state) => {
|
|
1458
|
+
const next = state.stack.filter((item) => item.id !== id);
|
|
1459
|
+
if (next.length === state.stack.length) return state;
|
|
1460
|
+
pruneRegistry(next);
|
|
1461
|
+
if (next.length === 0) return {
|
|
1462
|
+
stack: [],
|
|
1463
|
+
isOpen: false
|
|
1464
|
+
};
|
|
1465
|
+
return { stack: next };
|
|
1466
|
+
});
|
|
1467
|
+
},
|
|
1468
|
+
pop() {
|
|
1469
|
+
set((state) => {
|
|
1470
|
+
if (state.stack.length <= 1) {
|
|
1471
|
+
pruneRegistry([]);
|
|
1472
|
+
return {
|
|
1473
|
+
stack: [],
|
|
1474
|
+
isOpen: false
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
const next = state.stack.slice(0, -1);
|
|
1478
|
+
pruneRegistry(next);
|
|
1479
|
+
return {
|
|
1480
|
+
stack: next,
|
|
1481
|
+
isOpen: true
|
|
1482
|
+
};
|
|
1483
|
+
});
|
|
1484
|
+
},
|
|
1485
|
+
close() {
|
|
1486
|
+
pruneRegistry([]);
|
|
1487
|
+
set({
|
|
1488
|
+
stack: [],
|
|
1489
|
+
isOpen: false
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
}),
|
|
1494
|
+
componentRegistry,
|
|
1495
|
+
componentMap
|
|
1496
|
+
};
|
|
1497
|
+
}
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region src/create.tsx
|
|
1500
|
+
/**
|
|
1501
|
+
* Create an isolated sheet stack instance with typed store, hooks, and provider.
|
|
1502
|
+
*
|
|
1503
|
+
* Works with both `interface` and `type` definitions:
|
|
1504
|
+
*
|
|
1505
|
+
* ```ts
|
|
1506
|
+
* // Using an interface
|
|
1507
|
+
* interface SheetDataMap {
|
|
1508
|
+
* "bucket-create": { onCreated?: (b: Bucket) => void };
|
|
1509
|
+
* "bucket-edit": { bucket: Bucket };
|
|
1510
|
+
* }
|
|
1511
|
+
*
|
|
1512
|
+
* // Using a type alias
|
|
1513
|
+
* type SheetDataMap = {
|
|
1514
|
+
* "bucket-create": { onCreated?: (b: Bucket) => void };
|
|
1515
|
+
* "bucket-edit": { bucket: Bucket };
|
|
1516
|
+
* };
|
|
1517
|
+
*
|
|
1518
|
+
* const { StacksheetProvider, useSheet, useStacksheetState } =
|
|
1519
|
+
* createStacksheet<SheetDataMap>();
|
|
1520
|
+
* ```
|
|
1521
|
+
*
|
|
1522
|
+
* Sheet content components receive their data as **spread props**:
|
|
1523
|
+
* ```ts
|
|
1524
|
+
* // Data map defines: "bucket-edit": { bucket: Bucket }
|
|
1525
|
+
* // Component receives: ({ bucket }: { bucket: Bucket }) => JSX.Element
|
|
1526
|
+
* ```
|
|
1527
|
+
*
|
|
1528
|
+
* Use `useSheetPanel()` inside content components to access `close()` and `back()`.
|
|
1529
|
+
*/
|
|
1530
|
+
function createStacksheet(config) {
|
|
1531
|
+
const resolved = resolveConfig(config);
|
|
1532
|
+
const { store, componentMap } = createSheetStore(resolved);
|
|
1533
|
+
const StoreContext = createContext(null);
|
|
1534
|
+
function useStoreContext() {
|
|
1535
|
+
const ctx = useContext(StoreContext);
|
|
1536
|
+
if (!ctx) throw new Error("useSheet/useStacksheetState must be used within <StacksheetProvider>");
|
|
1537
|
+
return ctx;
|
|
1538
|
+
}
|
|
1539
|
+
const EMPTY_SHEETS = {};
|
|
1540
|
+
function StacksheetProvider({ sheets = EMPTY_SHEETS, children, classNames, layout, renderHeader }) {
|
|
1541
|
+
const value = useMemo(() => ({
|
|
1542
|
+
store,
|
|
1543
|
+
config: resolved
|
|
1544
|
+
}), []);
|
|
1545
|
+
return /* @__PURE__ */ jsxs(StoreContext.Provider, {
|
|
1546
|
+
value,
|
|
1547
|
+
children: [children, /* @__PURE__ */ jsx(Portal, {
|
|
1548
|
+
asChild: false,
|
|
1549
|
+
children: /* @__PURE__ */ jsx(SheetRenderer, {
|
|
1550
|
+
classNames,
|
|
1551
|
+
componentMap,
|
|
1552
|
+
config: resolved,
|
|
1553
|
+
layout,
|
|
1554
|
+
renderHeader,
|
|
1555
|
+
sheets,
|
|
1556
|
+
store
|
|
1557
|
+
})
|
|
1558
|
+
})]
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
function useSheet() {
|
|
1562
|
+
const { store: s } = useStoreContext();
|
|
1563
|
+
return useMemo(() => {
|
|
1564
|
+
const state = s.getState();
|
|
1565
|
+
return {
|
|
1566
|
+
open: state.open,
|
|
1567
|
+
push: state.push,
|
|
1568
|
+
replace: state.replace,
|
|
1569
|
+
swap: state.swap,
|
|
1570
|
+
navigate: state.navigate,
|
|
1571
|
+
setData: state.setData,
|
|
1572
|
+
remove: state.remove,
|
|
1573
|
+
pop: state.pop,
|
|
1574
|
+
close: state.close
|
|
1575
|
+
};
|
|
1576
|
+
}, [s]);
|
|
1577
|
+
}
|
|
1578
|
+
function useStacksheetState() {
|
|
1579
|
+
const { store: s } = useStoreContext();
|
|
1580
|
+
return useStore(s, useShallow((state) => ({
|
|
1581
|
+
stack: state.stack,
|
|
1582
|
+
isOpen: state.isOpen
|
|
1583
|
+
})));
|
|
1584
|
+
}
|
|
1585
|
+
return {
|
|
1586
|
+
StacksheetProvider,
|
|
1587
|
+
useSheet,
|
|
1588
|
+
useStacksheetState,
|
|
1589
|
+
store
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
//#endregion
|
|
1593
|
+
//#region src/SheetParts/SheetBack.tsx
|
|
1594
|
+
function SheetBack({ asChild, className, style, children }) {
|
|
1595
|
+
const { back, isNested } = useSheetPanel();
|
|
1596
|
+
if (!isNested) return null;
|
|
1597
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "button", {
|
|
1598
|
+
"aria-label": children ? void 0 : "Back",
|
|
1599
|
+
className: joinClassNames(asChild ? void 0 : "flex min-h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-inherit opacity-60 transition-opacity duration-150 hover:opacity-100", className),
|
|
1600
|
+
onClick: back,
|
|
1601
|
+
style,
|
|
1602
|
+
type: asChild ? void 0 : "button",
|
|
1603
|
+
children: children ?? /* @__PURE__ */ jsx(ArrowLeftIcon, {})
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
//#endregion
|
|
1607
|
+
//#region src/SheetParts/SheetBody.tsx
|
|
1608
|
+
function SheetBody({ asChild, className, style, children }) {
|
|
1609
|
+
if (asChild) return /* @__PURE__ */ jsx(Slot, {
|
|
1610
|
+
className: joinClassNames("relative min-h-0 flex-1", className),
|
|
1611
|
+
"data-stacksheet-no-drag": "",
|
|
1612
|
+
style,
|
|
1613
|
+
children
|
|
1614
|
+
});
|
|
1615
|
+
return /* @__PURE__ */ jsxs(Root, {
|
|
1616
|
+
className: joinClassNames("relative flex min-h-0 flex-1 flex-col overflow-hidden", className),
|
|
1617
|
+
"data-stacksheet-no-drag": "",
|
|
1618
|
+
style,
|
|
1619
|
+
children: [/* @__PURE__ */ jsx(Viewport, {
|
|
1620
|
+
className: "min-h-0 w-full flex-1 overscroll-contain",
|
|
1621
|
+
children
|
|
1622
|
+
}), /* @__PURE__ */ jsx(Scrollbar, {
|
|
1623
|
+
className: "flex w-2 touch-none select-none p-0.5",
|
|
1624
|
+
orientation: "vertical",
|
|
1625
|
+
children: /* @__PURE__ */ jsx(Thumb, { className: "relative flex-1 rounded bg-current/15" })
|
|
1626
|
+
})]
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
//#endregion
|
|
1630
|
+
//#region src/SheetParts/SheetClose.tsx
|
|
1631
|
+
function SheetClose({ asChild, className, style, children }) {
|
|
1632
|
+
const { close } = useSheetPanel();
|
|
1633
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "button", {
|
|
1634
|
+
"aria-label": children ? void 0 : "Close",
|
|
1635
|
+
className: joinClassNames(asChild ? void 0 : "flex min-h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-inherit opacity-60 transition-opacity duration-150 hover:opacity-100", className),
|
|
1636
|
+
onClick: close,
|
|
1637
|
+
style,
|
|
1638
|
+
type: asChild ? void 0 : "button",
|
|
1639
|
+
children: children ?? /* @__PURE__ */ jsx(XIcon, {})
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region src/SheetParts/SheetDescription.tsx
|
|
1644
|
+
function SheetDescription({ asChild, className, style, children }) {
|
|
1645
|
+
const { panelId, registerDescription } = useSheetPanel();
|
|
1646
|
+
useEffect(() => registerDescription(), [registerDescription]);
|
|
1647
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "p", {
|
|
1648
|
+
className,
|
|
1649
|
+
id: `${panelId}-desc`,
|
|
1650
|
+
style,
|
|
1651
|
+
children
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
//#endregion
|
|
1655
|
+
//#region src/SheetParts/SheetFooter.tsx
|
|
1656
|
+
function SheetFooter({ asChild, className, style, children }) {
|
|
1657
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "footer", {
|
|
1658
|
+
className: joinClassNames(asChild ? "shrink-0" : "flex shrink-0 items-center gap-2 border-t px-6 py-3", className),
|
|
1659
|
+
style,
|
|
1660
|
+
children
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
//#endregion
|
|
1664
|
+
//#region src/SheetParts/SheetHandle.tsx
|
|
1665
|
+
function SheetHandle({ asChild, className, style, children }) {
|
|
1666
|
+
const { close, back, isNested } = useSheetPanel();
|
|
1667
|
+
const dismiss = isNested ? back : close;
|
|
1668
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "div", {
|
|
1669
|
+
"aria-label": "Dismiss",
|
|
1670
|
+
className: joinClassNames(asChild ? void 0 : "flex shrink-0 cursor-grab touch-none items-center justify-center pt-4 pb-1", className),
|
|
1671
|
+
"data-stacksheet-handle": "",
|
|
1672
|
+
onClick: dismiss,
|
|
1673
|
+
onKeyDown: (e) => {
|
|
1674
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1675
|
+
e.preventDefault();
|
|
1676
|
+
dismiss();
|
|
1677
|
+
}
|
|
1678
|
+
},
|
|
1679
|
+
role: "button",
|
|
1680
|
+
style,
|
|
1681
|
+
tabIndex: 0,
|
|
1682
|
+
children: children ?? /* @__PURE__ */ jsx("div", {
|
|
1683
|
+
"aria-hidden": "true",
|
|
1684
|
+
className: "h-1 w-9 rounded-sm bg-current/25"
|
|
1685
|
+
})
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
//#endregion
|
|
1689
|
+
//#region src/SheetParts/SheetHeader.tsx
|
|
1690
|
+
function SheetHeader({ asChild, className, style, children }) {
|
|
1691
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "header", {
|
|
1692
|
+
className: joinClassNames(asChild ? "shrink-0" : "flex h-14 shrink-0 items-center justify-between border-b px-6", className),
|
|
1693
|
+
style,
|
|
1694
|
+
children
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
//#endregion
|
|
1698
|
+
//#region src/SheetParts/SheetTitle.tsx
|
|
1699
|
+
function SheetTitle({ asChild, className, style, children }) {
|
|
1700
|
+
const { panelId } = useSheetPanel();
|
|
1701
|
+
return /* @__PURE__ */ jsx(asChild ? Slot : "h2", {
|
|
1702
|
+
className: joinClassNames(asChild ? void 0 : "font-semibold text-sm", className),
|
|
1703
|
+
id: `${panelId}-title`,
|
|
1704
|
+
style,
|
|
1705
|
+
children
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
//#endregion
|
|
1709
|
+
//#region src/SheetParts/SheetParts.ts
|
|
1710
|
+
/**
|
|
1711
|
+
* Composable Sheet Parts for building custom Sheet layouts.
|
|
1712
|
+
*
|
|
1713
|
+
* Use with `layout="composable"` on the provider to opt into composable mode:
|
|
1714
|
+
* no auto header or scroll wrapper, full control over the Sheet structure.
|
|
1715
|
+
*
|
|
1716
|
+
* `Sheet.Title` and `Sheet.Description` are linked to the Panel's
|
|
1717
|
+
* `aria-labelledby` and `aria-describedby` via matching IDs.
|
|
1718
|
+
*/
|
|
1719
|
+
const Sheet = {
|
|
1720
|
+
Back: SheetBack,
|
|
1721
|
+
Body: SheetBody,
|
|
1722
|
+
Close: SheetClose,
|
|
1723
|
+
Description: SheetDescription,
|
|
1724
|
+
Footer: SheetFooter,
|
|
1725
|
+
Handle: SheetHandle,
|
|
1726
|
+
Header: SheetHeader,
|
|
1727
|
+
Title: SheetTitle
|
|
1728
|
+
};
|
|
1729
|
+
//#endregion
|
|
1730
|
+
export { Sheet, createStacksheet, springs, useIsMobile, useResolvedSide, useSheetPanel };
|
|
1731
|
+
|
|
1732
|
+
//# sourceMappingURL=index.mjs.map
|