"use client" /** * FloatingSheetPanel — base shell for hub-adjacent product drawers. * * Composes `Sheet` with the floating inset pattern (`showOverlay={false}`, * rounded rail beside the hub). Feature drawers (Export, Properties, invite, * folder create) supply body + footer content; this module owns chrome only. * * Reference consumer: `components/ui/export-drawer.tsx`. * * Layout contract (scroll body + pinned footer): * ``` * FloatingSheetPanelContent // flex column, overflow-hidden, fixed height * FloatingSheetPanelHeader // shrink-0 *
// optional; pins footer stack * FloatingSheetPanelBody // flex-1 min-h-0 overflow-y-auto — scrolls * FloatingSheetPanelWorkflowFooter // shrink-0 — stays visible *
* ``` * Header and footer MUST NOT live inside `FloatingSheetPanelBody`. */ import * as React from "react" import { FLOATING_SHEET_SIZE_LABEL, FLOATING_SHEET_SIZES, floatingSheetSizeStorageKey, floatingSheetWidthStorageKey, getFloatingSheetInsetProps, useCompactFloatingSheet, type FloatingSheetSide, type FloatingSheetSize, } from "../../lib/floating-sheet-panel" import { OverlayElevationProvider, SHEET_TOOLTIP_Z_INDEX, } from "../../lib/overlay-elevation" import { getStorageItem, scheduleStorageWrite } from "../../lib/persisted-state" import { useSheetRail } from "../../lib/sheet-rail" import { cn } from "../../lib/utils" import { Button } from "./button" import { Kbd, KbdGroup } from "./kbd" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Shortcut, } from "./dropdown-menu" import { Separator } from "./separator" import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle, } from "./sheet" import { Tip } from "./tip" /** * Controlled floating sheet root — alias of `Sheet` with the rail behaviour: * * - **Non-modal.** No focus trap, no scroll lock, no `pointer-events: none` on * the body, so the hub behind stays clickable while the rail is open. This is * an inspector beside the work, not a door in front of it. * - **Single rail.** Opening this panel closes whichever rail was already open. * - **Dismissed by the page, except by its own triggers.** A click on the hub * closes the rail. A click on a control that *feeds* the rail — the * Properties button, a column menu that deep-links into it, the row that * swaps which record it is showing — keeps it open and changes its content * instead. Mark those with {@link railTriggerProps}. */ function FloatingSheetPanel({ exclusive = true, modal = false, ...props }: React.ComponentProps & { /** * Set `false` to let this panel coexist with another open rail. Only correct * when the two are pinned to opposite edges and genuinely belong on screen * together. */ exclusive?: boolean }) { useSheetRail(props.open === true, props.onOpenChange, exclusive) return } interface FloatingSheetPanelContextValue { size: FloatingSheetSize setSize: (size: FloatingSheetSize) => void /** False on compact layouts, where the rail is already near full-bleed. */ sizeable: boolean /** True once `FloatingSheetPanelBody` has been scrolled off its top. */ scrolled: boolean setScrolled: (scrolled: boolean) => void } const FloatingSheetPanelContext = React.createContext( null, ) function useFloatingSheetPanel(component: string): FloatingSheetPanelContextValue { const context = React.useContext(FloatingSheetPanelContext) if (!context) { throw new Error(`${component} must be rendered inside FloatingSheetPanelContent.`) } return context } /** * Marks a control whose job is to put something *into* a rail: the Properties * button, a column menu that deep-links into Filter or Sort, a row that swaps * which record the rail is showing. * * A rail dismisses on an outside click, which is right for the hub at large but * wrong for these: pressing Properties while Properties is open is a request to * change what it shows, not to close it, and Radix sees the same pointerdown * either way. Without the mark the sequence is dismiss-then-reopen — the rail * blinks, its scroll position and any half-filled field are gone, and a toggle * trigger closes it outright. */ export const RAIL_TRIGGER_ATTRIBUTE = "data-rail-trigger" /** * Spread onto the trigger, or onto the menu content when the control that leads * to the rail is a menu item — a portalled menu is outside the rail too, so the * press that chooses "Filter" would dismiss what it is about to fill. */ export function railTriggerProps(): { [RAIL_TRIGGER_ATTRIBUTE]: "" } { return { [RAIL_TRIGGER_ATTRIBUTE]: "" } } function isRailTrigger(target: EventTarget | null): boolean { return target instanceof Element && target.closest(`[${RAIL_TRIGGER_ATTRIBUTE}]`) != null } type InteractOutsideEvent = Parameters< NonNullable["onInteractOutside"]> >[0] type FocusOutsideEvent = Parameters< NonNullable["onFocusOutside"]> >[0] function readStoredSize(key: string | undefined): FloatingSheetSize | null { if (!key) return null const raw = getStorageItem(key) return raw === "sm" || raw === "md" || raw === "lg" ? raw : null } export interface FloatingSheetPanelContentProps extends Omit< React.ComponentProps, "side" | "showCloseButton" | "showOverlay" | "size" > { /** Rail edge. Default `right`. */ side?: FloatingSheetSide /** * Width the rail opens at: `sm` 24rem (default), `md` 32rem, `lg` 40rem. * The user can change this from the toolbar's size menu or by dragging, and * both choices are remembered per `contentSlot`. */ size?: FloatingSheetSize /** * Applied to `SheetContent` as `data-slot` (e.g. `export-drawer`). Also keys * the remembered size and drag width, so each drawer keeps its own. */ contentSlot?: string } function FloatingSheetPanelContent({ side = "right", size = "sm", className, style, contentSlot, children, resizable = true, resizeStorageKey, onOpenAutoFocus, onInteractOutside, onFocusOutside, ...props }: FloatingSheetPanelContentProps) { const compact = useCompactFloatingSheet() const sizeKey = floatingSheetSizeStorageKey(contentSlot) const [chosenSize, setChosenSize] = React.useState( () => readStoredSize(sizeKey) ?? size, ) const [scrolled, setScrolled] = React.useState(false) // Adopt a changed `size` prop. The panel is not guaranteed to unmount between // opens, so the mount-time initializer alone would pin the first size forever. // A size the user picked still wins, which is why the stored value is re-read. const [lastSizeProp, setLastSizeProp] = React.useState(size) if (size !== lastSizeProp) { setLastSizeProp(size) setChosenSize(readStoredSize(sizeKey) ?? size) } const inset = getFloatingSheetInsetProps(side, compact, chosenSize) // Compact rails are already near full-bleed and pin their width with `!w-…`, // which an inline width cannot override. Nothing to drag or resize. const canResize = resizable && !compact const setSize = React.useCallback( (next: FloatingSheetSize) => { setChosenSize(next) if (sizeKey) scheduleStorageWrite(sizeKey, next) }, [sizeKey], ) const context = React.useMemo( () => ({ size: chosenSize, setSize, sizeable: !compact, scrolled, setScrolled }), [chosenSize, setSize, compact, scrolled], ) // Land on the panel itself, not on the toolbar's leading button. Radix would // otherwise focus the first control, which pops its tooltip open the instant // the rail appears and hands that tooltip the first Escape, so the user has // to press Escape twice to leave. Focusing the panel also puts Tab at the top // of the rail's content rather than one control into it. const handleOpenAutoFocus = React.useCallback( (event: Event) => { onOpenAutoFocus?.(event) if (event.defaultPrevented) return event.preventDefault() ;(event.currentTarget as HTMLElement | null)?.focus({ preventScroll: true }) }, [onOpenAutoFocus], ) // The hub dismisses the rail; the rail's own triggers retarget it. Radix // reports both as the same interaction, so the trigger has to say which it is // (`railTriggerProps`). const handleInteractOutside = React.useCallback( (event: InteractOutsideEvent) => { onInteractOutside?.(event) if (event.defaultPrevented) return if (isRailTrigger(event.detail.originalEvent.target)) event.preventDefault() }, [onInteractOutside], ) // A rail closes on an outside *click*, never on focus merely landing outside. // The rail is non-modal on purpose: the page behind stays workable, so focus // leaving is ordinary, not a dismissal. It is also how a rail opened from a // menu item dies — the menu restores focus to its trigger as it unmounts, // milliseconds after the rail it just opened has mounted, and Radix reads // that focus restore as an interaction outside. Radix's own exemption covers // only a `DialogTrigger`, and these rails are controlled and have none. const handleFocusOutside = React.useCallback( (event: FocusOutsideEvent) => { onFocusOutside?.(event) if (event.defaultPrevented) return event.preventDefault() }, [onFocusOutside], ) return ( {children} ) } export interface FloatingSheetPanelToolbarProps { /** * Return to the rail's root panel. Set this while a sub-panel is showing: * back takes the leading slot on the left. Close stays on the right either * way, so a user two levels into Properties can still leave in one action * instead of backing out step by step (Escape is the only other full exit, * and it is not discoverable the way a visible control is). */ onBack?: () => void backLabel?: string /** * Step to the previous and next record. Wire these only when the rail is * showing one of an ordered set (a table row detail); omit for rails that * are not about a record, such as Export. */ onPrevious?: () => void onNext?: () => void previousLabel?: string nextLabel?: string /** Label read out for the size menu. */ sizeLabel?: string /** Hide the size menu on rails whose width is fixed by their content. */ showSize?: boolean closeLabel?: string /** * Extra controls in the right-hand cluster: share, favourite, overflow. * Icon-only buttons in `Tip`. Rendered after size/stepping but before * Close, which always stays last, at the panel's outer edge. */ actions?: React.ReactNode className?: string /** Extra classes on each `Tip`, for rails that need a higher tooltip z-index. */ tipClassName?: string tipZIndex?: number } /** * Top row of a rail: Back on the left when there is somewhere to go back to, * everything else — size, record stepping, consumer `actions`, Close — in one * right-aligned cluster, in that fixed order. * * Close trails the cluster, at the panel's outer corner, and renders whether * or not `onBack` or `actions` are set, on purpose: it is the one control * every rail carries ("leave the rail" is never optional the way stepping or * a size menu are), so it gets the one position that is always the same * corner regardless of how many other controls a given panel happens to add. * A user who drilled into Filter or Sort still needs a one-click way out, not * N presses of Back or a keyboard-only Escape they may not know about — * mirroring the common back-top-left / close-top-right convention. */ function FloatingSheetPanelToolbar({ onBack, backLabel = "Back", onPrevious, onNext, previousLabel = "Previous", nextLabel = "Next", sizeLabel = "Panel size", showSize = true, closeLabel = "Close", actions, className, tipClassName, tipZIndex, }: FloatingSheetPanelToolbarProps) { const { size, setSize, sizeable } = useFloatingSheetPanel("FloatingSheetPanelToolbar") const stepping = onPrevious != null || onNext != null return (
{/* Back is the only leading control — "one level up" within the rail's own navigation. Everything else, including Close, lives in the trailing cluster below so the row reads the same whether or not a given panel happens to have somewhere to go back to. */} {onBack ? (