"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode, } from "react"; export type DockTab = { id: string; label: string; content: ReactNode }; export type DockLayout = { panes: [string[], string[]]; active: [string | null, string | null]; split: number; collapsed: 0 | 1 | null; }; function normalize( layout: DockLayout | undefined, tabs: DockTab[], ): DockLayout { const ids = tabs.map((tab) => tab.id), seen = new Set(); const keep = (items: string[]) => items.filter((id) => { if (!ids.includes(id) || seen.has(id)) return false; seen.add(id); return true; }); const middle = Math.ceil(ids.length / 2); const panes: DockLayout["panes"] = layout ? [keep(layout.panes[0]), keep(layout.panes[1])] : [keep(ids.slice(0, middle)), keep(ids.slice(middle))]; panes[0].push(...ids.filter((id) => !seen.has(id))); return { panes, active: panes.map((items, pane) => items.includes(layout?.active[pane] ?? "") ? layout!.active[pane] : (items[0] ?? null), ) as DockLayout["active"], split: Math.max( 20, Math.min(80, Number.isFinite(layout?.split) ? layout!.split : 50), ), collapsed: layout?.collapsed === 0 || layout?.collapsed === 1 ? layout.collapsed : null, }; } export function Dock({ label, tabs, value, defaultValue, onValueChange, readOnly = false, className = "", }: { label: string; tabs: DockTab[]; value?: DockLayout; defaultValue?: DockLayout; onValueChange?: (layout: DockLayout) => void; /** Selection stays available; structural layout edits are disabled. */ readOnly?: boolean; className?: string; }) { const [internal, setInternal] = useState(() => normalize(defaultValue, tabs)); const id = useId(), grid = useRef(null); const triggers = useRef(new Map()); const pendingFocus = useRef(null); const available = tabs.filter( (tab, index) => tabs.findIndex((item) => item.id === tab.id) === index, ); const current = normalize(value ?? internal, available); const tabOf = (key: string | null) => available.find((tab) => tab.id === key); const tabId = (key: string) => `${id}-tab-${encodeURIComponent(key)}`; const panelId = (key: string) => `${id}-panel-${encodeURIComponent(key)}`; const paneName = (pane: number) => (pane === 0 ? "first" : "second"); const update = (next: DockLayout) => { const valid = normalize(next, available); if (value === undefined) setInternal(valid); onValueChange?.(valid); }; useEffect(() => { if (pendingFocus.current !== null) { triggers.current.get(pendingFocus.current)?.focus(); pendingFocus.current = null; } }); const select = (pane: 0 | 1, key: string) => { const active: DockLayout["active"] = [...current.active]; active[pane] = key; update({ ...current, active }); }; const reorder = (pane: 0 | 1, key: string, direction: number) => { if (readOnly) return; const items = [...current.panes[pane]], from = items.indexOf(key), to = from + direction; if (from < 0 || to < 0 || to >= items.length) return; [items[from], items[to]] = [items[to]!, items[from]!]; const panes: DockLayout["panes"] = [...current.panes]; panes[pane] = items; pendingFocus.current = key; update({ ...current, panes }); }; const move = (pane: 0 | 1, key: string) => { if (readOnly) return; const other = pane === 0 ? 1 : 0; const panes: DockLayout["panes"] = [ [...current.panes[0]], [...current.panes[1]], ]; const index = panes[pane].indexOf(key); if (index < 0) return; panes[pane].splice(index, 1); panes[other].push(key); const active: DockLayout["active"] = [...current.active]; active[pane] = panes[pane][Math.min(index, panes[pane].length - 1)] ?? null; active[other] = key; pendingFocus.current = key; update({ ...current, panes, active, collapsed: current.collapsed === other ? null : current.collapsed, }); }; const resize = (split: number) => { if (!readOnly) update({ ...current, split }); }; const style = { "--mizu-dock-first": `${current.split}fr`, "--mizu-dock-second": `${100 - current.split}fr`, } as CSSProperties; return (
{label} {current.collapsed !== null && ( )} {readOnly && Layout locked}
{([0, 1] as const).map((pane) => { const active = current.active[pane], selected = tabOf(active); const index = active === null ? -1 : current.panes[pane].indexOf(active); return ( ); })}
panelId(tab.id)).join(" ") || undefined } aria-valuemin={20} aria-valuemax={80} aria-valuenow={current.split} aria-disabled={readOnly} tabIndex={readOnly ? -1 : 0} className="mizu-dock-separator" hidden={current.collapsed !== null} onPointerDown={(event) => { if (readOnly || event.button !== 0) return; event.currentTarget.focus(); event.currentTarget.setPointerCapture(event.pointerId); }} onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; const bounds = grid.current!.getBoundingClientRect(); if (bounds.width > 24) resize( Math.round( ((event.clientX - bounds.left - 12) / (bounds.width - 24)) * 100, ), ); }} onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onKeyDown={(event) => { const next = event.key === "Home" ? 20 : event.key === "End" ? 80 : event.key === "ArrowLeft" ? current.split - (event.shiftKey ? 10 : 1) : event.key === "ArrowRight" ? current.split + (event.shiftKey ? 10 : 1) : null; if (next === null) return; event.preventDefault(); resize(next); }} >
{/* Stable siblings keep a panel's editor state when it moves between panes. */} {available.map((tab) => { const pane = current.panes[0].includes(tab.id) ? 0 : 1; return ( ); })} {([0, 1] as const).map( (pane) => current.panes[pane].length === 0 && ( ), )}

{([0, 1] as const) .map( (pane) => `${pane === 0 ? "First" : "Second"}: ${current.collapsed === pane ? "collapsed" : (tabOf(current.active[pane])?.label ?? "empty")}`, ) .join(" ยท ")}

On a tab: arrows select; Alt + arrows reorder; Alt + Shift + arrows move panes.

); }