Files
go-otel/otel-ui/src/components/MasterDetail.tsx
T
2026-09-19 14:13:43 +02:00

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