{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sheet","type":"registry:ui","title":"Sheet","description":"A modal panel that slides in from an edge of the viewport, for settings, filters, or any focused task that doesn't need a full page","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["overlays"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/sheet/Sheet.tsx","content":"\"use client\";\n\nimport { forwardRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useFocusTrap } from \"../../internals/focus-trap\";\nimport { useScrollLock } from \"../../internals/scroll-lock\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { anchored, prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { DURATIONS, JS_EASINGS } from \"../../internals/motion/tokens\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport type { TransitionSpec } from \"../../internals/motion/transitions\";\n\nexport type SheetSide = \"left\" | \"right\" | \"top\" | \"bottom\";\nexport type SheetSize = \"sm\" | \"md\" | \"lg\";\n\nexport interface SheetProps {\n\t/** Whether the sheet is open. Controlled when given; the component keeps its own copy either way. */\n\topen?: boolean;\n\t/** Called with the new value whenever the sheet opens or closes. */\n\tonOpenChange?: (open: boolean) => void;\n\t/** Edge of the viewport the panel slides in from. */\n\tside?: SheetSide;\n\t/** Heading rendered in the header and wired to `aria-labelledby`. */\n\ttitle?: string;\n\t/** Supporting text under the title, wired to `aria-describedby`. */\n\tdescription?: string;\n\t/** Whether Escape, the scrim and the close button can close the sheet. */\n\tdismissible?: boolean;\n\t/** Panel width (left/right sides) or height (top/bottom sides). */\n\tsize?: SheetSize;\n\t/** Panel body content. */\n\tchildren?: ReactNode;\n\t/** Content pinned below the body, e.g. actions. */\n\tfooter?: ReactNode;\n\t/**\n\t * The panel's `id`. A trigger the caller renders outside the component\n\t * points its `aria-controls` at it while the panel is open.\n\t */\n\tid?: string;\n\t/** Additional CSS classes merged onto the panel. */\n\tclassName?: string;\n\t/**\n\t * Accessible name for the panel when there is no `title`. A modal dialog\n\t * must be named, and `aria-labelledby` can only point at a rendered\n\t * heading — pass this when the header is built from `children` instead.\n\t * Ignored once `title` is set, since `aria-labelledby` already supplies\n\t * the name.\n\t */\n\tariaLabel?: string;\n\t/**\n\t * Plays the `close` cue through the sound controller when the sheet is\n\t * dismissed. Off by default; only audible once the user has enabled\n\t * sound.\n\t */\n\tsound?: boolean;\n}\n\n// The one place this component owns motion. Not `anchored`: that helper is\n// scale+opacity by design and deliberately carries no translate term, and a\n// sheet's whole gesture is travel. Not a pixel-distance preset either — a\n// sheet has to clear its own edge whatever its size, which only `%`\n// expresses.\n//\n// The exit does NOT halve its travel the way the scale rung does. A sheet\n// that slid half-way off the viewport and then vanished would read worse\n// than one that simply leaves; it is a named exception to the half-delta\n// exit rule rather than an oversight.\nfunction edgeSlide(\n\t_node: Element,\n\tparams?: { side: SheetSide; entering: boolean }\n): TransitionSpec {\n\tconst reduced = prefersReducedMotion();\n\tconst entering = params?.entering ?? false;\n\tconst side = params?.side ?? \"right\";\n\tconst axis = side === \"left\" || side === \"right\" ? \"X\" : \"Y\";\n\tconst sign = side === \"left\" || side === \"top\" ? -1 : 1;\n\treturn {\n\t\tdelay: 0,\n\t\t// Reduced motion collapses this to 0, which makes `runTransition`\n\t\t// finish synchronously and never touch `element.animate()` — so the\n\t\t// close is exactly as synchronous as it was before the sheet animated\n\t\t// out at all.\n\t\tduration: reduced ? 0 : entering ? DURATIONS.base : DURATIONS.exit,\n\t\teasing: entering ? JS_EASINGS.out : JS_EASINGS.in,\n\t\t// `u = 1 - t`: fully out at t=0, resting at t=1. No opacity term — a\n\t\t// sheet leaves by travelling, and fading it as well reads as two\n\t\t// gestures fighting.\n\t\tcss: (_t, u) => `transform: translate${axis}(${sign * 100 * u}%)`,\n\t};\n}\n\nconst POSITION_CLASSES: Record<SheetSide, string> = {\n\tleft: \"inset-y-0 left-0 border-r border-border\",\n\tright: \"inset-y-0 right-0 border-l border-border\",\n\ttop: \"inset-x-0 top-0 border-b border-border\",\n\tbottom: \"inset-x-0 bottom-0 border-t border-border\",\n};\n\n// Literal Tailwind class strings (not template-built at runtime) so the\n// Tailwind v4 source scanner — which reads this file as text, not as\n// evaluated JS — can see every candidate class it needs to generate.\nconst WIDTH_CLASSES: Record<SheetSize, string> = {\n\tsm: \"w-[20rem] max-w-[90vw] h-dvh\",\n\tmd: \"w-[24rem] max-w-[90vw] h-dvh\",\n\tlg: \"w-[32rem] max-w-[90vw] h-dvh\",\n};\nconst HEIGHT_CLASSES: Record<SheetSize, string> = {\n\tsm: \"h-[14rem] max-h-[85vh] w-full\",\n\tmd: \"h-[18rem] max-h-[85vh] w-full\",\n\tlg: \"h-[24rem] max-h-[85vh] w-full\",\n};\n\nexport const Sheet = forwardRef<HTMLDivElement, SheetProps>(function Sheet(\n\t{\n\t\topen: openProp,\n\t\tonOpenChange,\n\t\tside = \"right\",\n\t\ttitle,\n\t\tdescription,\n\t\tdismissible = true,\n\t\tsize = \"md\",\n\t\tchildren,\n\t\tfooter,\n\t\tid,\n\t\tclassName,\n\t\tariaLabel,\n\t\tsound = false,\n\t},\n\tforwardedRef\n) {\n\t// The React shape of the source's `open = $bindable(false)`: an internal\n\t// copy seeded from the prop, re-synced during render whenever the CALLER\n\t// changes the prop, and free to move on its own in between. Re-synced in\n\t// the render path, not an effect: an effect would paint one frame of the\n\t// stale value first.\n\tconst [open, setOpenState] = useState(openProp ?? false);\n\tconst [lastOpenProp, setLastOpenProp] = useState(openProp);\n\tif (lastOpenProp !== openProp) {\n\t\tsetLastOpenProp(openProp);\n\t\tsetOpenState(openProp ?? false);\n\t}\n\n\t// A no-op while `sound` is false, so the call site below stays unguarded.\n\tconst playCue = useSoundCue(sound);\n\n\tfunction close() {\n\t\t// The same guard keeps the cue honest: every dismissal — Escape, the\n\t\t// scrim, the close button — funnels through here, while a parent\n\t\t// writing `open` to false never does, and a second Escape landing\n\t\t// during the exit is already gone by the time it arrives.\n\t\tif (!open) return;\n\t\tsetOpenState(false);\n\t\tplayCue(\"close\");\n\t\tonOpenChange?.(false);\n\t}\n\n\t// One seed per instance, suffixed for title/description — SSR-stable,\n\t// same approach the source settled on with `$props.id()`.\n\tconst uid = useFancyId();\n\tconst titleId = title ? `${uid}-title` : undefined;\n\tconst descriptionId = description ? `${uid}-description` : undefined;\n\n\t// Convention C-1: the NODE, not a ref. The panel is created by\n\t// `presence.mounted`, so a `useRef` + `[]`-deps effect would still be\n\t// holding `null` when the trap's effect fires and the trap would never\n\t// arm — silently.\n\tconst [panel, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\t// Returns the two functions the source hands out through `onActivate`:\n\t// the eager return, and the re-arm.\n\tconst trap = useFocusTrap(panel);\n\n\tconst presence = usePresence(open, {\n\t\t// The two halves of the focus handshake, at the two moments the\n\t\t// source puts them: `onintrostart` → rearm, `onoutrostart` →\n\t\t// returnFocusNow.\n\t\t//\n\t\t// `returnFocusNow` at the dismiss instant is the whole point: it is\n\t\t// called on EVERY close path (Escape, the scrim, the close button, a\n\t\t// caller's own `open` write). Waiting for the trap's own destroy\n\t\t// would leave a keyboard user on `<body>` for the whole length of the\n\t\t// slide-out, because the closing panel is made inert the instant the\n\t\t// exit starts.\n\t\t//\n\t\t// `rearm` is the other half. A sheet reopened DURING its exit\n\t\t// reverses instead of remounting, so the trap is never re-created:\n\t\t// without this the panel would come back `aria-modal` and interactive\n\t\t// with focus left on the trigger behind it, and the eager return\n\t\t// already spent for the life of the instance.\n\t\tonEnterStart: () => trap.rearm(),\n\t\tonExitStart: () => trap.returnFocusNow(),\n\t});\n\n\t// Release timing: keyed on `presence.mounted`, not `open`, so the page\n\t// stays locked until the panel has actually finished sliding out instead\n\t// of unlocking the instant `open` flips and leaving the page scrollable\n\t// under a scrim still on screen — the same reason the source reached for\n\t// `use:scrollLock` (outro-delayed destroy) over an `$effect`.\n\tuseScrollLock(presence.mounted);\n\n\t// `active: open` — a plain boolean where the source needed `() => open`.\n\t// The layer stays ON the stack for its whole exit and stops being TOP of\n\t// it the instant `open` flips, so a second Escape during the slide-out\n\t// changes nothing.\n\tuseDismissable(panel, {\n\t\tonDismiss: close,\n\t\tescape: dismissible,\n\t\toutsideClick: dismissible,\n\t\tactive: open,\n\t\tkeepFocus: true,\n\t});\n\n\t// Convention C-2: composed ABOVE the conditional below. `side` rides in\n\t// through the params factory, read at the instant a leg starts.\n\tconst panelRef = useComposedRefs(\n\t\tsetPanelNode,\n\t\tforwardedRef,\n\t\tpresence.register(\"panel\", edgeSlide, (entering) => ({ side, entering }))\n\t);\n\n\t// The scrim fades on opacity alone (`scale: false`) while the panel\n\t// travels, and both run the same clock, so they leave together and the\n\t// unmount is a tie rather than a straggler.\n\tconst scrimRef = presence.register(\"scrim\", anchored, (entering) => ({\n\t\tentering,\n\t\tscale: false,\n\t\tduration: DURATIONS.base,\n\t\texitDuration: DURATIONS.exit,\n\t}));\n\n\tconst isHorizontal = side === \"left\" || side === \"right\";\n\tconst dimensionClasses = isHorizontal ? WIDTH_CLASSES[size] : HEIGHT_CLASSES[size];\n\n\tconst panelClasses = cn(\n\t\t\"ft-sheet-panel bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 p-4 shadow-2xl\",\n\t\tPOSITION_CLASSES[side],\n\t\tdimensionClasses,\n\t\tclassName\n\t);\n\n\t// No portal-before-focus-trap ordering ceremony here (divergence D-10):\n\t// `createPortal` commits children into the container before any effect\n\t// runs and refs populate before layout effects, so the node the trap\n\t// focuses is always connected.\n\t//\n\t// The `Portal` stays mounted and its CHILDREN are what `presence.mounted`\n\t// gates — `usePortalTarget` resolves its container in a layout effect, so\n\t// a `Portal` mounting in the same commit as the surface would skip the\n\t// entrance outright.\n\t//\n\t// `data-state` is an ordinary attribute (divergence D-2) carrying\n\t// `surfaceState`'s TWO values (convention C-5); `inert` is not written by\n\t// hand, because `usePresence` sets it on every registered node for the\n\t// whole exit. `data-side` drives POSITION_CLASSES semantics for\n\t// consumers, not just the keyframes the source once selected with it.\n\treturn (\n\t\t<Portal>\n\t\t\t{presence.mounted ? (\n\t\t\t\t<>\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={scrimRef}\n\t\t\t\t\t\tclassName=\"ft-sheet-scrim fixed inset-0 z-50 bg-black/60\"\n\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t/>\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={panelRef}\n\t\t\t\t\t\tid={id}\n\t\t\t\t\t\tclassName={panelClasses}\n\t\t\t\t\t\trole=\"dialog\"\n\t\t\t\t\t\taria-modal=\"true\"\n\t\t\t\t\t\taria-label={titleId ? undefined : ariaLabel}\n\t\t\t\t\t\taria-labelledby={titleId}\n\t\t\t\t\t\taria-describedby={descriptionId}\n\t\t\t\t\t\tdata-state={presence.surfaceState}\n\t\t\t\t\t\tdata-side={side}\n\t\t\t\t\t>\n\t\t\t\t\t\t{title || dismissible ? (\n\t\t\t\t\t\t\t<div className=\"ft-sheet-header flex items-start justify-between gap-4\">\n\t\t\t\t\t\t\t\t{title ? (\n\t\t\t\t\t\t\t\t\t<h2 id={titleId} className=\"text-[15px] font-semibold\">\n\t\t\t\t\t\t\t\t\t\t{title}\n\t\t\t\t\t\t\t\t\t</h2>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t{dismissible ? (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"ft-sheet-close text-muted-foreground hover:text-foreground cursor-pointer text-[13px] leading-none\"\n\t\t\t\t\t\t\t\t\t\taria-label=\"Close\"\n\t\t\t\t\t\t\t\t\t\tonClick={close}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t✕\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t{description ? (\n\t\t\t\t\t\t\t<p id={descriptionId} className=\"text-muted-foreground text-[12.5px] leading-relaxed\">\n\t\t\t\t\t\t\t\t{description}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t<div className=\"ft-sheet-body flex flex-1 flex-col gap-3 overflow-y-auto\">\n\t\t\t\t\t\t\t{children}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{footer ? <div className=\"ft-sheet-footer flex justify-end gap-2\">{footer}</div> : null}\n\t\t\t\t\t</div>\n\t\t\t\t</>\n\t\t\t) : null}\n\t\t</Portal>\n\t);\n});\n\nSheet.displayName = \"Sheet\";\n","type":"registry:ui","target":"@ui/fancy/components/sheet/Sheet.tsx"},{"path":"react/src/components/sheet/index.ts","content":"\"use client\";\n\nexport { Sheet } from \"./Sheet\";\nexport type { SheetProps, SheetSide, SheetSize } from \"./Sheet\";\n","type":"registry:ui","target":"@ui/fancy/components/sheet/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/sheet?framework=react"}}