import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react" import { ResizablePanes, type PaneSize } from "./panes/ResizablePanes" /** Handed to renderDetail so the detail can open (and close) the extended pane. */ export interface DrillApi { /** Key of the currently open extended item, or null. */ extendedKey: string | null openExtended: (key: string) => void closeExtended: () => void } interface MasterDetailProps { items: T[] getKey: (item: T) => string selectedKey: string | null onSelect: (key: string) => void renderItem: (item: T, selected: boolean) => ReactNode renderDetail: (item: T, drill: DrillApi) => ReactNode /** * Third pane, opened from inside the detail via drill.openExtended(key). * Omit for a plain two-pane list/detail. */ renderExtended?: (item: T, key: string) => ReactNode extendedTitle?: ReactNode /** Accessible name for the list, e.g. "Traces". */ label: string /** Optional heading/controls rendered above the list. */ listHeader?: ReactNode emptyDetail?: ReactNode /** * "split": resizable list | detail [| extended] panes (default). * "stacked": full-width list on top (tables), detail below. */ layout?: "split" | "stacked" listSize?: PaneSize extendedSize?: PaneSize /** Persists pane widths per screen. */ storageKey?: string } /** * Reusable drill-down layout. Selection is owned by the caller so it can be * driven from elsewhere (deep links, "focus this trace"); the extended pane's * state lives here and resets whenever the selection changes. */ export function MasterDetail({ items, getKey, selectedKey, onSelect, renderItem, renderDetail, renderExtended, extendedTitle = "Details", label, listHeader, emptyDetail, layout = "split", listSize = { default: 320, min: 240, max: 560 }, extendedSize = { default: 380, min: 280, max: 720 }, storageKey, }: MasterDetailProps) { const stacked = layout === "stacked" const listRef = useRef(null) const [extendedKey, setExtendedKey] = useState(null) const selected = items.find((item) => getKey(item) === selectedKey) ?? null useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest" }) }, [selectedKey]) // A different selection invalidates whatever was open in the extended pane. useEffect(() => setExtendedKey(null), [selectedKey]) const select = (key: string) => { setExtendedKey(null) onSelect(key) } const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return e.preventDefault() const idx = items.findIndex((item) => getKey(item) === selectedKey) const next = items[Math.min(items.length - 1, Math.max(0, idx + (e.key === "ArrowDown" ? 1 : -1)))] if (next) select(getKey(next)) } const list = (
{listHeader}
{items.map((item) => { const key = getKey(item) const isSelected = key === selectedKey return (
select(key)} className={`cursor-pointer rounded-[9px] border px-3 py-2 ${ isSelected ? "border-ds-accent/40 bg-ds-surface-2" : "border-transparent hover:bg-ds-surface-2" }`} > {renderItem(item, isSelected)}
) })}
) const drill: DrillApi = { extendedKey, openExtended: setExtendedKey, closeExtended: () => setExtendedKey(null) } const detail = selected ? ( renderDetail(selected, drill) ) : (

{emptyDetail ?? "Select an item to see its details."}

) if (stacked) { return (
{list}
{detail}
) } return ( ) } export default MasterDetail