@howells/stacksheet 1.0.1 → 1.1.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/dist/index.js CHANGED
@@ -1,8 +1,6 @@
1
1
  // src/springs.ts
2
2
  var springs = {
3
- soft: { stiffness: 120, damping: 18, mass: 1 },
4
3
  subtle: { stiffness: 300, damping: 30, mass: 1 },
5
- natural: { stiffness: 200, damping: 20, mass: 1 },
6
4
  snappy: { stiffness: 400, damping: 28, mass: 0.8 },
7
5
  stiff: { stiffness: 400, damping: 40, mass: 1 }
8
6
  };
@@ -10,7 +8,7 @@ var springs = {
10
8
  // src/config.ts
11
9
  var DEFAULT_STACKING = {
12
10
  scaleStep: 0.04,
13
- offsetStep: 36,
11
+ offsetStep: 16,
14
12
  opacityStep: 0,
15
13
  radius: 12,
16
14
  renderThreshold: 3
@@ -19,9 +17,19 @@ var DEFAULT_SIDE = {
19
17
  desktop: "right",
20
18
  mobile: "bottom"
21
19
  };
20
+ function resolveSide(side) {
21
+ if (typeof side === "string") {
22
+ return { desktop: side, mobile: side };
23
+ }
24
+ return { ...DEFAULT_SIDE, ...side };
25
+ }
26
+ function resolveSpring(spring) {
27
+ if (typeof spring === "string") {
28
+ return springs[spring];
29
+ }
30
+ return { ...springs.stiff, ...spring };
31
+ }
22
32
  function resolveConfig(config = {}) {
23
- const side = typeof config.side === "string" ? { desktop: config.side, mobile: config.side } : { ...DEFAULT_SIDE, ...config.side };
24
- const spring = typeof config.spring === "string" ? springs[config.spring] : { ...springs.stiff, ...config.spring };
25
33
  return {
26
34
  maxDepth: config.maxDepth ?? Number.POSITIVE_INFINITY,
27
35
  closeOnEscape: config.closeOnEscape ?? true,
@@ -31,13 +39,17 @@ function resolveConfig(config = {}) {
31
39
  width: config.width ?? 420,
32
40
  maxWidth: config.maxWidth ?? "90vw",
33
41
  breakpoint: config.breakpoint ?? 768,
34
- side,
42
+ side: resolveSide(config.side),
35
43
  stacking: { ...DEFAULT_STACKING, ...config.stacking },
36
- spring,
44
+ spring: resolveSpring(config.spring),
37
45
  zIndex: config.zIndex ?? 100,
38
46
  ariaLabel: config.ariaLabel ?? "Sheet dialog",
39
47
  onOpenComplete: config.onOpenComplete,
40
48
  onCloseComplete: config.onCloseComplete,
49
+ snapPoints: config.snapPoints ?? [],
50
+ snapPointIndex: config.snapPointIndex,
51
+ onSnapPointChange: config.onSnapPointChange,
52
+ snapToSequentialPoints: config.snapToSequentialPoints ?? false,
41
53
  drag: config.drag ?? true,
42
54
  closeThreshold: config.closeThreshold ?? 0.25,
43
55
  velocityThreshold: config.velocityThreshold ?? 0.5,
@@ -137,6 +149,96 @@ function useSheetPanel() {
137
149
  return ctx;
138
150
  }
139
151
 
152
+ // src/snap-points.ts
153
+ var SNAP_POINT_RE = /^(\d+(?:\.\d+)?)(px|rem|em|vh|%)$/;
154
+ function resolveSnapPointPx(point, viewportHeight) {
155
+ if (typeof point === "number") {
156
+ return point <= 1 ? point * viewportHeight : point;
157
+ }
158
+ if (typeof point === "string") {
159
+ const match = point.match(SNAP_POINT_RE);
160
+ if (!match?.[1]) {
161
+ return 0;
162
+ }
163
+ const value = Number.parseFloat(match[1]);
164
+ const unit = match[2];
165
+ switch (unit) {
166
+ case "px":
167
+ return value;
168
+ case "rem":
169
+ case "em":
170
+ return value * Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
171
+ case "vh":
172
+ case "%":
173
+ return value / 100 * viewportHeight;
174
+ default:
175
+ return 0;
176
+ }
177
+ }
178
+ return 0;
179
+ }
180
+ function resolveSnapPoints(points, viewportHeight) {
181
+ if (points.length === 0) {
182
+ return [];
183
+ }
184
+ const resolved = points.map((p) => resolveSnapPointPx(p, viewportHeight)).filter((px) => px > 0);
185
+ resolved.sort((a, b) => a - b);
186
+ const deduped = [];
187
+ for (const px of resolved) {
188
+ const last = deduped.at(-1);
189
+ if (last === void 0 || Math.abs(px - last) > 1) {
190
+ deduped.push(px);
191
+ }
192
+ }
193
+ return deduped;
194
+ }
195
+ var SNAP_VELOCITY_THRESHOLD = 0.4;
196
+ var MAX_SNAP_VELOCITY = 2;
197
+ var SNAP_VELOCITY_MULTIPLIER = 150;
198
+ function findSnapTarget(dragOffset, panelHeight, snapHeights, velocity, currentIndex, sequential) {
199
+ if (snapHeights.length === 0) {
200
+ return -1;
201
+ }
202
+ const snapOffsets = snapHeights.map((h) => panelHeight - h);
203
+ const currentPos = dragOffset;
204
+ if (sequential) {
205
+ const direction = velocity > 0 ? 1 : -1;
206
+ const nextIndex = currentIndex - direction;
207
+ if (nextIndex < 0) {
208
+ return -1;
209
+ }
210
+ if (nextIndex >= snapHeights.length) {
211
+ return snapHeights.length - 1;
212
+ }
213
+ return nextIndex;
214
+ }
215
+ const velocityOffset = Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD ? Math.min(Math.max(velocity, -MAX_SNAP_VELOCITY), MAX_SNAP_VELOCITY) * SNAP_VELOCITY_MULTIPLIER : 0;
216
+ const projectedPos = currentPos + velocityOffset;
217
+ const first = snapOffsets[0] ?? 0;
218
+ let bestIndex = 0;
219
+ let bestDist = Math.abs(projectedPos - first);
220
+ for (let i = 1; i < snapOffsets.length; i++) {
221
+ const offset = snapOffsets[i] ?? 0;
222
+ const dist = Math.abs(projectedPos - offset);
223
+ if (dist < bestDist) {
224
+ bestDist = dist;
225
+ bestIndex = i;
226
+ }
227
+ }
228
+ const dismissDist = Math.abs(projectedPos - panelHeight);
229
+ if (dismissDist <= bestDist) {
230
+ return -1;
231
+ }
232
+ return bestIndex;
233
+ }
234
+ function getSnapOffset(snapIndex, snapHeights, panelHeight) {
235
+ if (snapIndex < 0 || snapIndex >= snapHeights.length) {
236
+ return 0;
237
+ }
238
+ const targetHeight = snapHeights[snapIndex] ?? 0;
239
+ return panelHeight - targetHeight;
240
+ }
241
+
140
242
  // src/stacking.ts
141
243
  function getStackTransform(depth, stacking) {
142
244
  if (depth <= 0) {
@@ -181,6 +283,30 @@ function getSlideFrom(side) {
181
283
  function getSlideTarget() {
182
284
  return { x: 0, y: 0 };
183
285
  }
286
+ function getStackOffset(side, offset) {
287
+ if (offset === 0) {
288
+ return {};
289
+ }
290
+ switch (side) {
291
+ case "right":
292
+ return { x: -offset };
293
+ case "left":
294
+ return { x: offset };
295
+ case "bottom":
296
+ return { y: -offset };
297
+ default:
298
+ return {};
299
+ }
300
+ }
301
+ function getTransformOrigin(side) {
302
+ if (side === "right") {
303
+ return "left center";
304
+ }
305
+ if (side === "left") {
306
+ return "right center";
307
+ }
308
+ return "center top";
309
+ }
184
310
  function getPanelStyles(side, config, _depth, index) {
185
311
  const { width, maxWidth, zIndex } = config;
186
312
  const base = {
@@ -188,9 +314,8 @@ function getPanelStyles(side, config, _depth, index) {
188
314
  zIndex: zIndex + 10 + index,
189
315
  display: "flex",
190
316
  flexDirection: "column",
191
- overflow: "hidden",
192
317
  willChange: "transform",
193
- transformOrigin: side === "bottom" ? "center bottom" : `${side} center`
318
+ transformOrigin: getTransformOrigin(side)
194
319
  };
195
320
  if (side === "bottom") {
196
321
  return {
@@ -235,6 +360,29 @@ function isInteractiveElement(el) {
235
360
  }
236
361
  return false;
237
362
  }
363
+ function findScrollableAncestor(el, axis) {
364
+ let current = el;
365
+ while (current) {
366
+ if (current instanceof HTMLElement) {
367
+ const style = getComputedStyle(current);
368
+ const overflow = axis === "y" ? style.overflowY : style.overflowX;
369
+ if (overflow === "auto" || overflow === "scroll") {
370
+ const scrollable = axis === "y" ? current.scrollHeight > current.clientHeight : current.scrollWidth > current.clientWidth;
371
+ if (scrollable) {
372
+ return current;
373
+ }
374
+ }
375
+ }
376
+ current = current.parentElement;
377
+ }
378
+ return null;
379
+ }
380
+ function isAtScrollEdge(el, axis, sign) {
381
+ if (axis === "y") {
382
+ return sign === 1 ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
383
+ }
384
+ return sign === 1 ? el.scrollLeft <= 0 : el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
385
+ }
238
386
  function getDismissAxis(side) {
239
387
  switch (side) {
240
388
  case "right":
@@ -249,6 +397,7 @@ function getDismissAxis(side) {
249
397
  }
250
398
  var DEAD_ZONE = 10;
251
399
  var MAX_ANGLE_DEG = 35;
400
+ var RUBBER_BAND_FACTOR = 0.6;
252
401
  function classifyGesture(dx, dy, axis, sign) {
253
402
  const absDx = Math.abs(dx);
254
403
  const absDy = Math.abs(dy);
@@ -277,6 +426,7 @@ function useDrag(panelRef, config, onDragUpdate) {
277
426
  const startRef = useRef(null);
278
427
  const committedRef = useRef(null);
279
428
  const offsetRef = useRef(0);
429
+ const scrollTargetRef = useRef(null);
280
430
  const { axis, sign } = getDismissAxis(config.side);
281
431
  const handlePointerDown = useCallback(
282
432
  (e) => {
@@ -294,12 +444,7 @@ function useDrag(panelRef, config, onDragUpdate) {
294
444
  if (!isHandle && isInteractiveElement(target)) {
295
445
  return;
296
446
  }
297
- if (!isHandle) {
298
- const scrollable = target.closest("[data-radix-scroll-area-viewport]");
299
- if (scrollable && scrollable.scrollTop > 0 && axis === "y") {
300
- return;
301
- }
302
- }
447
+ scrollTargetRef.current = isHandle ? null : findScrollableAncestor(target, axis);
303
448
  startRef.current = { x: e.clientX, y: e.clientY, time: Date.now() };
304
449
  committedRef.current = null;
305
450
  offsetRef.current = 0;
@@ -319,66 +464,88 @@ function useDrag(panelRef, config, onDragUpdate) {
319
464
  return;
320
465
  }
321
466
  if (committedRef.current === null) {
322
- committedRef.current = classifyGesture(dx, dy, axis, sign);
323
- if (committedRef.current === "none") {
467
+ const gesture = classifyGesture(dx, dy, axis, sign);
468
+ if (gesture === "none") {
469
+ committedRef.current = "none";
324
470
  startRef.current = null;
325
471
  return;
326
472
  }
473
+ const scrollEl = scrollTargetRef.current;
474
+ if (scrollEl && !isAtScrollEdge(scrollEl, axis, sign)) {
475
+ committedRef.current = "none";
476
+ startRef.current = null;
477
+ return;
478
+ }
479
+ committedRef.current = "drag";
327
480
  }
328
481
  if (committedRef.current !== "drag") {
329
482
  return;
330
483
  }
331
484
  const rawOffset = axis === "x" ? dx : dy;
332
- const clampedOffset = Math.max(0, rawOffset * sign);
485
+ const directional = rawOffset * sign;
486
+ const clampedOffset = directional >= 0 ? directional : -Math.sqrt(Math.abs(directional)) * RUBBER_BAND_FACTOR;
333
487
  offsetRef.current = clampedOffset;
334
488
  onDragUpdate({ offset: clampedOffset, isDragging: true });
335
489
  e.preventDefault();
336
490
  },
337
491
  [axis, sign, onDragUpdate]
338
492
  );
493
+ const dismiss = useCallback(() => {
494
+ if (config.isNested) {
495
+ config.onPop();
496
+ } else {
497
+ config.onClose();
498
+ }
499
+ onDragUpdate({ offset: 0, isDragging: false });
500
+ }, [config, onDragUpdate]);
339
501
  const handlePointerUp = useCallback(
340
502
  (_e) => {
341
503
  if (!startRef.current || committedRef.current !== "drag") {
342
504
  startRef.current = null;
343
505
  committedRef.current = null;
506
+ scrollTargetRef.current = null;
344
507
  return;
345
508
  }
346
- const offset = offsetRef.current;
509
+ const offset = Math.max(0, offsetRef.current);
347
510
  const elapsed = Date.now() - startRef.current.time;
348
511
  const velocity = elapsed > 0 ? offset / elapsed : 0;
349
512
  startRef.current = null;
350
513
  committedRef.current = null;
351
514
  offsetRef.current = 0;
515
+ scrollTargetRef.current = null;
352
516
  const panelSize = getPanelDimension(panelRef.current, axis);
517
+ if (config.snapHeights.length > 0) {
518
+ const targetIndex = findSnapTarget(
519
+ offset,
520
+ panelSize,
521
+ config.snapHeights,
522
+ velocity,
523
+ config.activeSnapIndex,
524
+ config.sequential
525
+ );
526
+ if (targetIndex === -1) {
527
+ dismiss();
528
+ } else {
529
+ config.onSnap(targetIndex);
530
+ onDragUpdate({ offset: 0, isDragging: false });
531
+ }
532
+ return;
533
+ }
353
534
  const pastThreshold = offset / panelSize > config.closeThreshold;
354
535
  const fastEnough = velocity > config.velocityThreshold;
355
536
  if (pastThreshold || fastEnough) {
356
- if (config.isNested) {
357
- config.onPop();
358
- } else {
359
- config.onClose();
360
- }
361
- onDragUpdate({ offset: 0, isDragging: false });
537
+ dismiss();
362
538
  } else {
363
539
  onDragUpdate({ offset: 0, isDragging: false });
364
540
  }
365
541
  },
366
- [
367
- panelRef,
368
- axis,
369
- config.closeThreshold,
370
- config.velocityThreshold,
371
- config.isNested,
372
- config.onClose,
373
- config.onPop,
374
- onDragUpdate,
375
- config
376
- ]
542
+ [panelRef, axis, config, onDragUpdate, dismiss]
377
543
  );
378
544
  const handlePointerCancel = useCallback(() => {
379
545
  startRef.current = null;
380
546
  committedRef.current = null;
381
547
  offsetRef.current = 0;
548
+ scrollTargetRef.current = null;
382
549
  onDragUpdate({ offset: 0, isDragging: false });
383
550
  }, [onDragUpdate]);
384
551
  useEffect2(() => {
@@ -408,82 +575,40 @@ function useDrag(panelRef, config, onDragUpdate) {
408
575
 
409
576
  // src/renderer.tsx
410
577
  import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime";
411
- var BUTTON_STYLE = {
412
- display: "inline-flex",
413
- alignItems: "center",
414
- justifyContent: "center",
415
- width: 32,
416
- height: 32,
417
- borderRadius: "50%",
418
- border: "none",
419
- background: "transparent",
420
- cursor: "pointer",
421
- color: "inherit",
422
- opacity: 0.5,
423
- transition: "opacity 150ms",
424
- padding: 0
425
- };
426
- var HANDLE_BAR_STYLE = {
427
- width: 36,
428
- height: 4,
429
- borderRadius: 2,
430
- background: "var(--muted-foreground, rgba(0, 0, 0, 0.25))"
431
- };
432
- function DefaultHeader({ isNested, onBack, onClose, side }) {
433
- return /* @__PURE__ */ jsxs(Fragment, { children: [
434
- side === "bottom" && /* @__PURE__ */ jsx2(
435
- "div",
436
- {
437
- "data-stacksheet-handle": "",
438
- style: {
439
- display: "flex",
440
- alignItems: "center",
441
- justifyContent: "center",
442
- padding: "12px 0 4px",
443
- flexShrink: 0,
444
- cursor: "grab",
445
- touchAction: "none"
446
- },
447
- children: /* @__PURE__ */ jsx2("div", { style: HANDLE_BAR_STYLE })
448
- }
449
- ),
450
- /* @__PURE__ */ jsxs(
451
- "div",
452
- {
453
- style: {
454
- display: "flex",
455
- alignItems: "center",
456
- height: 48,
457
- flexShrink: 0,
458
- padding: "0 12px",
459
- borderBottom: "1px solid var(--border, transparent)"
460
- },
461
- children: [
462
- isNested && /* @__PURE__ */ jsx2(
463
- "button",
464
- {
465
- "aria-label": "Back",
466
- onClick: onBack,
467
- style: BUTTON_STYLE,
468
- type: "button",
469
- children: /* @__PURE__ */ jsx2(ArrowLeftIcon, {})
470
- }
471
- ),
472
- /* @__PURE__ */ jsx2("div", { style: { flex: 1 } }),
473
- /* @__PURE__ */ jsx2(
474
- "button",
475
- {
476
- "aria-label": "Close",
477
- onClick: onClose,
478
- style: BUTTON_STYLE,
479
- type: "button",
480
- children: /* @__PURE__ */ jsx2(XIcon, {})
481
- }
482
- )
483
- ]
484
- }
485
- )
486
- ] });
578
+ function DefaultHeader({
579
+ isNested,
580
+ onBack,
581
+ onClose,
582
+ className
583
+ }) {
584
+ return /* @__PURE__ */ jsxs(
585
+ "div",
586
+ {
587
+ className: `flex h-14 shrink-0 items-center justify-between border-b px-6 ${className ?? ""}`,
588
+ children: [
589
+ /* @__PURE__ */ jsx2("div", { className: "flex items-center gap-2", children: isNested && /* @__PURE__ */ jsx2(
590
+ "button",
591
+ {
592
+ "aria-label": "Back",
593
+ className: "flex h-8 w-8 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",
594
+ onClick: onBack,
595
+ type: "button",
596
+ children: /* @__PURE__ */ jsx2(ArrowLeftIcon, {})
597
+ }
598
+ ) }),
599
+ /* @__PURE__ */ jsx2(
600
+ "button",
601
+ {
602
+ "aria-label": "Close",
603
+ className: "flex h-8 w-8 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",
604
+ onClick: onClose,
605
+ type: "button",
606
+ children: /* @__PURE__ */ jsx2(XIcon, {})
607
+ }
608
+ )
609
+ ]
610
+ }
611
+ );
487
612
  }
488
613
  var EMPTY_CLASSNAMES = {
489
614
  backdrop: "",
@@ -534,6 +659,102 @@ function getDragTransform(side, offset) {
534
659
  return {};
535
660
  }
536
661
  }
662
+ function usePanelHeight(panelRef, hasSnapPoints) {
663
+ const [height, setHeight] = useState2(0);
664
+ useEffect3(() => {
665
+ const el = panelRef.current;
666
+ if (!(el && hasSnapPoints)) {
667
+ return;
668
+ }
669
+ setHeight(el.offsetHeight);
670
+ const observer = new ResizeObserver(([entry]) => {
671
+ if (entry) {
672
+ setHeight(entry.contentRect.height);
673
+ }
674
+ });
675
+ observer.observe(el);
676
+ return () => observer.disconnect();
677
+ }, [panelRef, hasSnapPoints]);
678
+ return height;
679
+ }
680
+ function buildPanelStyle(panelStyles, isTop, hasPanelClass, isDragging) {
681
+ return {
682
+ ...panelStyles,
683
+ pointerEvents: isTop ? "auto" : "none",
684
+ ...isTop ? {} : { contain: "layout style paint" },
685
+ ...isDragging ? { transition: "none" } : {},
686
+ ...hasPanelClass ? {} : {
687
+ background: "var(--background, #fff)",
688
+ borderColor: "var(--border, transparent)"
689
+ }
690
+ };
691
+ }
692
+ function buildPanelTransition(isDragging, isTop, spring, stackSpring) {
693
+ const visualTween = {
694
+ type: "tween",
695
+ duration: 0.25,
696
+ ease: "easeOut"
697
+ };
698
+ if (isDragging) {
699
+ return { type: "tween", duration: 0 };
700
+ }
701
+ const base = selectSpring(isTop, spring, stackSpring);
702
+ return { ...base, borderRadius: visualTween, boxShadow: visualTween };
703
+ }
704
+ function computeSnapYOffset(side, snapHeights, activeSnapIndex, measuredHeight) {
705
+ if (side !== "bottom" || snapHeights.length === 0 || measuredHeight <= 0) {
706
+ return 0;
707
+ }
708
+ return getSnapOffset(activeSnapIndex, snapHeights, measuredHeight);
709
+ }
710
+ function PanelInnerContent({
711
+ isComposable,
712
+ shouldRender,
713
+ Content,
714
+ data,
715
+ renderHeader,
716
+ headerProps,
717
+ headerClassName
718
+ }) {
719
+ if (isComposable) {
720
+ return shouldRender && Content ? /* @__PURE__ */ jsx2(Content, { ...data }) : null;
721
+ }
722
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
723
+ renderHeader ? renderHeader(headerProps) : /* @__PURE__ */ jsx2(DefaultHeader, { ...headerProps, className: headerClassName }),
724
+ shouldRender && Content && /* @__PURE__ */ jsx2(
725
+ "div",
726
+ {
727
+ className: "min-h-0 flex-1 overflow-y-auto overscroll-contain",
728
+ "data-stacksheet-no-drag": "",
729
+ children: /* @__PURE__ */ jsx2(Content, { ...data })
730
+ }
731
+ )
732
+ ] });
733
+ }
734
+ function BottomHandle() {
735
+ return /* @__PURE__ */ jsx2(
736
+ "div",
737
+ {
738
+ className: "flex shrink-0 cursor-grab touch-none items-center justify-center pt-4 pb-1",
739
+ "data-stacksheet-handle": "",
740
+ children: /* @__PURE__ */ jsx2("div", { className: "h-1 w-9 rounded-sm bg-current/25" })
741
+ }
742
+ );
743
+ }
744
+ function SideHandle({ side, isHovered }) {
745
+ const position = side === "right" ? { right: "100%" } : { left: "100%" };
746
+ return /* @__PURE__ */ jsx2(
747
+ m.div,
748
+ {
749
+ animate: { opacity: isHovered ? 1 : 0 },
750
+ className: "absolute top-0 bottom-0 flex w-6 cursor-grab touch-none items-center justify-center",
751
+ "data-stacksheet-handle": "",
752
+ style: position,
753
+ transition: { duration: isHovered ? 0.15 : 0.4, ease: "easeOut" },
754
+ children: /* @__PURE__ */ jsx2("div", { className: "h-10 w-[5px] rounded-sm bg-current/35 shadow-sm" })
755
+ }
756
+ );
757
+ }
537
758
  function SheetPanel({
538
759
  item,
539
760
  index,
@@ -547,6 +768,11 @@ function SheetPanel({
547
768
  shouldRender,
548
769
  pop,
549
770
  close,
771
+ swipeClose,
772
+ swipePop,
773
+ snapHeights,
774
+ activeSnapIndex,
775
+ onSnap,
550
776
  renderHeader,
551
777
  slideFrom,
552
778
  slideTarget,
@@ -560,9 +786,10 @@ function SheetPanel({
560
786
  offset: 0,
561
787
  isDragging: false
562
788
  });
789
+ const [isHovered, setIsHovered] = useState2(false);
790
+ const measuredHeight = usePanelHeight(panelRef, snapHeights.length > 0);
563
791
  const transform = getStackTransform(depth, config.stacking);
564
792
  const panelStyles = getPanelStyles(side, config, depth, index);
565
- const stackOffset = getStackingOffset(side, transform.offset);
566
793
  useEffect3(() => {
567
794
  if (!isTop) {
568
795
  hasEnteredRef.current = false;
@@ -581,9 +808,13 @@ function SheetPanel({
581
808
  closeThreshold: config.closeThreshold,
582
809
  velocityThreshold: config.velocityThreshold,
583
810
  side,
584
- onClose: close,
585
- onPop: pop,
586
- isNested
811
+ onClose: swipeClose,
812
+ onPop: swipePop,
813
+ isNested,
814
+ snapHeights,
815
+ activeSnapIndex,
816
+ onSnap,
817
+ sequential: config.snapToSequentialPoints
587
818
  },
588
819
  setDragState
589
820
  );
@@ -596,33 +827,39 @@ function SheetPanel({
596
827
  const isComposable = renderHeader === false;
597
828
  const hasPanelClass = classNames.panel !== "";
598
829
  const dragOffset = getDragTransform(side, dragState.offset);
599
- const panelStyle = {
600
- ...panelStyles,
601
- boxShadow: isTop ? getShadow(side, false) : getShadow(side, true),
602
- pointerEvents: isTop ? "auto" : "none",
603
- // During drag, disable spring transition for immediate feedback
604
- ...dragState.isDragging ? { transition: "none" } : {},
605
- ...hasPanelClass ? {} : {
606
- background: "var(--background, #fff)",
607
- borderColor: "var(--border, transparent)"
608
- }
609
- };
830
+ const panelStyle = buildPanelStyle(
831
+ panelStyles,
832
+ isTop,
833
+ hasPanelClass,
834
+ dragState.isDragging
835
+ );
610
836
  const headerProps = {
611
837
  isNested,
612
838
  onBack: pop,
613
839
  onClose: close,
614
840
  side
615
841
  };
616
- const isModal = config.modal;
617
842
  const ariaProps = buildAriaProps(
618
843
  isTop,
619
- isModal,
844
+ config.modal,
620
845
  isComposable,
621
846
  ariaLabel,
622
847
  panelId
623
848
  );
624
- const transition = dragState.isDragging ? { type: "tween", duration: 0 } : selectSpring(isTop, spring, stackSpring);
849
+ const transition = buildPanelTransition(
850
+ dragState.isDragging,
851
+ isTop,
852
+ spring,
853
+ stackSpring
854
+ );
625
855
  const animatedRadius = getAnimatedBorderRadius(side, depth, config.stacking);
856
+ const snapYOffset = computeSnapYOffset(
857
+ side,
858
+ snapHeights,
859
+ activeSnapIndex,
860
+ measuredHeight
861
+ );
862
+ const stackOffset = getStackOffset(side, transform.offset);
626
863
  const animateTarget = {
627
864
  ...slideTarget,
628
865
  ...stackOffset,
@@ -630,9 +867,19 @@ function SheetPanel({
630
867
  scale: transform.scale,
631
868
  opacity: transform.opacity,
632
869
  ...animatedRadius,
633
- transition
870
+ boxShadow: getShadow(side, !isTop),
871
+ transition,
872
+ ...snapYOffset > 0 ? { y: (dragOffset.y ?? 0) + snapYOffset } : {}
634
873
  };
635
- const panelContent = /* @__PURE__ */ jsx2(
874
+ const initialRadius = getInitialRadius(side);
875
+ const showSideHandle = isTop && side !== "bottom";
876
+ const showBottomHandle = isTop && side === "bottom";
877
+ const exitTween = {
878
+ type: "tween",
879
+ duration: 0.25,
880
+ ease: "easeOut"
881
+ };
882
+ const panelContent = /* @__PURE__ */ jsxs(
636
883
  m.div,
637
884
  {
638
885
  animate: animateTarget,
@@ -640,41 +887,44 @@ function SheetPanel({
640
887
  exit: {
641
888
  ...slideFrom,
642
889
  opacity: 0.6,
643
- transition: exitSpring
890
+ boxShadow: getShadow(side, false),
891
+ transition: { ...exitSpring, boxShadow: exitTween }
644
892
  },
645
893
  initial: {
646
894
  ...slideFrom,
647
- opacity: 0.8
895
+ opacity: 0.8,
896
+ ...initialRadius,
897
+ boxShadow: getShadow(side, false)
648
898
  },
649
899
  onAnimationComplete: handleAnimationComplete,
900
+ onMouseEnter: showSideHandle ? () => setIsHovered(true) : void 0,
901
+ onMouseLeave: showSideHandle ? () => setIsHovered(false) : void 0,
650
902
  ref: panelRef,
651
903
  style: panelStyle,
652
904
  tabIndex: isTop ? -1 : void 0,
653
- transition: spring,
654
905
  ...ariaProps,
655
- children: isComposable ? (
656
- /* Composable mode: content fills panel directly, uses Sheet.* parts */
657
- shouldRender && Content && /* @__PURE__ */ jsx2(Content, { ...item.data })
658
- ) : /* @__PURE__ */ jsxs(Fragment, { children: [
659
- renderHeader ? renderHeader(headerProps) : /* @__PURE__ */ jsx2(DefaultHeader, { ...headerProps }),
660
- shouldRender && Content && /* @__PURE__ */ jsx2(
661
- "div",
662
- {
663
- "data-stacksheet-no-drag": "",
664
- style: {
665
- flex: 1,
666
- minHeight: 0,
667
- overflowY: "auto",
668
- overscrollBehavior: "contain"
669
- },
670
- children: /* @__PURE__ */ jsx2(Content, { ...item.data })
671
- }
672
- )
673
- ] })
906
+ children: [
907
+ showSideHandle && /* @__PURE__ */ jsx2(SideHandle, { isHovered, side }),
908
+ /* @__PURE__ */ jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden rounded-[inherit]", children: [
909
+ showBottomHandle && /* @__PURE__ */ jsx2(BottomHandle, {}),
910
+ /* @__PURE__ */ jsx2(
911
+ PanelInnerContent,
912
+ {
913
+ Content,
914
+ data: item.data,
915
+ headerClassName: classNames.header || void 0,
916
+ headerProps,
917
+ isComposable,
918
+ renderHeader,
919
+ shouldRender
920
+ }
921
+ )
922
+ ] })
923
+ ]
674
924
  },
675
925
  item.id
676
926
  );
677
- if (!isModal) {
927
+ if (!config.modal) {
678
928
  return /* @__PURE__ */ jsx2(SheetPanelContext.Provider, { value: panelContext, children: panelContent });
679
929
  }
680
930
  return /* @__PURE__ */ jsx2(SheetPanelContext.Provider, { value: panelContext, children: /* @__PURE__ */ jsx2(
@@ -739,10 +989,51 @@ function SheetRenderer({
739
989
  }) {
740
990
  const isOpen = useStore(store, (s) => s.isOpen);
741
991
  const stack = useStore(store, (s) => s.stack);
742
- const close = useStore(store, (s) => s.close);
743
- const pop = useStore(store, (s) => s.pop);
992
+ const rawClose = useStore(store, (s) => s.close);
993
+ const rawPop = useStore(store, (s) => s.pop);
744
994
  const side = useResolvedSide(config);
745
995
  const classNames = resolveClassNames(classNamesProp);
996
+ const snapHeights = useMemo(
997
+ () => side === "bottom" && config.snapPoints.length > 0 ? resolveSnapPoints(
998
+ config.snapPoints,
999
+ typeof window !== "undefined" ? window.innerHeight : 0
1000
+ ) : [],
1001
+ [side, config.snapPoints]
1002
+ );
1003
+ const [internalSnapIndex, setInternalSnapIndex] = useState2(
1004
+ snapHeights.length > 0 ? snapHeights.length - 1 : 0
1005
+ );
1006
+ const activeSnapIndex = config.snapPointIndex ?? internalSnapIndex;
1007
+ const handleSnap = useCallback2(
1008
+ (index) => {
1009
+ setInternalSnapIndex(index);
1010
+ config.onSnapPointChange?.(index);
1011
+ },
1012
+ [config.onSnapPointChange, config]
1013
+ );
1014
+ useEffect3(() => {
1015
+ if (isOpen && snapHeights.length > 0) {
1016
+ const initial = config.snapPointIndex ?? snapHeights.length - 1;
1017
+ setInternalSnapIndex(initial);
1018
+ }
1019
+ }, [isOpen, snapHeights.length, config.snapPointIndex]);
1020
+ const closeReasonRef = useRef2("programmatic");
1021
+ const closeWith = useCallback2(
1022
+ (reason) => {
1023
+ closeReasonRef.current = reason;
1024
+ rawClose();
1025
+ },
1026
+ [rawClose]
1027
+ );
1028
+ const popWith = useCallback2(
1029
+ (reason) => {
1030
+ closeReasonRef.current = reason;
1031
+ rawPop();
1032
+ },
1033
+ [rawPop]
1034
+ );
1035
+ const close = useCallback2(() => closeWith("programmatic"), [closeWith]);
1036
+ const pop = useCallback2(() => popWith("programmatic"), [popWith]);
746
1037
  useBodyScale(config, isOpen);
747
1038
  const triggerRef = useRef2(null);
748
1039
  const wasOpenRef = useRef2(false);
@@ -766,9 +1057,9 @@ function SheetRenderer({
766
1057
  if (e.key === "Escape") {
767
1058
  e.preventDefault();
768
1059
  if (stack.length > 1) {
769
- pop();
1060
+ popWith("escape");
770
1061
  } else {
771
- close();
1062
+ closeWith("escape");
772
1063
  }
773
1064
  }
774
1065
  }
@@ -779,9 +1070,23 @@ function SheetRenderer({
779
1070
  config.closeOnEscape,
780
1071
  config.dismissible,
781
1072
  stack.length,
782
- pop,
783
- close
1073
+ popWith,
1074
+ closeWith
784
1075
  ]);
1076
+ useEffect3(() => {
1077
+ if (!(isOpen && config.dismissible) || typeof globalThis.CloseWatcher === "undefined") {
1078
+ return;
1079
+ }
1080
+ const watcher = new globalThis.CloseWatcher();
1081
+ watcher.onclose = () => {
1082
+ if (stack.length > 1) {
1083
+ popWith("escape");
1084
+ } else {
1085
+ closeWith("escape");
1086
+ }
1087
+ };
1088
+ return () => watcher.destroy();
1089
+ }, [isOpen, config.dismissible, stack.length, popWith, closeWith]);
785
1090
  const slideFrom = getSlideFrom(side);
786
1091
  const slideTarget = getSlideTarget();
787
1092
  const spring = {
@@ -796,27 +1101,27 @@ function SheetRenderer({
796
1101
  const showOverlay = isModal && config.showOverlay;
797
1102
  const hasBackdropClass = classNames.backdrop !== "";
798
1103
  const backdropStyle = {
799
- position: "fixed",
800
- inset: 0,
801
1104
  zIndex: config.zIndex,
802
1105
  cursor: config.closeOnBackdrop && config.dismissible ? "pointer" : void 0,
803
1106
  ...hasBackdropClass ? {} : { background: "var(--overlay, rgba(0, 0, 0, 0.2))" }
804
1107
  };
805
1108
  const handleExitComplete = useCallback2(() => {
806
1109
  if (stack.length === 0) {
807
- config.onCloseComplete?.();
1110
+ config.onCloseComplete?.(closeReasonRef.current);
808
1111
  }
809
1112
  }, [stack.length, config]);
1113
+ const swipeClose = useCallback2(() => closeWith("swipe"), [closeWith]);
1114
+ const swipePop = useCallback2(() => popWith("swipe"), [popWith]);
810
1115
  const shouldLockScroll = isOpen && isModal && config.lockScroll;
811
1116
  return /* @__PURE__ */ jsxs(Fragment, { children: [
812
1117
  showOverlay && /* @__PURE__ */ jsx2(AnimatePresence, { children: isOpen && /* @__PURE__ */ jsx2(
813
1118
  m.div,
814
1119
  {
815
1120
  animate: { opacity: 1 },
816
- className: classNames.backdrop || void 0,
1121
+ className: `fixed inset-0 ${classNames.backdrop || ""}`,
817
1122
  exit: { opacity: 0 },
818
1123
  initial: { opacity: 0 },
819
- onClick: config.closeOnBackdrop && config.dismissible ? close : void 0,
1124
+ onClick: config.closeOnBackdrop && config.dismissible ? () => closeWith("backdrop") : void 0,
820
1125
  style: backdropStyle,
821
1126
  transition: spring
822
1127
  },
@@ -825,22 +1130,18 @@ function SheetRenderer({
825
1130
  /* @__PURE__ */ jsx2(RemoveScroll, { enabled: shouldLockScroll, forwardProps: true, children: /* @__PURE__ */ jsx2(
826
1131
  "div",
827
1132
  {
828
- style: {
829
- position: "fixed",
830
- inset: 0,
831
- zIndex: config.zIndex + 1,
832
- overflow: "hidden",
833
- pointerEvents: "none"
834
- },
1133
+ className: "pointer-events-none fixed inset-0 overflow-hidden",
1134
+ style: { zIndex: config.zIndex + 1 },
835
1135
  children: /* @__PURE__ */ jsx2(AnimatePresence, { onExitComplete: handleExitComplete, children: stack.map((item, index) => {
836
1136
  const depth = stack.length - 1 - index;
837
1137
  const isTop = depth === 0;
838
- const isNested = stack.length > 1;
839
- const shouldRender = depth < config.stacking.renderThreshold;
1138
+ const isNested = index > 0;
1139
+ const shouldRender = depth <= config.stacking.renderThreshold;
840
1140
  const Content = componentMap.get(item.type) ?? sheets[item.type];
841
1141
  return /* @__PURE__ */ jsx2(
842
1142
  SheetPanel,
843
1143
  {
1144
+ activeSnapIndex,
844
1145
  Content,
845
1146
  classNames,
846
1147
  close,
@@ -851,14 +1152,18 @@ function SheetRenderer({
851
1152
  isNested,
852
1153
  isTop,
853
1154
  item,
1155
+ onSnap: handleSnap,
854
1156
  pop,
855
1157
  renderHeader,
856
1158
  shouldRender,
857
1159
  side,
858
1160
  slideFrom,
859
1161
  slideTarget,
1162
+ snapHeights,
860
1163
  spring,
861
- stackSpring
1164
+ stackSpring,
1165
+ swipeClose,
1166
+ swipePop
862
1167
  },
863
1168
  item.id
864
1169
  );
@@ -867,27 +1172,21 @@ function SheetRenderer({
867
1172
  ) })
868
1173
  ] });
869
1174
  }
870
- function getStackingOffset(side, offset) {
871
- if (offset === 0) {
872
- return {};
873
- }
874
- switch (side) {
875
- case "right":
876
- return { x: -offset };
877
- case "left":
878
- return { x: offset };
879
- case "bottom":
880
- return { y: -offset };
881
- default:
882
- return {};
883
- }
884
- }
885
- function getShadow(side, isNested) {
1175
+ function getInitialRadius(side) {
886
1176
  if (side === "bottom") {
887
- return isNested ? "0 -2px 16px rgba(0,0,0,0.06)" : "0 -8px 32px rgba(0,0,0,0.15)";
1177
+ return {
1178
+ borderTopLeftRadius: 0,
1179
+ borderTopRightRadius: 0,
1180
+ borderBottomLeftRadius: 0,
1181
+ borderBottomRightRadius: 0
1182
+ };
888
1183
  }
889
- const dir = side === "right" ? -1 : 1;
890
- return isNested ? `${dir * 2}px 0 16px rgba(0,0,0,0.06)` : `${dir * 8}px 0 32px rgba(0,0,0,0.15)`;
1184
+ return { borderRadius: 0 };
1185
+ }
1186
+ var SHADOW_SM = "0px 2px 5px 0px rgba(0,0,0,0.11), 0px 9px 9px 0px rgba(0,0,0,0.1), 0px 21px 13px 0px rgba(0,0,0,0.06)";
1187
+ var SHADOW_LG = "0px 23px 52px 0px rgba(0,0,0,0.08), 0px 94px 94px 0px rgba(0,0,0,0.07), 0px 211px 127px 0px rgba(0,0,0,0.04)";
1188
+ function getShadow(_side, isNested) {
1189
+ return isNested ? SHADOW_SM : SHADOW_LG;
891
1190
  }
892
1191
 
893
1192
  // src/store.ts
@@ -1172,21 +1471,6 @@ import {
1172
1471
  } from "@radix-ui/react-scroll-area";
1173
1472
  import { Slot } from "@radix-ui/react-slot";
1174
1473
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1175
- var HANDLE_STYLE = {
1176
- display: "flex",
1177
- alignItems: "center",
1178
- justifyContent: "center",
1179
- padding: "12px 0 4px",
1180
- flexShrink: 0,
1181
- cursor: "grab",
1182
- touchAction: "none"
1183
- };
1184
- var HANDLE_BAR_STYLE2 = {
1185
- width: 36,
1186
- height: 4,
1187
- borderRadius: 2,
1188
- background: "var(--muted-foreground, rgba(0, 0, 0, 0.25))"
1189
- };
1190
1474
  function SheetHandle({
1191
1475
  asChild,
1192
1476
  className,
@@ -1197,16 +1481,13 @@ function SheetHandle({
1197
1481
  return /* @__PURE__ */ jsx4(
1198
1482
  Comp,
1199
1483
  {
1200
- className,
1484
+ className: `flex shrink-0 cursor-grab touch-none items-center justify-center pt-4 pb-1 ${className ?? ""}`,
1201
1485
  "data-stacksheet-handle": "",
1202
- style: { ...HANDLE_STYLE, ...style },
1203
- children: children ?? /* @__PURE__ */ jsx4("div", { style: HANDLE_BAR_STYLE2 })
1486
+ style,
1487
+ children: children ?? /* @__PURE__ */ jsx4("div", { className: "h-1 w-9 rounded-sm bg-current/25" })
1204
1488
  }
1205
1489
  );
1206
1490
  }
1207
- var HEADER_STYLE = {
1208
- flexShrink: 0
1209
- };
1210
1491
  function SheetHeader({
1211
1492
  asChild,
1212
1493
  className,
@@ -1214,12 +1495,27 @@ function SheetHeader({
1214
1495
  children
1215
1496
  }) {
1216
1497
  const Comp = asChild ? Slot : "header";
1217
- return /* @__PURE__ */ jsx4(Comp, { className, style: { ...HEADER_STYLE, ...style }, children });
1498
+ return /* @__PURE__ */ jsx4(
1499
+ Comp,
1500
+ {
1501
+ className: `flex h-14 shrink-0 items-center justify-between border-b px-6 ${className ?? ""}`,
1502
+ style,
1503
+ children
1504
+ }
1505
+ );
1218
1506
  }
1219
1507
  function SheetTitle({ asChild, className, style, children }) {
1220
1508
  const { panelId } = useSheetPanel();
1221
1509
  const Comp = asChild ? Slot : "h2";
1222
- return /* @__PURE__ */ jsx4(Comp, { className, id: `${panelId}-title`, style, children });
1510
+ return /* @__PURE__ */ jsx4(
1511
+ Comp,
1512
+ {
1513
+ className: `font-semibold text-sm ${className ?? ""}`,
1514
+ id: `${panelId}-title`,
1515
+ style,
1516
+ children
1517
+ }
1518
+ );
1223
1519
  }
1224
1520
  function SheetDescription({
1225
1521
  asChild,
@@ -1231,32 +1527,14 @@ function SheetDescription({
1231
1527
  const Comp = asChild ? Slot : "p";
1232
1528
  return /* @__PURE__ */ jsx4(Comp, { className, id: `${panelId}-desc`, style, children });
1233
1529
  }
1234
- var BODY_STYLE = {
1235
- flex: 1,
1236
- minHeight: 0,
1237
- position: "relative"
1238
- };
1239
- var SCROLLBAR_STYLE = {
1240
- display: "flex",
1241
- userSelect: "none",
1242
- touchAction: "none",
1243
- padding: 2,
1244
- width: 8
1245
- };
1246
- var THUMB_STYLE = {
1247
- flex: 1,
1248
- borderRadius: 4,
1249
- background: "var(--border, rgba(0, 0, 0, 0.15))",
1250
- position: "relative"
1251
- };
1252
1530
  function SheetBody({ asChild, className, style, children }) {
1253
1531
  if (asChild) {
1254
1532
  return /* @__PURE__ */ jsx4(
1255
1533
  Slot,
1256
1534
  {
1257
- className,
1535
+ className: `relative min-h-0 flex-1 ${className ?? ""}`,
1258
1536
  "data-stacksheet-no-drag": "",
1259
- style: { ...BODY_STYLE, ...style },
1537
+ style,
1260
1538
  children
1261
1539
  }
1262
1540
  );
@@ -1264,25 +1542,23 @@ function SheetBody({ asChild, className, style, children }) {
1264
1542
  return /* @__PURE__ */ jsxs3(
1265
1543
  ScrollAreaRoot,
1266
1544
  {
1267
- className,
1545
+ className: `relative min-h-0 flex-1 overflow-hidden ${className ?? ""}`,
1268
1546
  "data-stacksheet-no-drag": "",
1269
- style: { ...BODY_STYLE, overflow: "hidden", ...style },
1547
+ style,
1270
1548
  children: [
1549
+ /* @__PURE__ */ jsx4(ScrollAreaViewport, { className: "h-full w-full overscroll-contain", children }),
1271
1550
  /* @__PURE__ */ jsx4(
1272
- ScrollAreaViewport,
1551
+ ScrollAreaScrollbar,
1273
1552
  {
1274
- style: { height: "100%", width: "100%", overscrollBehavior: "contain" },
1275
- children
1553
+ className: "flex w-2 touch-none select-none p-0.5",
1554
+ orientation: "vertical",
1555
+ children: /* @__PURE__ */ jsx4(ScrollAreaThumb, { className: "relative flex-1 rounded bg-current/15" })
1276
1556
  }
1277
- ),
1278
- /* @__PURE__ */ jsx4(ScrollAreaScrollbar, { orientation: "vertical", style: SCROLLBAR_STYLE, children: /* @__PURE__ */ jsx4(ScrollAreaThumb, { style: THUMB_STYLE }) })
1557
+ )
1279
1558
  ]
1280
1559
  }
1281
1560
  );
1282
1561
  }
1283
- var FOOTER_STYLE = {
1284
- flexShrink: 0
1285
- };
1286
1562
  function SheetFooter({
1287
1563
  asChild,
1288
1564
  className,
@@ -1290,7 +1566,14 @@ function SheetFooter({
1290
1566
  children
1291
1567
  }) {
1292
1568
  const Comp = asChild ? Slot : "footer";
1293
- return /* @__PURE__ */ jsx4(Comp, { className, style: { ...FOOTER_STYLE, ...style }, children });
1569
+ return /* @__PURE__ */ jsx4(
1570
+ Comp,
1571
+ {
1572
+ className: `flex shrink-0 items-center gap-2 border-t px-6 py-3 ${className ?? ""}`,
1573
+ style,
1574
+ children
1575
+ }
1576
+ );
1294
1577
  }
1295
1578
  function SheetClose({ asChild, className, style, children }) {
1296
1579
  const { close } = useSheetPanel();
@@ -1299,7 +1582,7 @@ function SheetClose({ asChild, className, style, children }) {
1299
1582
  Comp,
1300
1583
  {
1301
1584
  "aria-label": children ? void 0 : "Close",
1302
- className,
1585
+ className: `flex h-8 w-8 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 ?? ""}`,
1303
1586
  onClick: close,
1304
1587
  style,
1305
1588
  type: asChild ? void 0 : "button",
@@ -1317,7 +1600,7 @@ function SheetBack({ asChild, className, style, children }) {
1317
1600
  Comp,
1318
1601
  {
1319
1602
  "aria-label": children ? void 0 : "Back",
1320
- className,
1603
+ className: `flex h-8 w-8 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 ?? ""}`,
1321
1604
  onClick: back,
1322
1605
  style,
1323
1606
  type: asChild ? void 0 : "button",