This commit is contained in:
mats committed 2026-09-19 14:13:43 +02:00
1 parent 49f9bb3458
commit 7436966689
22 files changed
+989 -125

No files matched your search

+114 -66
View File
@@ -1,4 +1,13 @@
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react"
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[]
@@ -6,28 +15,33 @@ interface MasterDetailProps<T> {
selectedKey: string | null
onSelect: (key: string) => void
renderItem: (item: T, selected: boolean) => ReactNode
renderDetail: (item: T) => ReactNode
/** Accessible name for the list, e.g. "Occurrences". */
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 rendered above the list. */
/** Optional heading/controls rendered above the list. */
listHeader?: ReactNode
emptyDetail?: ReactNode
/**
* "split": list left, detail right (lists of cards). "stacked": full-width
* list on top (tables/overviews), detail below.
* "split": resizable list | detail [| extended] panes (default).
* "stacked": full-width list on top (tables), detail below.
*/
layout?: "split" | "stacked"
/** Split layout: Tailwind width class for the master column. */
listWidth?: string
/** Tailwind max-height class for the list (and, in split, the detail). */
maxHeight?: string
listSize?: PaneSize
extendedSize?: PaneSize
/** Persists pane widths per screen. */
storageKey?: string
}
/**
* Reusable master-detail layout: a selectable list on the left, the selected
* item's detail on the right (stacked on narrow screens). Arrow keys move the
* selection. Callers own selection state so it can be driven from elsewhere
* (e.g. a deep link or "focus this trace").
* 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,
@@ -36,78 +50,112 @@ export function MasterDetail<T>({
onSelect,
renderItem,
renderDetail,
renderExtended,
extendedTitle = "Details",
label,
listHeader,
emptyDetail,
layout = "split",
listWidth = "lg:w-[380px]",
maxHeight,
listSize = { default: 320, min: 240, max: 560 },
extendedSize = { default: 380, min: 280, max: 720 },
storageKey,
}: MasterDetailProps<T>) {
const stacked = layout === "stacked"
const maxH = maxHeight ?? (stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-260px)]")
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" })
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) onSelect(getKey(next))
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 (
<div className={`flex flex-col gap-4 ${stacked ? "" : "lg:flex-row lg:items-start"}`}>
<div className={`flex w-full shrink-0 flex-col gap-2 ${stacked ? "" : listWidth}`}>
{listHeader}
<div
ref={listRef}
role="listbox"
aria-label={label}
tabIndex={0}
onKeyDown={onKeyDown}
className={`flex max-h-[360px] 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 ${maxH}`}
>
{items.map((item) => {
const key = getKey(item)
const isSelected = key === selectedKey
return (
<div
key={key}
role="option"
aria-selected={isSelected}
onClick={() => onSelect(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>
<section
aria-live="polite"
className={`min-w-0 flex-1 ${stacked ? "" : `overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-5 ${maxH}`}`}
>
{selected ? (
renderDetail(selected)
) : (
<p className="py-10 text-center text-[13px] text-ds-text-3">
{emptyDetail ?? "Select an item to see its details."}
</p>
)}
</section>
</div>
<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,
},
]
: []),
]}
/>
)
}