@patternmode/stacksheet 1.3.7 → 1.4.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.
Files changed (36) hide show
  1. package/dist/Drag/drag-velocity.d.ts +24 -0
  2. package/dist/Drag/drag-velocity.d.ts.map +1 -0
  3. package/dist/Drag/use-drag.d.ts.map +1 -1
  4. package/dist/SheetPanel/default-header.d.ts +1 -3
  5. package/dist/SheetPanel/default-header.d.ts.map +1 -1
  6. package/dist/SheetPanel/sheet-panel-content.d.ts +1 -2
  7. package/dist/SheetPanel/sheet-panel-content.d.ts.map +1 -1
  8. package/dist/SheetPanel/sheet-panel-focus.d.ts.map +1 -1
  9. package/dist/SheetPanel/sheet-panel-handles.d.ts +3 -2
  10. package/dist/SheetPanel/sheet-panel-handles.d.ts.map +1 -1
  11. package/dist/SheetPanel/sheet-panel-types.d.ts +2 -0
  12. package/dist/SheetPanel/sheet-panel-types.d.ts.map +1 -1
  13. package/dist/SheetPanel/sheet-panel.d.ts.map +1 -1
  14. package/dist/SheetParts/sheet-handle.d.ts +1 -1
  15. package/dist/SheetParts/sheet-handle.d.ts.map +1 -1
  16. package/dist/SheetParts/sheet-header.d.ts.map +1 -1
  17. package/dist/SheetParts/sheet-parts.d.ts +1 -1
  18. package/dist/SheetParts/sheet-title.d.ts.map +1 -1
  19. package/dist/Store/store-args.d.ts +6 -0
  20. package/dist/Store/store-args.d.ts.map +1 -1
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/index.mjs +300 -106
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/panel-context.d.ts +4 -0
  25. package/dist/panel-context.d.ts.map +1 -1
  26. package/dist/renderer-effects.d.ts +12 -0
  27. package/dist/renderer-effects.d.ts.map +1 -1
  28. package/dist/renderer-helpers.d.ts +11 -3
  29. package/dist/renderer-helpers.d.ts.map +1 -1
  30. package/dist/renderer.d.ts.map +1 -1
  31. package/dist/stacking.d.ts +6 -0
  32. package/dist/stacking.d.ts.map +1 -1
  33. package/dist/styles.css +1 -1
  34. package/dist/types.d.ts +35 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { Portal } from "@radix-ui/react-portal";
3
- import { createContext, use, useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { createStore, useStore } from "zustand";
5
5
  import { useShallow } from "zustand/react/shallow";
6
6
  import { springs as springs$1 } from "@howells/motion";
@@ -56,10 +56,12 @@ const DEFAULT_CONFIG = {
56
56
  closeThreshold: .25,
57
57
  dismissible: true,
58
58
  drag: true,
59
+ handle: "inside",
59
60
  lockScroll: true,
60
61
  maxDepth: Number.POSITIVE_INFINITY,
61
62
  maxWidth: "90vw",
62
63
  modal: true,
64
+ repositionInputs: true,
63
65
  scaleBackgroundAmount: .97,
64
66
  shouldScaleBackground: false,
65
67
  showOverlay: true,
@@ -169,38 +171,123 @@ const useViewportHeight = (active) => {
169
171
  }, []);
170
172
  return active ? height ?? 0 : 0;
171
173
  };
