163 lines
5.0 KiB
TypeScript
163 lines
5.0 KiB
TypeScript
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<T> {
|
|
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<T>({
|
|
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<T>) {
|
|
const stacked = layout === "stacked"
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
const [extendedKey, setExtendedKey] = useState<string | null>(null)
|
|
const selected = items.find((item) => getKey(item) === selectedKey) ?? null
|
|
|
|
useEffect(() => {
|
|
listRef.current?.querySelector<HTMLElement>('[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 = (
|
|
<div className="flex flex-col gap-2">
|
|
{listHeader}
|
|
<div
|
|
ref={listRef}
|
|
role="listbox"
|
|
aria-label={label}
|
|
tabIndex={0}
|
|
onKeyDown={onKeyDown}
|
|
className={`flex flex-col gap-1.5 overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-1.5 focus:outline-none focus-visible:border-ds-accent ${
|
|
stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-240px)]"
|
|
}`}
|
|
>
|
|
{items.map((item) => {
|
|
const key = getKey(item)
|
|
const isSelected = key === selectedKey
|
|
return (
|
|
<div
|
|
key={key}
|
|
role="option"
|
|
aria-selected={isSelected}
|
|
onClick={() => 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)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const drill: DrillApi = { extendedKey, openExtended: setExtendedKey, closeExtended: () => setExtendedKey(null) }
|
|
const detail = selected ? (
|
|
renderDetail(selected, drill)
|
|
) : (
|
|
<p className="py-10 text-center text-[13px] text-ds-text-3">{emptyDetail ?? "Select an item to see its details."}</p>
|
|
)
|
|
|
|
if (stacked) {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{list}
|
|
<section aria-live="polite">{detail}</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ResizablePanes
|
|
storageKey={storageKey}
|
|
panes={[
|
|
{ id: "list", label, node: list, size: listSize, sticky: true },
|
|
{ id: "detail", label: "Details", node: detail },
|
|
...(selected && renderExtended && extendedKey
|
|
? [
|
|
{
|
|
id: "extended",
|
|
label: "Extended details",
|
|
node: renderExtended(selected, extendedKey),
|
|
size: extendedSize,
|
|
sticky: true,
|
|
title: extendedTitle,
|
|
onClose: drill.closeExtended,
|
|
},
|
|
]
|
|
: []),
|
|
]}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export default MasterDetail
|