import { createContext, useContext, useId, useRef, type ComponentProps, type ReactNode, } from "react"; import { ArrowLeftIcon, XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { Button } from "./button"; import { LogoTileSizeProvider } from "./logo-tile"; import { SECTION_TITLE_CLASS } from "./section"; import { Sheet, SheetClose, SheetDescription, SheetTitle, SheetTrigger } from "./sheet"; import { Skeleton } from "./skeleton"; import { cn } from "../lib/cn"; import { containedPortal } from "./portal-container"; const DialogContainedPortal = containedPortal(DialogPrimitive.Portal); /* ---------------------------------------------------------------------------- DetailSheet, DetailPage and DetailInline (design brief 7.5) - the single place to manage one thing: anything with more than one control or its own sub-list. One anatomy in three presentations: - DetailSheet (A, default) 520px right sheet, sections, sticky opaque footer, full screen on phones. Keeps the list in view. For accounts, people, keys and schedules. - DetailPage (B, default for objects with their own tables) a deep-linkable page. For variable sets and environments. - DetailInline (C) expand in place under a list row. Only for one level of secondary options (see ListRow `expanded`). The parts are shared: DetailHeader (tile, 18/600 title, subtitle, status, ⋯), DetailBody with borderless DetailSections split by hairlines, facts as label/value rows (DetailFacts, 12px label, 14px value) and DetailFooter (destructive ghost left, Cancel/Done and the primary right). Keep the sheet URL-addressable (`?account=`) at the call site. -------------------------------------------------------------------------- */ export type DetailPresentation = "sheet" | "preview" | "page" | "inline"; const PresentationContext = createContext("preview"); function usePresentation(): DetailPresentation { return useContext(PresentationContext); } /** Where the enclosing detail view is shown. */ export function useDetailPresentation(): DetailPresentation { return usePresentation(); } /* ---------------------------------------------------------------------------- A. The sheet. -------------------------------------------------------------------------- */ /** The sheet root. Control it with `open` and `onOpenChange` (tie it to the URL). */ export function DetailSheet(props: ComponentProps) { return ; } export const DetailSheetTrigger = SheetTrigger; const SHEET_PANEL = "flex min-h-0 min-w-0 flex-col bg-surface text-fg shadow-[var(--og-shadow-lg)] @container/detail"; export interface DetailSheetContentProps extends Omit< ComponentProps, "children" > { children: ReactNode; } /** * The 520px right sheet: full height, full screen on phones, slides in over * 200ms (reduced motion skips it). Children are a DetailHeader, a DetailBody * and an optional DetailFooter. Pass `aria-describedby={undefined}` when the * header has no subtitle. */ export function DetailSheetContent({ children, className, onOpenAutoFocus, onCloseAutoFocus, ...props }: DetailSheetContentProps) { // Sheets usually open from a list row, not a DetailSheetTrigger, so Radix // has nothing to return focus to. Remember what had focus as the sheet // opened (the row) and go back there on close. const returnTo = useRef(null); return ( { // Focus hasn't moved yet: this is still the row that opened the sheet. const active = document.activeElement; returnTo.current = active instanceof HTMLElement && active !== document.body ? active : null; onOpenAutoFocus?.(event); if (event.defaultPrevented) return; // Land on the sheet itself: screen readers hear its title and // description, and no control lights up before anyone asks. event.preventDefault(); (event.currentTarget as HTMLElement | null)?.focus({ preventScroll: true }); }} onCloseAutoFocus={(event) => { onCloseAutoFocus?.(event); if (event.defaultPrevented) return; const target = returnTo.current; returnTo.current = null; if (target?.isConnected) { event.preventDefault(); target.focus({ preventScroll: true }); } }} className={cn( SHEET_PANEL, "fixed inset-y-0 right-0 z-50 h-full w-full border-border outline-hidden sm:w-[520px] sm:max-w-[calc(100vw-2rem)] sm:border-l", "transition-transform duration-200 ease-out starting:translate-x-full", className, )} > {children} ); } /** * The same sheet panel without the overlay, in the page flow. For docs, * previews and screenshots; `onClose` wires the header's close button. */ export function DetailSheetPreview({ children, label, onClose, className, }: { children: ReactNode; /** Accessible name of the region, usually the object's name. */ label: string; onClose?: (() => void) | undefined; className?: string | undefined; }) { return (
{children}
); } const PreviewCloseContext = createContext<(() => void) | null>(null); /* ---------------------------------------------------------------------------- B. The page, and C. inline. -------------------------------------------------------------------------- */ export interface DetailBackLink { label: ReactNode; href?: string | undefined; onClick?: (() => void) | undefined; } /** A deep-linkable detail page on the standard 960px column. */ export function DetailPage({ back, children, className, }: { /** "Variable sets": the list this page belongs to. */ back?: DetailBackLink | undefined; children: ReactNode; className?: string | undefined; }) { return (
{back ? : null} {children}
); } /** "← Variable sets": the back link a detail page, or a page opened from another scope, starts with. */ export function BackLink({ back }: { back: DetailBackLink }) { const className = "mb-4 inline-flex w-fit items-center gap-1.5 rounded-[6px] text-sm leading-5 font-medium text-fg-muted transition-colors duration-[120ms] hover:text-fg pointer-coarse:min-h-11"; const content = ( <>