/* eslint-disable react-refresh/only-export-components */ import { Children, Fragment, isValidElement, useRef } from "react"; import type React from "react"; import { ArrowLeft, Columns3, Grid3X3, List, Rows3, Search, X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/separator"; import { DocsLink } from "@/components/docs-link"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/select"; /** One spacing rhythm for every panel and tab. */ export const PANEL = { body: "p-2.5", gap: "gap-2", band: "px-3 py-2", cardHead: "px-3 py-2.5", header: "h-12.5", } as const; export type PanelViewMode = "grid" | "list"; export type PanelDensity = "comfortable" | "compact"; /** Cards per row. "auto" fits as many as the pane has room for. */ export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6"; export const GRID_SIZES: readonly GridSize[] = [ "auto", "1", "2", "3", "4", "5", "6", ]; // Sized off the pane's own width, not the window. Cards keep a min width, // so narrow panes drop columns on their own. export function gridSizeStyle( size: GridSize, minCardWidth = 320, gap = "0.5rem", ): React.CSSProperties { if (size === "auto") { return { gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`, }; } const n = Number(size); return { gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`, }; } /** The header, toolbar band and body every tab and panel draws. */ export function PanelShell({ chrome = true, icon, title, status, leading, actions, toolbar, tabs, footer, scroll = true, docs, className, children, }: { /** False inside a sidebar, which draws its own header. */ chrome?: boolean; icon?: React.ReactNode; title?: React.ReactNode; /** Short caption beside the title, like "12 of 18 running". */ status?: React.ReactNode; /** Sits before the title, usually a BackButton. */ leading?: React.ReactNode; actions?: React.ReactNode; toolbar?: React.ReactNode; /** A full-bleed tab strip in place of a toolbar. */ tabs?: React.ReactNode; /** A bar pinned under the body, like Cancel and Save. */ footer?: React.ReactNode; /** False when the body scrolls itself. */ scroll?: boolean; /** Docs URL for this screen, shown as a book icon in the header. */ docs?: string | null; className?: string; children: React.ReactNode; }) { const body = (
{children}
); const bands = ( <> {toolbar && (
{toolbar}
)} {tabs && (
{tabs}
)} ); const foot = footer && (
{footer}
); if (!chrome) { return (
{bands} {body} {foot}
); } return (
{leading && (
{leading}
)}
{icon && {icon}} {title} {status && ( <> {status} )}
{(actions || docs) && (
{actions} {docs && }
)}
{bands} {body} {foot}
); } /** Square back button sized for the PanelShell header. */ export function BackButton({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) { const { t } = useTranslation(); const text = label ?? t("common.back"); return ( ); } /** Search box sized to match toolbar buttons. Esc clears it. */ export function PanelSearch({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; /** Takes the whole toolbar, for panels where search is the only control. */ fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; /** Runs on Enter, for searches that query a server. */ onEnter?: () => void; inputRef?: React.Ref; className?: string; }) { const { t } = useTranslation(); const localRef = useRef(null); const text = placeholder ?? t("common.search"); return (
{ localRef.current = el; if (typeof inputRef === "function") inputRef(el); else if (inputRef) ( inputRef as React.MutableRefObject ).current = el; }} type="text" value={value} autoFocus={autoFocus} disabled={disabled} title={title} aria-label={text} placeholder={text} onChange={(e) => onChange(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape" && value) { e.stopPropagation(); onChange(""); } if (e.key === "Enter" && onEnter) { e.preventDefault(); onEnter(); } }} className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30" /> {value && ( )}
); } /** Square segmented control. */ export function Segmented({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer; label: string; icon?: React.ReactNode; count?: number; title?: string; }[]; className?: string; }) { return (
{options.map((option, i) => { const active = option.value === value; return ( ); })}
); } /** Grid or list, plus the compact rows toggle and the cards per row picker. */ export function ViewToggle({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; /** Cards per row in grid view. Pass with onColumns to show the picker. */ columns?: GridSize; onColumns?: (next: GridSize) => void; }) { const { t } = useTranslation(); const toggle = (
onView("grid")} title={t("panel.gridView")} > onView("list")} title={t("panel.listView")} bordered > {density && onDensity && ( onDensity(density === "compact" ? "comfortable" : "compact") } title={ density === "compact" ? t("panel.comfortableRows") : t("panel.compactRows") } bordered > )}
); if (!columns || !onColumns || view !== "grid") return toggle; return (
{toggle}
); } function ToggleButton({ active, onClick, title, bordered, children, }: { active: boolean; onClick: () => void; title: string; bordered?: boolean; children: React.ReactNode; }) { return ( ); } /** * A run of peer facts with a rule between each pair, like "12 online | 40 * hosts". Never wraps; a fact that does not fit is truncated. */ /** Children with fragments opened up, so each fact gets its own divider. */ function flattenFacts(children: React.ReactNode): React.ReactNode[] { return Children.toArray(children).flatMap((child) => isValidElement<{ children?: React.ReactNode }>(child) && child.type === Fragment ? flattenFacts(child.props.children) : [child], ); } export function Facts({ className, children, }: { className?: string; children: React.ReactNode; }) { const items = flattenFacts(children); if (items.length === 0) return null; return ( {items.map((child, i) => ( {i > 0 && ( )} {child} ))} ); } /** Section label with a rule running to the edge. */ export function GroupHeading({ title, count, action, className, }: { title: string; count?: number; action?: React.ReactNode; className?: string; }) { return (
{title} {count !== undefined && ( {count} )}
{action}
); }