materialized view and a bit better ui
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s

This commit is contained in:
mats committed 2026-09-19 12:43:17 +02:00
1 parent 26b677154b
commit 49f9bb3458
30 files changed
+2184 -106

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react"
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) => ReactNode
/** Accessible name for the list, e.g. "Occurrences". */
label: string
/** Optional heading 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.
*/
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
}
/**
* 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").
*/
export function MasterDetail<T>({
items,
getKey,
selectedKey,
onSelect,
renderItem,
renderDetail,
label,
listHeader,
emptyDetail,
layout = "split",
listWidth = "lg:w-[380px]",
maxHeight,
}: 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 selected = items.find((item) => getKey(item) === selectedKey) ?? null
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[aria-selected="true"]')
?.scrollIntoView({ block: "nearest" })
}, [selectedKey])
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))
}
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>
)
}
export default MasterDetail