172
- const useBodyScale = (config, isOpen, prefersReducedMotion) => {
174
+ /** Input types that don't summon the on-screen keyboard. */
175
+ const NON_TEXT_INPUT_TYPES = new Set([
176
+ "button",
177
+ "checkbox",
178
+ "color",
179
+ "file",
180
+ "hidden",
181
+ "image",
182
+ "radio",
183
+ "range",
184
+ "reset",
185
+ "submit"
186
+ ]);
187
+ /** True for elements whose focus raises the on-screen keyboard. */
188
+ const isEditableElement = (el) => {
189
+ if (!(el instanceof HTMLElement)) return false;
190
+ if (el.isContentEditable) return true;
191
+ if (el instanceof HTMLTextAreaElement) return true;
192
+ if (el instanceof HTMLInputElement) return !NON_TEXT_INPUT_TYPES.has(el.type);
193
+ return false;
194
+ };
195
+ /**
196
+ * Height (px) the on-screen keyboard occupies while a field inside `containerRef`
197
+ * is focused, else `0`. Derived from the gap between the layout viewport and the
198
+ * (keyboard-shrunk, possibly panned) visual viewport.
199
+ *
200
+ * Focus-gated to fields *inside the container* so unrelated `visualViewport`
201
+ * changes — Android URL-bar collapse, or typing into the page behind a
202
+ * non-modal sheet — don't move the sheet, and zero while pinch-zoomed since a
203
+ * shrunk visual viewport then isn't a keyboard. rAF-throttled because iOS
204
+ * fires many resize events over the keyboard's open animation.
205
+ */
206
+ const useKeyboardInset = (active, containerRef) => {
207
+ const [inset, setInset] = useState(0);
173
208
  useEffect(() => {
174
- const wrapper = document.querySelector("[data-stacksheet-wrapper]");
175
- const resetWrapper = () => {
176
- if (wrapper instanceof HTMLElement) {
177
- wrapper.style.transform = "";
178
- wrapper.style.borderRadius = "";
179
- wrapper.style.transition = "";
180
- wrapper.style.overflow = "";
181
- wrapper.style.transformOrigin = "";
182
- }
209
+ const canListen = active && typeof window !== "undefined";
210
+ const container = containerRef.current;
211
+ const viewport = canListen ? window.visualViewport : void 0;
212
+ let frame = 0;
213
+ let scheduled = false;
214
+ const measure = () => {
215
+ const el = document.activeElement;
216
+ const focused = isEditableElement(el) && container !== null && container.contains(el);
217
+ const zoomed = (viewport?.scale ?? 1) > 1;
218
+ const visibleHeight = viewport?.height ?? window.innerHeight;
219
+ const gap = window.innerHeight - (viewport?.offsetTop ?? 0) - visibleHeight;
220
+ setInset(focused && !zoomed ? Math.max(0, gap) : 0);
183
221
  };
184
- const canScale = config.shouldScaleBackground && !prefersReducedMotion && wrapper instanceof HTMLElement;
185
- if (canScale && isOpen) {
186
- const scale = config.scaleBackgroundAmount;
187
- wrapper.style.transition = "transform 500ms cubic-bezier(0.32, 0.72, 0, 1), border-radius 500ms cubic-bezier(0.32, 0.72, 0, 1)";
188
- wrapper.style.transform = `scale(${scale})`;
189
- wrapper.style.borderRadius = "8px";
190
- wrapper.style.overflow = "hidden";
191
- wrapper.style.transformOrigin = "center top";
192
- return resetWrapper;
222
+ const schedule = () => {
223
+ if (scheduled) return;
224
+ scheduled = true;
225
+ frame = window.requestAnimationFrame(() => {
226
+ scheduled = false;
227
+ measure();
228
+ });
229
+ };
230
+ if (canListen) {
231
+ container?.addEventListener("focusin", schedule);
232
+ container?.addEventListener("focusout", schedule);
233
+ viewport?.addEventListener("resize", schedule, { passive: true });
234
+ viewport?.addEventListener("scroll", schedule, { passive: true });
235
+ schedule();
193
236
  }
194
- const handleEnd = () => {
195
- resetWrapper();
237
+ return () => {
238
+ if (frame !== 0) window.cancelAnimationFrame(frame);
239
+ container?.removeEventListener("focusin", schedule);
240
+ container?.removeEventListener("focusout", schedule);
241
+ viewport?.removeEventListener("resize", schedule);
242
+ viewport?.removeEventListener("scroll", schedule);
196
243
  };
197
- if (canScale) {
198
- wrapper.style.transform = "";
199
- wrapper.style.borderRadius = "";
200
- wrapper.addEventListener("transitionend", handleEnd, { once: true });
244
+ }, [active, containerRef]);
245
+ return active ? inset : 0;
246
+ };
247
+ const BODY_SCALE_TRANSITION = "transform 500ms cubic-bezier(0.32, 0.72, 0, 1), border-radius 500ms cubic-bezier(0.32, 0.72, 0, 1)";
248
+ /** Fallback delay (transition duration + margin) if `transitionend` never fires. */
249
+ const BODY_SCALE_RESET_FALLBACK_MS = 600;
250
+ /** Cancels a pending un-scale reset when the sheet reopens mid-animation. */
251
+ let cancelPendingBodyScaleReset;
252
+ const useBodyScale = (config, isOpen, prefersReducedMotion) => {
253
+ useEffect(() => {
254
+ const wrapper = document.querySelector("[data-stacksheet-wrapper]");
255
+ const scalable = config.shouldScaleBackground && !prefersReducedMotion && wrapper instanceof HTMLElement && isOpen ? wrapper : null;
256
+ if (scalable !== null) {
257
+ cancelPendingBodyScaleReset?.();
258
+ scalable.style.transition = BODY_SCALE_TRANSITION;
259
+ scalable.style.transform = `scale(${config.scaleBackgroundAmount})`;
260
+ scalable.style.borderRadius = "8px";
261
+ scalable.style.overflow = "hidden";
262
+ scalable.style.transformOrigin = "center top";
201
263
  }
202
264
  return () => {
203
- if (wrapper instanceof HTMLElement) wrapper.removeEventListener("transitionend", handleEnd);
265
+ if (scalable === null) return;
266
+ scalable.style.transition = BODY_SCALE_TRANSITION;
267
+ scalable.style.transform = "";
268
+ scalable.style.borderRadius = "";
269
+ const controller = new AbortController();
270
+ const { signal } = controller;
271
+ const finish = () => {
272
+ controller.abort();
273
+ cancelPendingBodyScaleReset = void 0;
274
+ scalable.style.transition = "";
275
+ scalable.style.overflow = "";
276
+ scalable.style.transformOrigin = "";
277
+ };
278
+ scalable.addEventListener("transitionend", (event) => {
279
+ if (event.target === scalable && event.propertyName === "transform") finish();
280
+ }, { signal });
281
+ const timeoutId = setTimeout(() => {
282
+ if (!signal.aborted) finish();
283
+ }, BODY_SCALE_RESET_FALLBACK_MS);
284
+ signal.addEventListener("abort", () => {
285
+ clearTimeout(timeoutId);
286
+ });
287
+ cancelPendingBodyScaleReset = () => {
288
+ controller.abort();
289
+ cancelPendingBodyScaleReset = void 0;
290
+ };
204
291
  };
205
292
  }, [
206
293
  isOpen,
@@ -307,23 +394,22 @@ const getSnapOffset = (snapIndex, snapHeights, panelHeight) => {
307
394
  //#region src/renderer-helpers.ts
308
395
  const EMPTY_CLASSNAMES = {
309
396
  backdrop: "",
310
- header: "",
311
397
  panel: ""
312
398
  };
313
399
  const resolveClassNames = (cn) => {
314
400
  if (!cn) return EMPTY_CLASSNAMES;
315
401
  return {
316
402
  backdrop: cn.backdrop ?? "",
317
- header: cn.header ?? "",
318
403
  panel: cn.panel ?? ""
319
404
  };
320
405
  };
321
- const buildAriaProps = (isTop, isModal, isComposable, ariaLabel, panelId, hasDescription) => {
406
+ const buildAriaProps = ({ ariaLabel, hasDescription, hasTitle, isComposable, isModal, isTop, panelId }) => {
322
407
  if (!isTop) return {};
323
408
  const props = { role: "dialog" };
324
409
  if (isModal) props["aria-modal"] = "true";
325
410
  if (isComposable) {
326
- props["aria-labelledby"] = `${panelId}-title`;
411
+ if (hasTitle) props["aria-labelledby"] = `${panelId}-title`;
412
+ else props["aria-label"] = ariaLabel;
327
413
  if (hasDescription) props["aria-describedby"] = `${panelId}-desc`;
328
414
  } else props["aria-label"] = ariaLabel;
329
415
  return props;
@@ -345,8 +431,21 @@ const VISUAL_TWEEN = {
345
431
  const SHADOW_SM = "0px 1px 3px 0px rgba(0,0,0,0.06), 0px 6px 12px 0px rgba(0,0,0,0.06)";
346
432
  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)";
347
433
  const getShadow = (isNested) => isNested ? SHADOW_SM : SHADOW_LG;
348
- const buildPanelStyle = (panelStyles, isTop, hasPanelClass, isDragging) => ({
434
+ /**
435
+ * Clear the on-screen keyboard. Plain sheets stay anchored at bottom: 0 and
436
+ * pad their content up instead — the panel surface extends under the keyboard
437
+ * (and iOS Safari's floating URL-pill chrome), so no backdrop gap ever shows
438
+ * between sheet and keyboard, and measurement error hides behind the keyboard.
439
+ * Snap sheets are transform-anchored, so they lift via `bottom` (not the
440
+ * Motion `y` transform) and keep their viewport-driven sizing untouched.
441
+ */
442
+ const getKeyboardClearance = (keyboardInset, padForKeyboard) => {
443
+ if (keyboardInset <= 0) return {};
444
+ return padForKeyboard ? { paddingBottom: keyboardInset } : { bottom: keyboardInset };
445
+ };
446
+ const buildPanelStyle = (panelStyles, isTop, hasPanelClass, isDragging, keyboardInset, padForKeyboard) => ({
349
447
  ...panelStyles,
448
+ ...getKeyboardClearance(keyboardInset, padForKeyboard),
350
449
  pointerEvents: isTop ? "auto" : "none",
351
450
  ...isTop ? {} : { contain: "layout style paint" },
352
451
  ...isDragging ? { transition: "none" } : {},
@@ -504,12 +603,41 @@ const getPanelDimension = (panel, axis) => {
504
603
  if (!panel) return 300;
505
604
  return axis === "x" ? panel.offsetWidth : panel.offsetHeight;
506
605
  };
606
+ /** Upper bound on retained samples — plenty for a 100ms window at 120Hz. */
607
+ const MAX_SAMPLES = 20;
608
+ /**
609
+ * Append a pointer sample, pruning entries that fall outside the sliding
610
+ * window (plus a hard cap as a memory guard). Mutates and returns `samples`.
611
+ */
612
+ const appendVelocitySample = (samples, sample) => {
613
+ samples.push(sample);
614
+ const cutoff = sample.time - 100;
615
+ while (samples.length > MAX_SAMPLES || samples.length > 2 && (samples[0]?.time ?? 0) < cutoff) samples.shift();
616
+ return samples;
617
+ };
618
+ /**
619
+ * Compute release velocity (px/ms, positive = dismiss direction) from the
620
+ * samples recorded within the sliding window before `releaseTime`.
621
+ *
622
+ * Using only recent samples means a pause followed by a flick reports the
623
+ * flick's velocity — not the whole-gesture average, which would dilute it
624
+ * to near zero.
625
+ */
626
+ const getReleaseVelocity = (samples, releaseTime) => {
627
+ const cutoff = releaseTime - 100;
628
+ const recent = samples.filter((sample) => sample.time >= cutoff);
629
+ const [first] = recent;
630
+ const last = recent.at(-1);
631
+ if (first === void 0 || last === void 0 || last.time <= first.time) return 0;
632
+ return (last.offset - first.offset) / (last.time - first.time);
633
+ };
507
634
  //#endregion
508
635
  //#region src/Drag/use-drag.ts
509
- const resetDragRefs = ({ committedRef, offsetRef, scrollTargetRef, startRef }) => {
636
+ const resetDragRefs = ({ committedRef, offsetRef, samplesRef, scrollTargetRef, startRef }) => {
510
637
  startRef.current = null;
511
638
  committedRef.current = null;
512
639
  offsetRef.current = 0;
640
+ samplesRef.current = [];
513
641
  scrollTargetRef.current = null;
514
642
  };
515
643
  const createDragHandlers = ({ axis, config, onDragUpdate, panelRef, refs, sign }) => {
@@ -524,13 +652,15 @@ const createDragHandlers = ({ axis, config, onDragUpdate, panelRef, refs, sign }
524
652
  if (!isHandle && isInteractiveElement(target)) return;
525
653
  refs.scrollTargetRef.current = isHandle ? null : findScrollableAncestor(target, axis);
526
654
  refs.startRef.current = {
527
- time: Date.now(),
528
655
  x: e.clientX,
529
656
  y: e.clientY
530
657
  };
531
658
  refs.committedRef.current = null;
532
659
  refs.offsetRef.current = 0;
533
- if (e.currentTarget instanceof HTMLElement) e.currentTarget.setPointerCapture(e.pointerId);
660
+ refs.samplesRef.current = [{
661
+ offset: 0,
662
+ time: Date.now()
663
+ }];
534
664
  };
535
665
  const handlePointerMove = (e) => {
536
666
  if (refs.startRef.current === null) return;
@@ -544,11 +674,16 @@ const createDragHandlers = ({ axis, config, onDragUpdate, panelRef, refs, sign }
544
674
  refs.startRef.current = null;
545
675
  return;
546
676
  }
677
+ if (e.currentTarget instanceof HTMLElement) e.currentTarget.setPointerCapture(e.pointerId);
547
678
  }
548
679
  if (refs.committedRef.current !== "drag") return;
549
680
  const directional = (axis === "x" ? dx : dy) * sign;
550
681
  const clampedOffset = directional >= 0 ? directional : -Math.sqrt(Math.abs(directional)) * RUBBER_BAND_FACTOR;
551
682
  refs.offsetRef.current = clampedOffset;
683
+ appendVelocitySample(refs.samplesRef.current, {
684
+ offset: clampedOffset,
685
+ time: Date.now()
686
+ });
552
687
  onDragUpdate({
553
688
  isDragging: true,
554
689
  offset: clampedOffset
@@ -561,8 +696,7 @@ const createDragHandlers = ({ axis, config, onDragUpdate, panelRef, refs, sign }
561
696
  return;
562
697
  }
563
698
  const offset = Math.max(0, refs.offsetRef.current);
564
- const elapsed = Date.now() - refs.startRef.current.time;
565
- const velocity = elapsed > 0 ? offset / elapsed : 0;
699
+ const velocity = getReleaseVelocity(refs.samplesRef.current, Date.now());
566
700
  resetDragRefs(refs);
567
701
  const panelSize = getPanelDimension(panelRef.current, axis);
568
702
  if (config.snapHeights.length > 0) {
@@ -619,6 +753,7 @@ const useDrag = (panelRef, config, onDragUpdate) => {
619
753
  const startRef = useRef(null);
620
754
  const committedRef = useRef(null);
621
755
  const offsetRef = useRef(0);
756
+ const samplesRef = useRef([]);
622
757
  const scrollTargetRef = useRef(null);
623
758
  const { axis, sign } = getDismissAxis(config.side);
624
759
  const { handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp } = createDragHandlers({
@@ -629,6 +764,7 @@ const useDrag = (panelRef, config, onDragUpdate) => {
629
764
  refs: {
630
765
  committedRef,
631
766
  offsetRef,
767
+ samplesRef,
632
768
  scrollTargetRef,
633
769
  startRef
634
770
  },
@@ -692,6 +828,12 @@ const useSheetPanel = () => {
692
828
  //#endregion
693
829
  //#region src/stacking.ts
694
830
  /**
831
+ * Resting height of a bottom sheet. `dvh` tracks the dynamic viewport on iOS
832
+ * Safari (accounts for browser chrome). Shared so the keyboard-inset `calc()`
833
+ * in `buildPanelStyle` can't drift from the panel's base height.
834
+ */
835
+ const BOTTOM_SHEET_HEIGHT = "85dvh";
836
+ /**
695
837
  * Compute visual transforms for a panel at a given depth.
696
838
  * depth=0 is the top (foreground) panel.
697
839
  * Panels beyond renderThreshold are clamped to the edge position and faded out.
@@ -768,6 +910,7 @@ const getPanelStyles = (side, config, index) => {
768
910
  const base = {
769
911
  display: "flex",
770
912
  flexDirection: "column",
913
+ outline: "none",
771
914
  position: "fixed",
772
915
  transformOrigin: getTransformOrigin(side),
773
916
  willChange: "transform",
@@ -776,9 +919,9 @@ const getPanelStyles = (side, config, index) => {
776
919
  if (side === "bottom") return {
777
920
  ...base,
778
921
  bottom: 0,
779
- height: "85dvh",
922
+ height: BOTTOM_SHEET_HEIGHT,
780
923
  left: 0,
781
- maxHeight: "85dvh",
924
+ maxHeight: BOTTOM_SHEET_HEIGHT,
782
925
  right: 0
783
926
  };
784
927
  const sideStyles = side === "right" ? {
@@ -826,34 +969,25 @@ const XIcon = () => /* @__PURE__ */ jsx("svg", {
826
969
  });
827
970
  //#endregion
828
971
  //#region src/SheetPanel/default-header.tsx
829
- const DefaultHeader = ({ isNested, onBack, onClose, className }) => /* @__PURE__ */ jsxs("div", {
830
- className: `flex shrink-0 items-center justify-between px-4 pt-4 pb-2 ${className ?? ""}`,
831
- children: [/* @__PURE__ */ jsx("div", {
832
- className: "flex items-center gap-2",
833
- children: isNested && /* @__PURE__ */ jsx("button", {
834
- "aria-label": "Back",
835
- 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",
836
- onClick: onBack,
837
- type: "button",
838
- children: /* @__PURE__ */ jsx(ArrowLeftIcon, {})
839
- })
840
- }), /* @__PURE__ */ jsx("button", {
841
- "aria-label": "Close",
842
- 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",
843
- onClick: onClose,
844
- type: "button",
845
- children: /* @__PURE__ */ jsx(XIcon, {})
846
- })]
847
- });
972
+ const DefaultHeader = ({ isNested, onBack, onClose }) => /* @__PURE__ */ jsxs(Fragment, { children: [isNested && /* @__PURE__ */ jsx("button", {
973
+ "aria-label": "Back",
974
+ className: "absolute left-2 top-2 z-20 flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-inherit opacity-50 transition-opacity duration-150 hover:opacity-100",
975
+ onClick: onBack,
976
+ type: "button",
977
+ children: /* @__PURE__ */ jsx(ArrowLeftIcon, {})
978
+ }), /* @__PURE__ */ jsx("button", {
979
+ "aria-label": "Close",
980
+ className: "absolute right-2 top-2 z-20 flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-inherit opacity-50 transition-opacity duration-150 hover:opacity-100",
981
+ onClick: onClose,
982
+ type: "button",
983
+ children: /* @__PURE__ */ jsx(XIcon, {})
984
+ })] });
848
985
  //#endregion
849
986
  //#region src/SheetPanel/sheet-panel-content.tsx
850
- const PanelInnerContent = ({ isComposable, shouldRender, Content, data, renderHeader, headerProps, headerClassName }) => {
987
+ const PanelInnerContent = ({ isComposable, shouldRender, Content, data, renderHeader, headerProps }) => {
851
988
  if (isComposable) return shouldRender && Content !== void 0 ? /* @__PURE__ */ jsx(Content, { ...data }) : null;
852
989
  const customHeader = renderHeader !== void 0 && renderHeader !== false ? renderHeader : void 0;
853
- return /* @__PURE__ */ jsxs(Fragment, { children: [customHeader === void 0 ? /* @__PURE__ */ jsx(DefaultHeader, {
854
- ...headerProps,
855
- className: headerClassName
856
- }) : customHeader(headerProps), shouldRender && Content !== void 0 && /* @__PURE__ */ jsx("div", {
990
+ return /* @__PURE__ */ jsxs(Fragment, { children: [customHeader === void 0 ? /* @__PURE__ */ jsx(DefaultHeader, { ...headerProps }) : customHeader(headerProps), shouldRender && Content !== void 0 && /* @__PURE__ */ jsx("div", {
857
991
  className: "min-h-0 flex-1 overflow-y-auto overscroll-contain",
858
992
  "data-stacksheet-no-drag": "",
859
993
  children: /* @__PURE__ */ jsx(Content, { ...data })
@@ -896,7 +1030,7 @@ const ModalFocusTrap = ({ enabled, active, fallbackRef, children }) => {
896
1030
  if (fallbackRef.current !== null) return fallbackRef.current;
897
1031
  return document.body;
898
1032
  },
899
- initialFocus: false,
1033
+ initialFocus: () => fallbackRef.current ?? void 0,
900
1034
  returnFocusOnDeactivate: true
901
1035
  },
902
1036
  paused,
@@ -905,9 +1039,9 @@ const ModalFocusTrap = ({ enabled, active, fallbackRef, children }) => {
905
1039
  };
906
1040
  //#endregion
907
1041
  //#region src/SheetPanel/sheet-panel-handles.tsx
908
- const BottomHandle = ({ onDismiss }) => /* @__PURE__ */ jsx("button", {
1042
+ const BottomHandle = ({ onDismiss, position = "inside" }) => /* @__PURE__ */ jsx("button", {
909
1043
  "aria-label": "Dismiss",
910
- 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",
1044
+ className: joinClassNames("absolute inset-x-0 z-10 flex w-full cursor-grab touch-none items-center justify-center border-none bg-transparent text-inherit", position === "outside" ? "bottom-full pt-1 pb-2" : "top-0 pt-2.5 pb-2"),
911
1045
  "data-stacksheet-handle": "",
912
1046
  onClick: onDismiss,
913
1047
  type: "button",
@@ -969,14 +1103,16 @@ const getHeaderProps = ({ close, isNested, pop, side }) => ({
969
1103
  onClose: close,
970
1104
  side
971
1105
  });
972
- const getPanelContext = ({ close, hasDescription, isNested, isTop, panelId, pop, registerDescription, side }) => ({
1106
+ const getPanelContext = ({ close, hasDescription, hasTitle, isNested, isTop, panelId, pop, registerDescription, registerTitle, side }) => ({
973
1107
  back: pop,
974
1108
  close,
975
1109
  hasDescription,
1110
+ hasTitle,
976
1111
  isNested,
977
1112
  isTop,
978
1113
  panelId,
979
1114
  registerDescription,
1115
+ registerTitle,
980
1116
  side
981
1117
  });
982
1118
  const getOptionalSideHandle = ({ isHovered, onDismiss, show, side }) => show ? /* @__PURE__ */ jsx(SideHandle, {
@@ -984,7 +1120,10 @@ const getOptionalSideHandle = ({ isHovered, onDismiss, show, side }) => show ? /
984
1120
  onDismiss,
985
1121
  side
986
1122
  }) : null;
987
- const getOptionalBottomHandle = (show, onDismiss) => show ? /* @__PURE__ */ jsx(BottomHandle, { onDismiss }) : null;
1123
+ const getOptionalBottomHandle = (show, onDismiss, position) => show ? /* @__PURE__ */ jsx(BottomHandle, {
1124
+ onDismiss,
1125
+ position
1126
+ }) : null;
988
1127
  const completeOpeningAnimation = (hasEnteredRef, isTop, onOpenCompleteRef) => {
989
1128
  if (isTop && !hasEnteredRef.current) {
990
1129
  hasEnteredRef.current = true;
@@ -1024,28 +1163,49 @@ const useSheetPanelDrag = ({ activeSnapIndex, config, isNested, isTop, onSnap, p
1024
1163
  };
1025
1164
  const useSheetPanelContext = ({ close, isNested, isTop, pop, side }, panelId) => {
1026
1165
  const [hasDescription, setHasDescription] = useState(false);
1027
- const registerDescription = () => {
1166
+ const [hasTitle, setHasTitle] = useState(false);
1167
+ const registerDescription = useCallback(() => {
1028
1168
  setHasDescription(true);
1029
1169
  return () => {
1030
1170
  setHasDescription(false);
1031
1171
  };
1032
- };
1172
+ }, []);
1173
+ const registerTitle = useCallback(() => {
1174
+ setHasTitle(true);
1175
+ return () => {
1176
+ setHasTitle(false);
1177
+ };
1178
+ }, []);
1033
1179
  return {
1034
1180
  hasDescription,
1035
- panelContext: getPanelContext({
1181
+ hasTitle,
1182
+ panelContext: useMemo(() => getPanelContext({
1036
1183
  close,
1037
1184
  hasDescription,
1185
+ hasTitle,
1038
1186
  isNested,
1039
1187
  isTop,
1040
1188
  panelId,
1041
1189
  pop,
1042
1190
  registerDescription,
1191
+ registerTitle,
1043
1192
  side
1044
- })
1193
+ }), [
1194
+ close,
1195
+ hasDescription,
1196
+ hasTitle,
1197
+ isNested,
1198
+ isTop,
1199
+ panelId,
1200
+ pop,
1201
+ registerDescription,
1202
+ registerTitle,
1203
+ side
1204
+ ])
1045
1205
  };
1046
1206
  };
1047
1207
  const useSheetPanelModel = (props) => {
1048
- const { item, index, depth, isTop, isNested, side, config, classNames, pop, close, snapHeights, activeSnapIndex, layout, renderHeader, slideFrom, slideTarget, spring, stackSpring } = props;
1208
+ const { item, index, depth, isTop, isNested, side, config, classNames, pop, close, snapHeights, keyboardInset, activeSnapIndex, layout, renderHeader, slideFrom, slideTarget, spring, stackSpring } = props;
1049
1209
  const panelRef = useRef(null);
1050
1210
  const [isHovered, setIsHovered] = useState(false);
1051
1211
  const measuredHeight = usePanelHeight(panelRef, snapHeights.length > 0);
@@ -1055,18 +1215,27 @@ const useSheetPanelModel = (props) => {
1055
1215
  const dragState = useSheetPanelDrag(props, panelRef);
1056
1216
  const ariaLabel = getPanelAriaLabel(item, config.ariaLabel);
1057
1217
  const panelId = `stacksheet-${item.id}`;
1058
- const { hasDescription, panelContext } = useSheetPanelContext(props, panelId);
1218
+ const { hasDescription, hasTitle, panelContext } = useSheetPanelContext(props, panelId);
1059
1219
  const isComposable = resolvePanelLayout(layout, renderHeader) === "composable";
1060
1220
  const hasPanelClass = classNames.panel !== "";
1061
1221
  const dragOffset = getDragTransform(side, dragState.offset);
1062
- const panelStyle = buildPanelStyle(panelStyles, isTop, hasPanelClass, dragState.isDragging);
1222
+ const activeKeyboardInset = isTop && side === "bottom" ? keyboardInset : 0;
1223
+ const panelStyle = buildPanelStyle(panelStyles, isTop, hasPanelClass, dragState.isDragging, activeKeyboardInset, snapHeights.length === 0);
1063
1224
  const headerProps = getHeaderProps({
1064
1225
  close,
1065
1226
  isNested,
1066
1227
  pop,
1067
1228
  side
1068
1229
  });
1069
- const ariaProps = buildAriaProps(isTop, config.modal, isComposable, ariaLabel, panelId, hasDescription);
1230
+ const ariaProps = buildAriaProps({
1231
+ ariaLabel,
1232
+ hasDescription,
1233
+ hasTitle,
1234
+ isComposable,
1235
+ isModal: config.modal,
1236
+ isTop,
1237
+ panelId
1238
+ });
1070
1239
  const transition = buildPanelTransition(dragState.isDragging, isTop, spring, stackSpring);
1071
1240
  const animatedRadius = getAnimatedBorderRadius(side, depth, config.stacking);
1072
1241
  const snapYOffset = computeSnapYOffset(side, snapHeights, activeSnapIndex, measuredHeight);
@@ -1074,7 +1243,7 @@ const useSheetPanelModel = (props) => {
1074
1243
  const animateTarget = buildAnimateTarget(slideTarget, getStackOffset(side, transform.offset), dragOffset, transform, animatedRadius, transition, snapYOffset, isTop);
1075
1244
  const initialRadius = getInitialRadius(side);
1076
1245
  const showSideHandle = isTop && side !== "bottom";
1077
- const showBottomHandle = isTop && side === "bottom";
1246
+ const showBottomHandle = isTop && side === "bottom" && !isComposable;
1078
1247
  const dismiss = isNested ? pop : close;
1079
1248
  const sideHandle = getOptionalSideHandle({
1080
1249
  isHovered,
@@ -1085,7 +1254,8 @@ const useSheetPanelModel = (props) => {
1085
1254
  return {
1086
1255
  animateTarget,
1087
1256
  ariaProps,
1088
- bottomHandle: getOptionalBottomHandle(showBottomHandle, dismiss),
1257
+ bottomHandle: getOptionalBottomHandle(showBottomHandle, dismiss, config.handle),
1258
+ bottomHandleOutside: showBottomHandle && config.handle === "outside",
1089
1259
  handleAnimationComplete,
1090
1260
  headerProps,
1091
1261
  hoverProps: getPanelHoverProps(showSideHandle, setIsHovered),
@@ -1101,7 +1271,7 @@ const useSheetPanelModel = (props) => {
1101
1271
  };
1102
1272
  const SheetPanel = (props) => {
1103
1273
  const { item, isTop, config, classNames, Content, shouldRender, renderHeader, prefersReducedMotion } = props;
1104
- const { animateTarget, ariaProps, bottomHandle, handleAnimationComplete, headerProps, hoverProps, inactivePanelProps, initialRadius, isComposable, panelContext, panelRef, panelStyle, resolvedSlideFrom, sideHandle } = useSheetPanelModel(props);
1274
+ const { animateTarget, ariaProps, bottomHandle, bottomHandleOutside, handleAnimationComplete, headerProps, hoverProps, inactivePanelProps, initialRadius, isComposable, panelContext, panelRef, panelStyle, resolvedSlideFrom, sideHandle } = useSheetPanelModel(props);
1105
1275
  const panelContent = /* @__PURE__ */ jsxs(m.div, {
1106
1276
  animate: animateTarget,
1107
1277
  className: classNames.panel || void 0,
@@ -1129,18 +1299,21 @@ const SheetPanel = (props) => {
1129
1299
  ...hoverProps,
1130
1300
  ...inactivePanelProps,
1131
1301
  ...ariaProps,
1132
- children: [sideHandle, /* @__PURE__ */ jsxs("div", {
1133
- className: "relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[inherit]",
1134
- children: [bottomHandle, /* @__PURE__ */ jsx(PanelInnerContent, {
1135
- Content,
1136
- data: item.data,
1137
- headerClassName: classNames.header || void 0,
1138
- headerProps,
1139
- isComposable,
1140
- renderHeader,
1141
- shouldRender
1142
- })]
1143
- })]
1302
+ children: [
1303
+ sideHandle,
1304
+ bottomHandleOutside ? bottomHandle : null,
1305
+ /* @__PURE__ */ jsxs("div", {
1306
+ className: "relative flex min-h-0 flex-1 flex-col overflow-hidden rounded-[inherit]",
1307
+ children: [bottomHandleOutside ? null : bottomHandle, /* @__PURE__ */ jsx(PanelInnerContent, {
1308
+ Content,
1309
+ data: item.data,
1310
+ headerProps,
1311
+ isComposable,
1312
+ renderHeader,
1313
+ shouldRender
1314
+ })]
1315
+ })
1316
+ ]
1144
1317
  }, item.id);
1145
1318
  return /* @__PURE__ */ jsx(SheetPanelContext.Provider, {
1146
1319
  value: panelContext,
@@ -1227,6 +1400,13 @@ const useSnapState = (config, isOpen, side, stack) => {
1227
1400
  snapHeights
1228
1401
  };
1229
1402
  };
1403
+ const usePanelKeyboardInset = (config, isOpen, side) => {
1404
+ const panelWrapperRef = useRef(null);
1405
+ return {
1406
+ keyboardInset: useKeyboardInset(isOpen && side === "bottom" && config.repositionInputs, panelWrapperRef),
1407
+ panelWrapperRef
1408
+ };
1409
+ };
1230
1410
  const useCloseControls = (rawClose, rawPop) => {
1231
1411
  const closeReasonRef = useRef("programmatic");
1232
1412
  const closeWith = (reason) => {
@@ -1275,15 +1455,14 @@ const useDismissalEffects = ({ closeReasonRef, config, isOpen, rawClose, rawPop,
1275
1455
  useEffect(() => {
1276
1456
  const shouldListen = isOpen && config.closeOnEscape && config.dismissible;
1277
1457
  const handleKeyDown = (e) => {
1278
- if (e.key === "Escape") {
1279
- e.preventDefault();
1280
- dismissFromEscape({
1281
- closeReasonRef,
1282
- rawClose,
1283
- rawPop,
1284
- stackLengthRef
1285
- });
1286
- }
1458
+ if (e.key !== "Escape" || e.defaultPrevented) return;
1459
+ e.preventDefault();
1460
+ dismissFromEscape({
1461
+ closeReasonRef,
1462
+ rawClose,
1463
+ rawPop,
1464
+ stackLengthRef
1465
+ });
1287
1466
  };
1288
1467
  if (shouldListen) document.addEventListener("keydown", handleKeyDown);
1289
1468
  return () => {
@@ -1299,7 +1478,7 @@ const useDismissalEffects = ({ closeReasonRef, config, isOpen, rawClose, rawPop,
1299
1478
  ]);
1300
1479
  useEffect(() => {
1301
1480
  const CloseWatcherConstructor = globalThis.CloseWatcher;
1302
- const shouldListen = isOpen && config.dismissible && CloseWatcherConstructor !== void 0;
1481
+ const shouldListen = isOpen && config.closeOnEscape && config.dismissible && CloseWatcherConstructor !== void 0;
1303
1482
  let watcher;
1304
1483
  const handleClose = () => {
1305
1484
  dismissFromEscape({
@@ -1321,6 +1500,7 @@ const useDismissalEffects = ({ closeReasonRef, config, isOpen, rawClose, rawPop,
1321
1500
  };
1322
1501
  }, [
1323
1502
  isOpen,
1503
+ config.closeOnEscape,
1324
1504
  config.dismissible,
1325
1505
  rawPop,
1326
1506
  rawClose,
@@ -1341,6 +1521,7 @@ const SheetRenderer = ({ store, config, sheets, componentMap, classNames: classN
1341
1521
  const classNames = resolveClassNames(classNamesProp);
1342
1522
  const { activeSnapIndex, handleSnap, snapHeights } = useSnapState(config, isOpen, side, stack);
1343
1523
  const { close, closeReasonRef, closeWith, pop, popWith } = useCloseControls(rawClose, rawPop);
1524
+ const { panelWrapperRef, keyboardInset } = usePanelKeyboardInset(config, isOpen, side);
1344
1525
  useBodyScale(config, isOpen, prefersReducedMotion);
1345
1526
  useFocusRestore(isOpen);
1346
1527
  useDismissalEffects({
@@ -1386,6 +1567,7 @@ const SheetRenderer = ({ store, config, sheets, componentMap, classNames: classN
1386
1567
  }), /* @__PURE__ */ jsx(RemoveScroll, {
1387
1568
  enabled: shouldLockScroll,
1388
1569
  forwardProps: true,
1570
+ ref: panelWrapperRef,
1389
1571
  children: /* @__PURE__ */ jsx("div", {
1390
1572
  className: "pointer-events-none fixed inset-0 overflow-hidden",
1391
1573
  style: { zIndex: config.zIndex + 1 },
@@ -1407,6 +1589,7 @@ const SheetRenderer = ({ store, config, sheets, componentMap, classNames: classN
1407
1589
  isNested,
1408
1590
  isTop,
1409
1591
  item,
1592
+ keyboardInset,
1410
1593
  layout,
1411
1594
  onSnap: handleSnap,
1412
1595
  pop,
@@ -1438,6 +1621,15 @@ const getComponentName = (component) => {
1438
1621
  return name === "" ? void 0 : name;
1439
1622
  };
1440
1623
  const getNodeEnv = () => globalThis.process?.env?.NODE_ENV;
1624
+ /**
1625
+ * Generate a unique sheet id. `crypto.randomUUID` is only available in
1626
+ * secure contexts, so fall back to a timestamp + random suffix on
1627
+ * non-secure origins (e.g. plain-HTTP LAN dev servers).
1628
+ */
1629
+ const generateSheetId = () => {
1630
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
1631
+ return `sheet-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1632
+ };
1441
1633
  const getStringArg = (value, name) => {
1442
1634
  if (typeof value !== "string") throw new TypeError(`Expected ${name} to be a string.`);
1443
1635
  return value;
@@ -1489,7 +1681,7 @@ const resolveArgs = (componentRegistry, componentMap, getNextKey, warnedNames, f
1489
1681
  return {
1490
1682
  ariaLabel: resolvePresentationOptions(third)?.ariaLabel,
1491
1683
  data: toRecord(second),
1492
- id: crypto.randomUUID(),
1684
+ id: generateSheetId(),
1493
1685
  type: typeKey
1494
1686
  };
1495
1687
  }
@@ -1828,7 +2020,7 @@ const SheetClose = ({ asChild, className, style, children }) => {
1828
2020
  const isAsChild = asChild === true;
1829
2021
  return /* @__PURE__ */ jsx(isAsChild ? Slot : "button", {
1830
2022
  "aria-label": children === void 0 || children === null ? "Close" : void 0,
1831
- className: joinClassNames(isAsChild ? 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),
2023
+ className: joinClassNames(isAsChild ? void 0 : "absolute right-2 top-2 z-20 flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-inherit opacity-50 transition-opacity duration-150 hover:opacity-100", className),
1832
2024
  onClick: close,
1833
2025
  style,
1834
2026
  type: isAsChild ? void 0 : "button",
@@ -1860,7 +2052,8 @@ const SheetFooter = ({ asChild, className, style, children }) => {
1860
2052
  //#endregion
1861
2053
  //#region src/SheetParts/sheet-handle.tsx
1862
2054
  const SheetHandle = ({ asChild, className, style, children }) => {
1863
- const { close, back, isNested } = useSheetPanel();
2055
+ const { close, back, isNested, side } = useSheetPanel();
2056
+ if (side !== "bottom") return null;
1864
2057
  const dismiss = isNested ? back : close;
1865
2058
  const isAsChild = asChild === true;
1866
2059
  return /* @__PURE__ */ jsx(isAsChild ? Slot : "button", {
@@ -1881,7 +2074,7 @@ const SheetHandle = ({ asChild, className, style, children }) => {
1881
2074
  const SheetHeader = ({ asChild, className, style, children }) => {
1882
2075
  const isAsChild = asChild === true;
1883
2076
  return /* @__PURE__ */ jsx(isAsChild ? Slot : "header", {
1884
- className: joinClassNames(isAsChild ? "shrink-0" : "flex h-14 shrink-0 items-center justify-between border-b px-6", className),
2077
+ className: joinClassNames(isAsChild ? "shrink-0" : "flex shrink-0 items-center justify-between gap-3", className),
1885
2078
  style,
1886
2079
  children
1887
2080
  });
@@ -1889,7 +2082,8 @@ const SheetHeader = ({ asChild, className, style, children }) => {
1889
2082
  //#endregion
1890
2083
  //#region src/SheetParts/sheet-title.tsx
1891
2084
  const SheetTitle = ({ asChild, className, style, children }) => {
1892
- const { panelId } = useSheetPanel();
2085
+ const { panelId, registerTitle } = useSheetPanel();
2086
+ useEffect(() => registerTitle(), [registerTitle]);
1893
2087
  const isAsChild = asChild === true;
1894
2088
  return /* @__PURE__ */ jsx(isAsChild ? Slot : "h2", {
1895
2089
  className: joinClassNames(isAsChild ? void 0 : "font-semibold text-sm", className),