"use client" import * as React from "react" import { Dialog as SheetPrimitive } from "radix-ui" import { useIsMobile } from "../../hooks/use-mobile" import { EDGE_RESIZE_HANDLE_CLASS, EDGE_RESIZE_HANDLE_LEFT_CLASS, startEdgeResizeDrag, verticalResizeSeparatorAria, } from "../../lib/edge-resize-handle" import { getStorageItem, scheduleStorageWrite } from "../../lib/persisted-state" import { ShortcutScopeProvider } from "../../lib/shortcut-scope" import { cn } from "../../lib/utils" import { Button } from "./button" function Sheet({ ...props }: React.ComponentProps) { return } function SheetTrigger({ ...props }: React.ComponentProps) { return } function SheetClose({ ...props }: React.ComponentProps) { return } function SheetPortal({ ...props }: React.ComponentProps) { return } function SheetOverlay({ className, ...props }: React.ComponentProps) { return ( ) } export type SheetContentSize = "sm" | "md" | "lg" | "xl" | "full" /** * Inline width per size, applied from the `sm` breakpoint up. Below `sm` every * sheet stays at the base `w-3/4` so narrow screens keep a visible dismiss * target beside the panel. Scoped to `left` / `right`: a `top` / `bottom` sheet * is stretched by `inset-x-0`, and capping its width would strand it against * one edge instead of centering it. */ const SHEET_CONTENT_SIZE: Record = { // 20rem — rail beside a hub: filters, properties, a short option list. sm: "data-[side=left]:sm:max-w-[20rem] data-[side=right]:sm:max-w-[20rem]", // 24rem — single-column forms, export options. md: "data-[side=left]:sm:max-w-[24rem] data-[side=right]:sm:max-w-[24rem]", // 32rem — two-column forms, option tables, record summaries. lg: "data-[side=left]:sm:max-w-[32rem] data-[side=right]:sm:max-w-[32rem]", // 40rem — dense tables or side-by-side comparison. xl: "data-[side=left]:sm:max-w-[40rem] data-[side=right]:sm:max-w-[40rem]", full: "data-[side=left]:w-full data-[side=right]:w-full data-[side=left]:sm:max-w-none data-[side=right]:sm:max-w-none", } /** Narrowest a user can drag a resizable sheet. Below this the content stops fitting. */ export const SHEET_RESIZE_MIN_WIDTH = 280 /** Widest a user can drag a resizable sheet. Past this, promote the surface to a route. */ export const SHEET_RESIZE_MAX_WIDTH = 960 /** Viewport breathing room kept to the side of a dragged sheet, so the page behind stays reachable. */ const SHEET_RESIZE_VIEWPORT_GUTTER = 48 /** Keyboard width nudge — matches FloatingWindow fine step. */ const SHEET_RESIZE_NUDGE = 16 /** Keyboard width nudge with Shift — matches FloatingWindow coarse step. */ const SHEET_RESIZE_NUDGE_COARSE = 48 function clampSheetWidth(width: number, min: number, max: number): number { const viewportMax = typeof window === "undefined" ? max : Math.max(min, window.innerWidth - SHEET_RESIZE_VIEWPORT_GUTTER) return Math.round(Math.min(Math.max(width, min), Math.min(max, viewportMax))) } function readStoredSheetWidth(key: string | undefined): number | null { if (!key) return null const raw = getStorageItem(key) if (raw == null) return null const parsed = Number.parseInt(raw, 10) return Number.isFinite(parsed) ? parsed : null } function SheetContent({ className, children, side = "right", size = "md", showCloseButton = true, showOverlay = true, dismissOnOutsideClick = true, resizable = false, resizeStorageKey, minWidth = SHEET_RESIZE_MIN_WIDTH, maxWidth = SHEET_RESIZE_MAX_WIDTH, widthResetToken, onInteractOutside, style, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left" /** * Width of a `left` / `right` sheet: `sm` 20rem, `md` 24rem (default), * `lg` 32rem, `xl` 40rem, `full` viewport. `top` / `bottom` sheets span the * viewport width and ignore this prop. Past `xl`, prefer a route over a sheet. */ size?: SheetContentSize showCloseButton?: boolean showOverlay?: boolean /** * Let a click on the page behind close the sheet. On by default, matching * Escape and the close button as a third equivalent way out. * * Radix's own outside-click detection already excludes anything a click * *opens* — a `Select` / `Popover` / `DropdownMenu` (or a nested sheet) * triggered from inside this one registers as part of the same dismissable * layer stack, so choosing an option in one doesn't also dismiss this * sheet. That covers "the click outside opened something" for free, * without a hand-rolled `onPointerDownOutside` guard — see the * drawer-vs-dialog pattern doc's "Dismissal" section. * * Set `false` only for a rail meant to stay open while the user keeps * working the live page behind it on purpose (e.g. a Properties rail the * user wants visible while clicking around the hub to see it react) — * Escape and the close button are still always available. */ dismissOnOutsideClick?: boolean /** * Show a resize handle on the sheet's inner edge (`left` / `right` only; off * on mobile). Pointer drag and keyboard: focus the handle, then ArrowLeft / * ArrowRight change width (Shift = coarse). Height stays fixed. */ resizable?: boolean /** * Remember the dragged width under this key. Panel width is a shell display * preference, so it belongs in the unnamespaced `shell:` family alongside the * secondary panel and Ask Leo rail widths. Omit to reset the width each open. */ resizeStorageKey?: string minWidth?: number maxWidth?: number /** * Change this to throw away a dragged width and fall back to the width the * sheet would otherwise open at. `FloatingSheetPanel` passes the chosen size, * so picking Medium after dragging actually moves the rail. */ widthResetToken?: string | number }) { const isMobile = useIsMobile() const canResize = resizable && (side === "left" || side === "right") && !isMobile const contentRef = React.useRef(null) const [width, setWidth] = React.useState(() => readStoredSheetWidth(resizeStorageKey), ) // Skip the first run: on mount the stored width is the user's own drag, not // something a size change invalidated. const seenResetToken = React.useRef(widthResetToken) React.useEffect(() => { if (seenResetToken.current === widthResetToken) return seenResetToken.current = widthResetToken setWidth(null) if (resizeStorageKey) scheduleStorageWrite(resizeStorageKey, "") }, [widthResetToken, resizeStorageKey]) const commitWidth = React.useCallback( (nextWidth: number) => { const clamped = clampSheetWidth(nextWidth, minWidth, maxWidth) setWidth(clamped) if (resizeStorageKey) scheduleStorageWrite(resizeStorageKey, String(clamped)) return clamped }, [minWidth, maxWidth, resizeStorageKey], ) const startResize = React.useCallback( (event: React.MouseEvent) => { // The handle is a direct child of the panel. Don't look the panel up by // `data-slot`: consumers such as `FloatingSheetPanelContent` rename that // slot to identify their drawer. const panel = event.currentTarget.parentElement if (!panel) return const startX = event.clientX const startWidth = panel.getBoundingClientRect().width let nextWidth = startWidth startEdgeResizeDrag( event, (clientX) => { const delta = side === "right" ? startX - clientX : clientX - startX nextWidth = clampSheetWidth(startWidth + delta, minWidth, maxWidth) setWidth(nextWidth) }, () => { if (resizeStorageKey) scheduleStorageWrite(resizeStorageKey, String(nextWidth)) }, ) }, [side, minWidth, maxWidth, resizeStorageKey], ) const onResizeKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return event.preventDefault() event.stopPropagation() const step = event.shiftKey ? SHEET_RESIZE_NUDGE_COARSE : SHEET_RESIZE_NUDGE // Growing toward the page: on a right rail, Left widens; on a left rail, Right widens. const grow = (side === "right" && event.key === "ArrowLeft") || (side === "left" && event.key === "ArrowRight") const delta = grow ? step : -step const base = width ?? contentRef.current?.getBoundingClientRect().width ?? minWidth commitWidth(base + delta) }, [side, width, minWidth, commitWidth], ) // Inline width beats both the `size` cap and the floating-rail width class, // without the specificity fight that a utility class would start. const resizeStyle: React.CSSProperties | undefined = canResize && width != null ? { width, maxWidth: "none" } : undefined const valueNow = width ?? contentRef.current?.getBoundingClientRect().width ?? minWidth return ( {showOverlay && } { onInteractOutside?.(event) if (!dismissOnOutsideClick) event.preventDefault() }} {...props} // After the spread, not before: `FloatingSheetPanelContent` renames // `data-slot` to identify its drawer, so styling that has to reach // every sheet needs a hook a consumer cannot displace. data-sheet-content="" > {children} {canResize && (
)} {showCloseButton && (