mreo ui
This commit is contained in:
1 parent
49f9bb3458
commit
7436966689
22 files changed
+989
-125
No files matched your search
@@ -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,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user