LayoutManager
Compose saved workspace layouts, editing fields, and template actions.
import { useState } from "react"
import { WORKSPACE_PERSISTENCE_BOUNDARIES, type WorkspaceLayout } from "@/lib/workspace-layout"
import {
LayoutManager,
LayoutManagerItem,
LayoutManagerName,
LayoutManagerRenameField,
LayoutManagerActive,
LayoutManagerPanelCount,
LayoutManagerSavedAt,
LayoutManagerUnknownKinds,
LayoutManagerLoad,
LayoutManagerRename,
LayoutManagerDuplicate,
LayoutManagerDelete,
LayoutManagerSaveName,
LayoutManagerSave,
LayoutManagerTaken,
type LayoutTemplate,
} from "@/components/ui/layout-manager"
// A snapshot captured from a workspace with one Book panel.
const BOOK: WorkspaceLayout = {
version: 1,
kind: "tradecn-workspace",
dockview: {
grid: {
root: { type: "branch", data: [{ type: "leaf", data: { views: ["book-1"], activeView: "book-1", id: "1" }, size: 478 }], size: 115 },
width: 478, height: 115, orientation: "HORIZONTAL",
},
panels: { "book-1": { id: "book-1", contentComponent: "tradecn-panel", tabComponent: "props.defaultTabComponent", title: "Book" } },
activeGroup: "1",
},
panels: { "book-1": { kind: "book", title: "Book", state: { symbol: "ZN" } } },
boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES,
}
export default function LayoutManagerDemo() {
const [templates, setTemplates] = useState<LayoutTemplate[]>([{ id: "t-1", name: "Treasury book", layout: BOOK, savedAt: Date.parse("2026-09-22T14:00:00Z") }])
const [current, setCurrent] = useState(BOOK)
const [activeId, setActiveId] = useState<string | null>(null)
const [message, setMessage] = useState("Current snapshot: one ZN book.")
return (
<div className="w-[36rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<LayoutManager
templates={templates}
onTemplatesChange={(next) => {
const previous = templates.find((template) => template.id === activeId)
const active = next.find((template) => template.id === activeId)
const layout = JSON.stringify(active?.layout)
if (!active || (layout !== JSON.stringify(previous?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
setTemplates(next)
}}
current={current}
kinds={["book"]}
activeId={activeId}
onLoad={(layout, template) => {
setCurrent(layout)
setActiveId(template.id)
setMessage(`Selected ${template.name}.`)
}}
>
<div className="flex items-center gap-1"><LayoutManagerSaveName className="min-w-40 flex-1" /><LayoutManagerSave /></div>
<LayoutManagerTaken />
{templates.length === 0 ? <p className="text-muted-foreground">No saved layouts. Save the current one under a name.</p> : <ul className="divide-y divide-border rounded-md border border-border">
{templates.map((template) => <li key={template.id}>
<LayoutManagerItem templateId={template.id} className="px-2 py-2">
<LayoutManagerName /><LayoutManagerRenameField /><LayoutManagerActive />
<LayoutManagerPanelCount /><LayoutManagerSavedAt /><LayoutManagerUnknownKinds />
<div className="flex w-full flex-wrap items-center gap-1">
<LayoutManagerLoad /><LayoutManagerRename /><LayoutManagerDuplicate /><LayoutManagerDelete />
</div>
</LayoutManagerItem>
</li>)}
</ul>}
</LayoutManager>
<p role="status" className="text-muted-foreground">{message}</p>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/layout-manager#v2.0.0
npx shadcn@latest add tradecn/ui/layout-manager#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/layout-manager#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/layout-manager#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge button inputnpx shadcn@latest add badge button inputyarn dlx shadcn@latest add badge button inputbunx --bun shadcn@latest add badge button input -
Copy the files into your project:
components/ui/ layout-manager.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type Dispatch, type ReactNode, type Ref, type SetStateAction } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { readSlot, setSlot, type Preferences } from "@/lib/preferences" import { parseWorkspaceLayout, unknownPanelKinds, type WorkspaceLayout } from "@/lib/workspace-layout" export interface LayoutTemplate { id: string name: string layout: WorkspaceLayout /** When it was saved, ms since the epoch. */ savedAt: number } export const LAYOUT_TEMPLATES_SLOT = "layouts" export const LAYOUT_TEMPLATES_VERSION = 1 export interface LayoutManagerLabels { title: string /** The save field's placeholder and name. */ saveName: string /** The import form's name field. */ importName: string save: string /** Said under the field when a name is taken. `{name}`. */ taken: string load: string loadAnyway: string rename: string duplicate: string /** The name of a copy. `{name}`. */ copyOf: string delete: string deleteAnyway: string export: string import: string /** The import field's placeholder. */ pasteLayout: string add: string /** Said when the pasted text is not a layout. */ notALayout: string reset: string /** `{n}` panels. */ panels: string /** `{kinds}` the kinds a layout asks for that the workspace does not have. */ unknownKinds: string empty: string /** Said of the loaded template. */ active: string } export const DEFAULT_LAYOUT_MANAGER_LABELS: LayoutManagerLabels = { title: "Layouts", saveName: "Layout name", importName: "Imported layout name", save: "Save current", taken: "A layout named {name} exists. Save replaces it.", load: "Load", loadAnyway: "Load anyway?", rename: "Rename", duplicate: "Duplicate", copyOf: "Copy of {name}", delete: "Delete", deleteAnyway: "Delete?", export: "Export", import: "Import", pasteLayout: "Paste a layout's JSON", add: "Add", notALayout: "That is not a workspace layout.", reset: "Reset to default", panels: "{n} panels", unknownKinds: "Needs {kinds}", empty: "No saved layouts. Save the current one under a name.", active: "loaded", } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? "") let clockFormat: Intl.DateTimeFormat | null = null const localTime = (ms: number) => (clockFormat ??= new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })).format(ms) /** `t-1`, then `t-2`: the lowest number not taken. */ function nextId(taken: Iterable<string>): string { const used = new Set(taken) let n = 1 while (used.has(`t-${n}`)) n += 1 return `t-${n}` } /** A stored list read back, taking nothing on trust: a template whose layout does not parse is dropped, and what comes back is a copy. */ export function parseLayoutTemplates(value: unknown): LayoutTemplate[] { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return [] } } const list = Array.isArray(raw) ? raw : typeof raw === "object" && raw !== null && Array.isArray((raw as { templates?: unknown }).templates) ? (raw as { templates: unknown[] }).templates : [] const out: LayoutTemplate[] = [] for (const entry of list) { if (typeof entry !== "object" || entry === null) continue const t = entry as Record<string, unknown> const layout = parseWorkspaceLayout(t.layout) if (!layout || typeof t.id !== "string" || !t.id || typeof t.name !== "string" || !t.name) continue // A stored timestamp beyond the Date range would throw in toISOString and the formatter // the moment it renders; such a value reads as never saved. const savedAt = typeof t.savedAt === "number" && Number.isFinite(t.savedAt) && Math.abs(t.savedAt) <= 8.64e15 ? t.savedAt : 0 out.push({ id: t.id, name: t.name, layout, savedAt }) } return out } /** The templates in a preferences envelope's slot, `layouts` by default. */ export function readLayoutTemplates(prefs: Preferences, slot: string = LAYOUT_TEMPLATES_SLOT): LayoutTemplate[] { return parseLayoutTemplates(readSlot(prefs, slot)) } /** The envelope with the templates written to the slot. */ export function writeLayoutTemplates(prefs: Preferences, templates: readonly LayoutTemplate[], slot: string = LAYOUT_TEMPLATES_SLOT): Preferences { return setSlot(prefs, slot, { version: LAYOUT_TEMPLATES_VERSION, templates }, LAYOUT_TEMPLATES_VERSION) } /** The list with `layout` saved under `name`: a new template, or the one already named that replaced. */ export function saveTemplate(templates: readonly LayoutTemplate[], name: string, layout: WorkspaceLayout, savedAt: number): LayoutTemplate[] { const trimmed = name.trim() const existing = templates.find((t) => t.name === trimmed) if (existing) return templates.map((t) => (t === existing ? { ...t, layout, savedAt } : t)) return [...templates, { id: nextId(templates.map((t) => t.id)), name: trimmed, layout, savedAt }] } export function renameTemplate(templates: readonly LayoutTemplate[], id: string, name: string): LayoutTemplate[] { const trimmed = name.trim() if (!trimmed) return [...templates] return templates.map((t) => (t.id === id ? { ...t, name: trimmed } : t)) } /** A copy beside the original, named `Copy of <name>` unless a name is given. */ export function duplicateTemplate(templates: readonly LayoutTemplate[], id: string, savedAt: number, name?: string, labels: LayoutManagerLabels = DEFAULT_LAYOUT_MANAGER_LABELS): LayoutTemplate[] { const i = templates.findIndex((t) => t.id === id) if (i < 0) return [...templates] const source = templates[i]! const copy: LayoutTemplate = { id: nextId(templates.map((t) => t.id)), name: name?.trim() || fill(labels.copyOf, { name: source.name }), layout: parseWorkspaceLayout(source.layout) ?? source.layout, savedAt } return [...templates.slice(0, i + 1), copy, ...templates.slice(i + 1)] } export function deleteTemplate(templates: readonly LayoutTemplate[], id: string): LayoutTemplate[] { return templates.filter((t) => t.id !== id) } /** The layout as JSON text, for a file or the clipboard. */ export function exportTemplate(template: LayoutTemplate, indent = 2): string { return JSON.stringify(template.layout, null, indent) } /** The list with a pasted layout added under `name`, or null when the text is not a layout. */ export function importTemplate(templates: readonly LayoutTemplate[], text: string, name: string, savedAt: number): LayoutTemplate[] | null { const layout = parseWorkspaceLayout(text) if (!layout) return null return saveTemplate(templates, name, layout, savedAt) } export interface LayoutManagerProps extends Omit<ComponentProps<"section">, "onLoad" | "onReset"> { children: ReactNode templates: readonly LayoutTemplate[] onTemplatesChange: (templates: LayoutTemplate[]) => void current?: WorkspaceLayout | null onLoad: (layout: WorkspaceLayout, template: LayoutTemplate) => void kinds?: Iterable<string> activeId?: string | null now?: () => number labels?: Partial<LayoutManagerLabels> } export interface LayoutManagerState { templates: readonly LayoutTemplate[] labels: LayoutManagerLabels saveName: string setSaveName: (name: string) => void canSave: boolean nameTaken: boolean takenId: string save: () => void importing: boolean setImporting: (open: boolean, trigger?: HTMLElement) => void importText: string setImportText: (text: string) => void importName: string setImportName: (name: string) => void importProblem: string | null importProblemId: string importContentId: string add: () => void } type Confirmation = { id: string; layout: string; action: "load" | "delete" } interface ManagerContextValue extends LayoutManagerState { known: string[] activeId: string | null asking: Confirmation | null setAsking: (asking: Confirmation | null) => void editingId: string | null setEditingId: Dispatch<SetStateAction<string | null>> change: (templates: LayoutTemplate[]) => void onLoad: LayoutManagerProps["onLoad"] clock: () => number } const ManagerContext = createContext<ManagerContextValue | null>(null) const FocusContext = createContext<(() => void) | null>(null) function useManagerContext() { const value = useContext(ManagerContext) if (!value) throw new Error("Layout manager parts must be inside LayoutManager") return value } export function useLayoutManager(): LayoutManagerState { return useManagerContext() } function assignRef<T>(ref: Ref<T> | undefined, value: T | null) { if (typeof ref === "function") return ref(value) if (ref) ref.current = value } function useManagerRef<T>(localRef: { current: T | null }, forwarded: Ref<T> | undefined) { return useCallback((node: T | null) => { localRef.current = node const cleanup = assignRef(forwarded, node) return () => { localRef.current = null if (typeof cleanup === "function") cleanup() else assignRef(forwarded, null) } }, [localRef, forwarded]) } export function LayoutManager({ templates, onTemplatesChange, current = null, onLoad, kinds, activeId = null, now = Date.now, labels: labelsProp, children, className, ref, ...props }: LayoutManagerProps) { const labels = { ...DEFAULT_LAYOUT_MANAGER_LABELS, ...labelsProp } const [saveName, setSaveName] = useState("") const [asking, setAsking] = useState<Confirmation | null>(null) const [editingId, setEditingId] = useState<string | null>(null) const [importing, setImportOpen] = useState(false) const [importText, setText] = useState("") const [importName, setImportName] = useState("") const [invalidImport, setInvalidImport] = useState(false) const takenId = useId() const importProblemId = useId() const importContentId = useId() const root = useRef<HTMLElement>(null) const rootRef = useManagerRef(root, ref) const importOrigin = useRef<HTMLElement | null>(null) const wasImporting = useRef(false) const focusFallback = useCallback(() => { const node = root.current if (!node?.isConnected) return const target = node.querySelector<HTMLElement>("[data-layout-save-name]:not(:disabled)") ?? node target.focus({ preventScroll: true }) }, []) // Preferences readers may copy the list on every render. Compare the target's content, not its identity. const target = asking ? templates.find((template) => template.id === asking.id) : null const targetLayout = target?.layout const targetSource = useMemo(() => targetLayout ? JSON.stringify(targetLayout, null, 2) : null, [targetLayout]) const confirmation = target && asking?.layout === targetSource ? asking : null if (asking && !confirmation) setAsking(null) if (editingId && !templates.some((template) => template.id === editingId)) setEditingId(null) useLayoutEffect(() => { const node = root.current if (!node) return if (wasImporting.current && !importing) { const active = node.ownerDocument.activeElement if (active === node.ownerDocument.body || node.querySelector("[data-layout-import-content]")?.contains(active)) { const trigger = importOrigin.current if (trigger?.isConnected && !trigger.matches(":disabled, [aria-disabled=true]")) trigger.focus({ preventScroll: true }) else focusFallback() } } wasImporting.current = importing }, [importing, focusFallback]) const canSave = Boolean(current && saveName.trim()) const state: ManagerContextValue = { templates, labels, saveName, setSaveName, canSave, takenId, nameTaken: templates.some((template) => template.name === saveName.trim()), save: () => { if (!current || !saveName.trim()) return onTemplatesChange(saveTemplate(templates, saveName, current, now())) setSaveName("") }, importing, setImporting: (open, trigger) => { if (open) importOrigin.current = trigger ?? null setImportOpen(open) }, importText, importName, setImportName, importProblemId, importContentId, importProblem: invalidImport ? labels.notALayout : null, setImportText: (text) => { setText(text); setInvalidImport(false) }, add: () => { if (!importText.trim()) return const time = now() const next = importTemplate(templates, importText, importName.trim() || `Imported ${localTime(time)}`, time) if (!next) { setInvalidImport(true); return } onTemplatesChange(next) setText("") setImportName("") setInvalidImport(false) setImportOpen(false) }, known: [...(kinds ?? [])], activeId, asking: confirmation, setAsking, editingId, setEditingId, change: onTemplatesChange, onLoad, clock: now, } return <ManagerContext value={state}><FocusContext value={focusFallback}> <section tabIndex={-1} aria-label={props["aria-labelledby"] ? undefined : labels.title} data-slot="tradecn-layout-manager" className={cn("flex min-w-0 flex-col gap-2 text-xs lining-nums tabular-nums", className)} {...props} ref={rootRef}>{children}</section> </FocusContext></ManagerContext> } type InputProps = Omit<ComponentProps<typeof Input>, "value" | "defaultValue"> type ButtonProps = ComponentProps<typeof Button> type ReadingProps<T extends "span" | "p" | "time"> = Omit<ComponentProps<T>, "children"> export function LayoutManagerSaveName({ className, onChange, onKeyDown, "aria-describedby": describedBy, ...props }: InputProps) { const { labels, saveName, setSaveName, save, nameTaken, takenId } = useLayoutManager() return <Input aria-label={labels.saveName} placeholder={labels.saveName} autoComplete="off" spellCheck={false} data-layout-save-name="" aria-describedby={[describedBy, nameTaken ? takenId : null].filter(Boolean).join(" ") || undefined} className={cn("h-7 min-w-0 rounded-sm px-2 py-0 text-xs md:text-xs", className)} {...props} value={saveName} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setSaveName(event.target.value) }} onKeyDown={(event) => { onKeyDown?.(event) if (!event.defaultPrevented && event.key === "Enter" && !event.nativeEvent.isComposing) { event.preventDefault(); save() } }} /> } export function LayoutManagerSave({ className, children, disabled, onClick, ref, ...props }: ButtonProps) { const { labels, canSave, save } = useLayoutManager() const focusFallback = useContext(FocusContext)! const button = useRef<HTMLButtonElement>(null) const buttonRef = useManagerRef(button, ref) useLayoutEffect(() => { const node = button.current if ((disabled || !canSave) && node && node.ownerDocument.activeElement === node) focusFallback() }, [disabled, canSave, focusFallback]) return <Button type="button" variant="outline" size="sm" data-layout-save="" className={cn("h-7 px-2 text-xs", className)} {...props} ref={buttonRef} disabled={disabled || !canSave} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) save() }}>{children ?? labels.save}</Button> } export function LayoutManagerTaken({ className, ...props }: Omit<ReadingProps<"p">, "id">) { const { labels, nameTaken, saveName, takenId } = useLayoutManager() return nameTaken ? <p data-layout-taken="" className={cn("text-muted-foreground", className)} {...props} id={takenId}>{fill(labels.taken, { name: saveName.trim() })}</p> : null } export function LayoutManagerImportTrigger({ className, children, onClick, ...props }: ButtonProps) { const { labels, importing, setImporting, importContentId } = useLayoutManager() return <Button type="button" variant="ghost" size="sm" data-layout-import="" className={cn("h-7 px-2 text-xs", className)} {...props} aria-expanded={importing} aria-controls={importing ? importContentId : undefined} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) setImporting(!importing, event.currentTarget) }}>{children ?? labels.import}</Button> } export function LayoutManagerImportContent({ className, children, ...props }: Omit<ComponentProps<"div">, "id"> & { children: ReactNode }) { const { importing, importContentId } = useLayoutManager() return importing ? <div data-layout-import-content="" className={cn("flex flex-col gap-2 rounded-md border border-border p-2", className)} {...props} id={importContentId}>{children}</div> : null } export function LayoutManagerImportText({ className, onChange, "aria-describedby": describedBy, ...props }: Omit<ComponentProps<"textarea">, "value" | "defaultValue">) { const { labels, importText, setImportText, importProblem, importProblemId } = useLayoutManager() return <textarea aria-label={labels.pasteLayout} placeholder={labels.pasteLayout} spellCheck={false} rows={4} className={cn("w-full rounded-sm border border-input bg-background px-2 py-1 font-(family-name:--tradecn-font-mono) text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40", className)} {...props} aria-invalid={importProblem ? true : undefined} aria-describedby={[describedBy, importProblem ? importProblemId : null].filter(Boolean).join(" ") || undefined} value={importText} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setImportText(event.target.value) }} /> } export function LayoutManagerImportName({ className, onChange, ...props }: InputProps) { const { labels, importName, setImportName } = useLayoutManager() return <Input aria-label={labels.importName} placeholder={labels.importName} autoComplete="off" spellCheck={false} className={cn("h-7 min-w-0 rounded-sm px-2 py-0 text-xs md:text-xs", className)} {...props} value={importName} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setImportName(event.target.value) }} /> } export function LayoutManagerImportSubmit({ className, children, disabled, onClick, ref, ...props }: ButtonProps) { const { labels, importText, add } = useLayoutManager() const focusFallback = useContext(FocusContext)! const button = useRef<HTMLButtonElement>(null) const buttonRef = useManagerRef(button, ref) const canAdd = Boolean(importText.trim()) useLayoutEffect(() => { const node = button.current if ((disabled || !canAdd) && node && node.ownerDocument.activeElement === node) focusFallback() }, [disabled, canAdd, focusFallback]) return <Button type="button" variant="outline" size="sm" data-layout-add="" className={cn("h-7 px-2 text-xs", className)} {...props} ref={buttonRef} disabled={disabled || !canAdd} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) add() }}>{children ?? labels.add}</Button> } export function LayoutManagerImportProblem({ className, ...props }: Omit<ReadingProps<"p">, "id">) { const { importProblem, importProblemId } = useLayoutManager() return importProblem ? <p role="alert" data-layout-import-problem="" className={cn("text-destructive", className)} {...props} id={importProblemId}>{importProblem}</p> : null } export interface LayoutManagerItemState { template: LayoutTemplate active: boolean panelCount: number missingKinds: readonly string[] asking: "load" | "delete" | null renaming: boolean name: string setName: (name: string) => void rename: (trigger?: HTMLElement) => void commitRename: (options?: { restoreFocus?: boolean }) => void cancelRename: () => void load: () => void duplicate: () => void remove: () => void } const ItemContext = createContext<LayoutManagerItemState | null>(null) function useItemContext() { const value = useContext(ItemContext) if (!value) throw new Error("Template readings and controls must be inside LayoutManagerItem") return value } export function useLayoutManagerItem(): LayoutManagerItemState { return useItemContext() } export interface LayoutManagerItemProps extends ComponentProps<"div"> { templateId: string children: ReactNode } export function LayoutManagerItem({ templateId, ...props }: LayoutManagerItemProps) { const { templates } = useLayoutManager() const template = templates.find((entry) => entry.id === templateId) return template ? <Item key={templateId} template={template} {...props} /> : null } function Item({ template, className, ref, onFocusCapture, onBlurCapture, ...props }: Omit<LayoutManagerItemProps, "templateId"> & { template: LayoutTemplate }) { const manager = useManagerContext() const focusFallback = useContext(FocusContext)! const root = useRef<HTMLDivElement>(null) const rootRef = useManagerRef(root, ref) const origin = useRef<HTMLElement | null>(null) const restoreFocus = useRef(true) const wasRenaming = useRef(false) const lastFocused = useRef<HTMLElement | null>(null) const committing = useRef(false) const [session, setSession] = useState<{ source: string; name: string } | null>(null) const editing = session !== null && manager.editingId === template.id const source = useMemo(() => editing ? JSON.stringify([template.name, template.layout]) : null, [editing, template.name, template.layout]) const current = editing && session?.source === source ? session : null if (session && !current) setSession(null) const renaming = current !== null const missingKinds = manager.known.length ? unknownPanelKinds(template.layout, manager.known) : [] const asking = manager.asking?.id === template.id ? manager.asking.action : null useLayoutEffect(() => { const node = root.current if (!node) return const active = node.ownerDocument.activeElement if (renaming && !wasRenaming.current) node.querySelector<HTMLElement>("[data-layout-rename-field]")?.focus() if (!renaming && wasRenaming.current && restoreFocus.current && (active === node.ownerDocument.body || node.contains(active))) { const target = origin.current if (target?.isConnected && !target.matches(":disabled, [aria-disabled=true]")) target.focus({ preventScroll: true }) else node.focus({ preventScroll: true }) } const focused = lastFocused.current if (focused && (!focused.isConnected || focused.matches(":disabled")) && (node.ownerDocument.activeElement === node.ownerDocument.body || node.ownerDocument.activeElement === focused)) node.focus({ preventScroll: true }) wasRenaming.current = renaming }) useLayoutEffect(() => { const node = root.current return () => { if (node?.contains(node.ownerDocument.activeElement)) focusFallback() } }, [focusFallback]) const cancelRename = () => { setSession(null) manager.setEditingId((id) => id === template.id ? null : id) } const commitRename = (options?: { restoreFocus?: boolean }) => { if (!current || committing.current) return committing.current = true restoreFocus.current = options?.restoreFocus ?? true // Blur can commit while activeElement is still body, before its destination receives focus. if (!restoreFocus.current) lastFocused.current = null manager.change(renameTemplate(manager.templates, template.id, current.name)) cancelRename() } const state: LayoutManagerItemState = { template, active: template.id === manager.activeId, panelCount: Object.keys(template.layout.panels).length, missingKinds, asking, renaming, name: current?.name ?? template.name, setName: (name) => { if (current) setSession({ ...current, name }) }, rename: (trigger) => { if (current) return origin.current = trigger ?? root.current restoreFocus.current = true committing.current = false manager.setEditingId(template.id) setSession({ source: JSON.stringify([template.name, template.layout]), name: template.name }) }, commitRename, cancelRename, load: () => { if (missingKinds.length && asking !== "load") { manager.setAsking({ id: template.id, layout: exportTemplate(template), action: "load" }); return } manager.setAsking(null) manager.onLoad(template.layout, template) }, duplicate: () => manager.change(duplicateTemplate(manager.templates, template.id, manager.clock(), undefined, manager.labels)), remove: () => { if (asking !== "delete") { manager.setAsking({ id: template.id, layout: exportTemplate(template), action: "delete" }); return } manager.setAsking(null) manager.change(deleteTemplate(manager.templates, template.id)) }, } return <ItemContext value={state}> <div role="group" tabIndex={-1} aria-label={props["aria-labelledby"] ? undefined : template.name} data-slot="tradecn-layout-manager-item" data-layout-template={template.id} data-active={template.id === manager.activeId || undefined} data-unknown-kinds={missingKinds.length || undefined} className={cn("flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1", className)} {...props} ref={rootRef} onFocusCapture={(event) => { lastFocused.current = event.target onFocusCapture?.(event) }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) lastFocused.current = null onBlurCapture?.(event) }} /> </ItemContext> } export function LayoutManagerName({ className, ...props }: ReadingProps<"span">) { const { template, renaming } = useLayoutManagerItem() return renaming ? null : <span data-layout-name="" className={cn("min-w-0 break-words font-medium", className)} {...props}>{template.name}</span> } export function LayoutManagerActive({ className, ...props }: Omit<ComponentProps<typeof Badge>, "children">) { const { active } = useLayoutManagerItem() const { labels } = useLayoutManager() return active ? <Badge variant="outline" data-layout-active="" className={cn("h-4 px-1 text-xs", className)} {...props}>{labels.active}</Badge> : null } export function LayoutManagerPanelCount({ className, ...props }: ReadingProps<"span">) { const { panelCount } = useLayoutManagerItem() const { labels } = useLayoutManager() return <span data-layout-panels={panelCount} className={cn("text-muted-foreground lining-nums tabular-nums", className)} {...props}>{fill(labels.panels, { n: String(panelCount) })}</span> } export function LayoutManagerSavedAt({ className, ...props }: Omit<ReadingProps<"time">, "dateTime">) { const { template } = useLayoutManagerItem() // The parser's check, applied to templates handed in directly too: a value // toISOString would throw on reads as never saved instead of unmounting the // manager. NaN and Infinity fail the comparison; an untyped caller's string fails // the typeof. const savedAt = typeof template.savedAt === "number" && Math.abs(template.savedAt) <= 8.64e15 ? template.savedAt : 0 return savedAt > 0 ? <time className={cn("text-muted-foreground lining-nums tabular-nums", className)} {...props} dateTime={new Date(savedAt).toISOString()}>{localTime(savedAt)}</time> : null } export function LayoutManagerUnknownKinds({ className, ...props }: Omit<ComponentProps<typeof Badge>, "children">) { const { missingKinds } = useLayoutManagerItem() const { labels } = useLayoutManager() return missingKinds.length ? <Badge variant="outline" data-layout-unknown={missingKinds.join(" ")} className={cn("h-auto whitespace-normal px-1 text-xs text-stale", className)} {...props}>{fill(labels.unknownKinds, { kinds: missingKinds.join(", ") })}</Badge> : null } export function LayoutManagerRenameField({ className, onChange, onKeyDown, onBlur, ...props }: InputProps) { const { template, renaming, name, setName, commitRename, cancelRename } = useItemContext() const { labels } = useLayoutManager() if (!renaming) return null return <Input aria-label={`${labels.rename}: ${template.name}`} autoComplete="off" spellCheck={false} data-layout-rename-field="" className={cn("h-6 w-40 max-w-full rounded-sm px-1.5 py-0 text-xs md:text-xs", className)} {...props} value={name} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setName(event.target.value) }} onKeyDown={(event) => { onKeyDown?.(event) if (event.defaultPrevented) return if (event.nativeEvent.isComposing) return if (event.key === "Enter" || event.key === "Escape") { event.stopPropagation() event.preventDefault() if (event.key === "Enter") commitRename() else cancelRename() } }} onBlur={(event) => { onBlur?.(event) if (!event.defaultPrevented) commitRename({ restoreFocus: false }) }} /> } export function LayoutManagerLoad({ className, children, onClick, ...props }: ButtonProps) { const { asking, load } = useLayoutManagerItem() const { labels } = useLayoutManager() return <Button type="button" variant={asking === "load" ? "default" : "outline"} size="sm" data-layout-load="" className={cn("h-6 px-2 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) load() }}>{children ?? (asking === "load" ? labels.loadAnyway : labels.load)}</Button> } export function LayoutManagerRename({ className, children, onClick, ...props }: ButtonProps) { const { template, rename } = useLayoutManagerItem() const { labels } = useLayoutManager() return <Button type="button" variant="ghost" size="sm" aria-label={`${labels.rename}: ${template.name}`} className={cn("h-6 px-2 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) rename(event.currentTarget) }}>{children ?? labels.rename}</Button> } export function LayoutManagerDuplicate({ className, children, onClick, ...props }: ButtonProps) { const { template, duplicate } = useLayoutManagerItem() const { labels } = useLayoutManager() return <Button type="button" variant="ghost" size="sm" aria-label={`${labels.duplicate}: ${template.name}`} className={cn("h-6 px-2 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) duplicate() }}>{children ?? labels.duplicate}</Button> } export function LayoutManagerDelete({ className, children, onClick, ...props }: ButtonProps) { const { template, asking, remove } = useLayoutManagerItem() const { labels } = useLayoutManager() const label = asking === "delete" ? labels.deleteAnyway : labels.delete return <Button type="button" variant={asking === "delete" ? "destructive" : "ghost"} size="sm" aria-label={`${label}: ${template.name}`} data-layout-delete={asking === "delete" ? "asking" : ""} className={cn("h-6 px-2 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) remove() }}>{children ?? label}</Button> }lib/workspace-layout.ts // What a workspace saves, and what it leaves out on purpose. // // A layout is the dock arrangement plus one small record per panel: its kind, its title, and the // JSON the panel asked to keep, such as a symbol, a link group, or a view setting. It is meant to be // reused: saved as a template, handed to a colleague, restored next quarter. That only works when // nothing tied to a session is in it, so the boundaries travel in the payload and say, as data, what // the writer put in and what it kept out. // // Storage is the consumer's. Nothing here touches localStorage. A stored layout comes back through // `parseWorkspaceLayout`, which takes nothing on trust. export const WORKSPACE_LAYOUT_KIND = "tradecn-workspace" export const WORKSPACE_LAYOUT_VERSION = 1 export const WORKSPACE_PERSISTENCE_BOUNDARIES = { /** In the layout. `onLayoutChange` hands over a fresh one when any of these changes. */ autosave: ["dock-arrangement", "floating-and-popout-positions", "panel-kinds", "panel-titles", "panel-state"], /** * Belongs to one workspace and is not in the layout. Store it beside the layout, keyed by panel id. Default ids are * reused once a panel closes, so give a panel that keeps such data an id of its own. */ workspaceScoped: ["grid-column-state", "selection", "scroll-position", "ticket-drafts"], /** Belongs to the person, whichever workspace is open. */ globalScoped: ["hotkey-remaps", "palette-recents", "theme", "instrument-conventions"], /** Stored by nobody. It comes from the server every time. */ excluded: ["market-data", "orders-and-status", "positions", "feed-health", "link-group-symbols"], } as const export type WorkspaceBoundary = keyof typeof WORKSPACE_PERSISTENCE_BOUNDARIES export type WorkspacePersistenceBoundaries = { readonly [K in WorkspaceBoundary]: readonly string[] } const BOUNDARY_KEYS = Object.keys(WORKSPACE_PERSISTENCE_BOUNDARIES) as WorkspaceBoundary[] export type WorkspaceJson = string | number | boolean | null | WorkspaceJson[] | { [key: string]: WorkspaceJson } /** What a panel keeps across a reload. Small and JSON: a symbol, a link group, a view setting. */ export type WorkspacePanelState = { [key: string]: WorkspaceJson } export interface WorkspacePanelRecord { /** Which of the workspace's `panels` draws it, and the hotkey scope `panel:<kind>`. */ kind: string title: string state: WorkspacePanelState } export interface WorkspaceLayout { version: typeof WORKSPACE_LAYOUT_VERSION kind: typeof WORKSPACE_LAYOUT_KIND /** The dock's own serialized form. Opaque here: read `panels` instead. */ dockview: WorkspaceDockLayout /** One record per panel id in `dockview`. */ panels: Record<string, WorkspacePanelRecord> /** What the writer included and what it kept out, at the time it wrote. */ boundaries: WorkspacePersistenceBoundaries } /** As much of the dock's format as this file checks. */ export interface WorkspaceDockLayout { grid: { root: unknown; [key: string]: unknown } panels: Record<string, unknown> [key: string]: unknown } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON would store it: functions and undefined are gone, and a top-level toJSON decides what is stored, as it does for JSON. When JSON cannot walk the whole value, what it can walk is kept key by key — a cycle or a throwing getter drops alone and never wipes the rest of the state — unless the value has a top-level toJSON at all, since its raw fields are what it chose to hide. */ export function toPanelState(value: unknown): WorkspacePanelState { if (!isObject(value)) return {} try { const text = JSON.stringify(value) // A top-level toJSON that returns undefined, a function, or a symbol stores nothing. if (text === undefined) return {} const stored: unknown = JSON.parse(text) return isObject(stored) ? (stored as WorkspacePanelState) : {} } catch { // A value with a toJSON, own or inherited, owns its serialization: when JSON // could not use it, store nothing rather than the fields it hides. Existence is // checked, not the value, so a stateful getter never gets a second, different say. try { if ("toJSON" in value) return {} } catch { return {} } } let keys: string[] try { keys = Object.keys(value) } catch { return {} } const pairs: Array<[string, WorkspaceJson]> = [] for (const key of keys) { try { // The read runs inside the guard: a throwing getter drops its own key only. const entry: unknown = (value as Record<string, unknown>)[key] // Serialized under its own key, so a nested toJSON receives the key JSON's // whole pass would give it; a value JSON omits leaves the wrapper empty. const stored = JSON.parse(JSON.stringify({ [key]: entry })) as Record<string, WorkspaceJson> if (!Object.prototype.hasOwnProperty.call(stored, key)) continue // Collected as pairs: assigning out[key] would hand a "__proto__" key to the // prototype setter instead of keeping it an ordinary own key, as JSON does. pairs.push([key, stored[key] as WorkspaceJson]) } catch { continue } } return Object.fromEntries(pairs) } function readBoundaries(value: unknown): WorkspacePersistenceBoundaries { if (!isObject(value)) return WORKSPACE_PERSISTENCE_BOUNDARIES const out = {} as Record<WorkspaceBoundary, readonly string[]> for (const key of BOUNDARY_KEYS) { const list = value[key] if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string")) return WORKSPACE_PERSISTENCE_BOUNDARIES out[key] = [...(list as string[])] } return out } /** * A stored layout, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn workspace whose every docked panel has a record saying what kind * it is. A record with no panel in the dock is dropped. What comes back is a copy. */ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.kind !== WORKSPACE_LAYOUT_KIND || raw.version !== WORKSPACE_LAYOUT_VERSION) return null const dock = raw.dockview if (!isObject(dock) || !isObject(dock.grid) || !("root" in dock.grid) || !isObject(dock.panels) || !isObject(raw.panels)) return null // The dock keys its panels by plain objects, so an id that names an Object.prototype member breaks it, and a record // under `__proto__` would set this object's prototype: such a layout is not one the workspace can show. if (Object.keys(dock.panels).some(isReservedPanelId)) return null const panels: Record<string, WorkspacePanelRecord> = {} for (const id of Object.keys(dock.panels)) { const record = raw.panels[id] if (!isObject(record) || typeof record.kind !== "string" || !record.kind) return null panels[id] = { kind: record.kind, title: typeof record.title === "string" && record.title ? record.title : record.kind, state: toPanelState(record.state) } } let dockview: WorkspaceDockLayout try { dockview = JSON.parse(JSON.stringify(dock)) as WorkspaceDockLayout } catch { return null } return { version: WORKSPACE_LAYOUT_VERSION, kind: WORKSPACE_LAYOUT_KIND, dockview, panels, boundaries: readBoundaries(raw.boundaries) } } /** The kinds a layout asks for that are not among `kinds`. The workspace draws a placeholder for each; this is for saying so up front. */ export function unknownPanelKinds(layout: WorkspaceLayout, kinds: Iterable<string>): string[] { const known = new Set(kinds) return [...new Set(Object.values(layout.panels).map((record) => record.kind))].filter((kind) => !known.has(kind)).sort() } /** Whether an id names an `Object.prototype` member (`__proto__`, `constructor`, `toString`, …), which no panel can carry: the dock keys its panels by plain objects. */ export function isReservedPanelId(id: string): boolean { return id in Object.prototype } /** `book-1`, then `book-2`: the lowest number not taken. */ export function nextPanelId(kind: string, taken: Iterable<string>): string { const used = new Set(taken) let n = 1 while (used.has(`${kind}-${n}`)) n += 1 return `${kind}-${n}` } export type WorkspacePanelStatePatch = { [key: string]: WorkspaceJson | undefined } export interface WorkspacePanelStore { /** The same object until this panel changes, so it is safe as a `useSyncExternalStore` snapshot. */ get(id: string): WorkspacePanelRecord | undefined /** Adds the panel, or replaces its record. */ set(id: string, record: WorkspacePanelRecord): void setTitle(id: string, title: string): void /** Merges a patch into the panel's state. A key set to undefined is removed. A patch that changes nothing tells nobody. */ setState(id: string, patch: WorkspacePanelStatePatch | ((state: WorkspacePanelState) => WorkspacePanelStatePatch)): void delete(id: string): void /** Every record at once, for a layout being loaded. */ replace(records: Record<string, WorkspacePanelRecord>): void ids(): string[] /** Copies, for writing out. */ snapshot(): Record<string, WorkspacePanelRecord> subscribe(cb: () => void): () => void } export function createWorkspacePanelStore(): WorkspacePanelStore { let records = new Map<string, WorkspacePanelRecord>() const listeners = new Set<() => void>() const emit = () => { for (const cb of [...listeners]) cb() } const clean = (record: WorkspacePanelRecord): WorkspacePanelRecord => ({ kind: record.kind, title: record.title || record.kind, state: toPanelState(record.state) }) return { get: (id) => records.get(id), set(id, record) { records.set(id, clean(record)) emit() }, setTitle(id, title) { const record = records.get(id) if (!record || !title || record.title === title) return records.set(id, { ...record, title }) emit() }, setState(id, patch) { const record = records.get(id) if (!record) return const next = toPanelState({ ...record.state, ...(typeof patch === "function" ? patch(record.state) : patch) }) if (JSON.stringify(next) === JSON.stringify(record.state)) return records.set(id, { ...record, state: next }) emit() }, delete(id) { if (records.delete(id)) emit() }, replace(next) { records = new Map(Object.entries(next).map(([id, record]) => [id, clean(record)])) emit() }, ids: () => [...records.keys()], snapshot: () => Object.fromEntries([...records].map(([id, record]) => [id, { ...record, state: toPanelState(record.state) }])), subscribe(cb) { listeners.add(cb) return () => { listeners.delete(cb) } }, } } export interface Debounced { (): void /** Run now if a run is waiting. */ flush(): void cancel(): void pending(): boolean } /** One call `ms` after the last of a burst: a sash drag reports a change on every pointer move. */ export function debounce(fn: () => void, ms: number): Debounced { let timer: ReturnType<typeof setTimeout> | null = null const run = () => { timer = null fn() } const debounced = (() => { if (timer) clearTimeout(timer) timer = setTimeout(run, ms) }) as Debounced debounced.flush = () => { if (!timer) return clearTimeout(timer) run() } debounced.cancel = () => { if (timer) clearTimeout(timer) timer = null } debounced.pending = () => timer !== null return debounced }lib/preferences.ts // The envelope everything a trader or a desk may change without a build travels in: a workspace // layout, a grid's column state, hotkey overrides, rules, a threshold. Each is a slot with a version // and a JSON value, and the envelope says in its payload which slots may leave as a desk template, // which belong to one person, and which never leave a session, in the language the workspace layout's // boundaries already use. The items keep their controlled props; the consumer wires a slot to a prop. // This is not a store and not a provider, and where the envelope lives (localStorage, a file in a // desktop shell, a server) is the consumer's. Nothing here touches storage, and a stored envelope comes // back through `parsePreferences`, which takes nothing on trust. export const PREFERENCES_MARK = "preferences" export const PREFERENCES_VERSION = 1 export type PreferencesJson = string | number | boolean | null | PreferencesJson[] | { [key: string]: PreferencesJson } /** A slot: the shape's version, as the item that owns the shape numbers it, and the value as JSON. */ export interface PreferenceSlot { version: number value: PreferencesJson } /** * Where a slot may go. `template`: shareable as a desk template, handed to a colleague, restored next * quarter. `user`: belongs to the person, whichever desk they sit at. `session`: never exported, and * an import skips slots the incoming envelope marks session — though an import can replace and * reclassify the receiving desk's own session slot, since boundaries are read from the envelope. */ export type PreferenceBoundary = "template" | "user" | "session" export type PreferenceBoundaries = { readonly [K in PreferenceBoundary]: readonly string[] } export const PREFERENCE_BOUNDARIES: readonly PreferenceBoundary[] = ["template", "user", "session"] /** A slot named under no boundary is the person's. */ export const DEFAULT_BOUNDARY: PreferenceBoundary = "user" export interface Preferences { tradecn: typeof PREFERENCES_MARK version: typeof PREFERENCES_VERSION slots: Record<string, PreferenceSlot> /** Which slots go where, by name, at the time the envelope was written. */ boundaries: PreferenceBoundaries } /** How a consumer brings an older slot up to the shape it reads today. */ export interface PreferenceMigrator { /** The version the consumer's code reads. */ version: number /** The value at `from`, returned at `version`; null drops the slot. Called only for a slot older than `version`. */ migrate: (value: PreferencesJson, from: number) => PreferencesJson | null } export type PreferenceMigrators = Record<string, PreferenceMigrator> function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON stores it: functions and undefined gone, a copy, or null when it cannot be stored. */ export function toJson(value: unknown): PreferencesJson | null { if (value === undefined) return null try { const stored: unknown = JSON.parse(JSON.stringify(value)) return stored === undefined ? null : (stored as PreferencesJson) } catch { return null } } function sameJson(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b) } function readBoundaries(value: unknown): PreferenceBoundaries { const out: Record<PreferenceBoundary, string[]> = { template: [], user: [], session: [] } if (!isObject(value)) return out for (const key of PREFERENCE_BOUNDARIES) { const list = value[key] if (Array.isArray(list)) out[key] = list.filter((entry): entry is string => typeof entry === "string") } return out } /** An empty envelope, with the boundaries you name. */ export function createPreferences(boundaries: Partial<{ [K in PreferenceBoundary]: readonly string[] }> = {}): Preferences { return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: {}, boundaries: { template: [...(boundaries.template ?? [])], user: [...(boundaries.user ?? [])], session: [...(boundaries.session ?? [])] }, } } /** * A stored envelope, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn preferences envelope. A slot that is not an object with a whole-number * version and a JSON value is dropped, the rest are kept, and what comes back is a copy. */ export function parsePreferences(value: unknown): Preferences | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.tradecn !== PREFERENCES_MARK || raw.version !== PREFERENCES_VERSION || !isObject(raw.slots)) return null const slots: [string, PreferenceSlot][] = [] for (const [name, slot] of Object.entries(raw.slots)) { if (!isObject(slot) || typeof slot.version !== "number" || !Number.isInteger(slot.version) || slot.version < 0 || !("value" in slot)) continue const stored = toJson(slot.value) if (stored === null && slot.value !== null) continue slots.push([name, { version: slot.version, value: stored }]) } // Defined rather than assigned, so a slot named `__proto__` is kept as a slot and doesn't set the object's prototype. return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: Object.fromEntries(slots), boundaries: readBoundaries(raw.boundaries) } } // A slot is read by its own key, so a name such as `constructor` or `toString` is a slot like any other and never // a member of Object.prototype. function slotNamed(prefs: Preferences, name: string): PreferenceSlot | undefined { return Object.prototype.hasOwnProperty.call(prefs.slots, name) ? prefs.slots[name] : undefined } /** The slot, or undefined. */ export function getSlot(prefs: Preferences, name: string): PreferenceSlot | undefined { return slotNamed(prefs, name) } /** * The envelope with the slot set. The value is stored as JSON at a version: the number given, or the version of * the migrator given, which is the one to pass when the value was read through `readSlot` with it; else the * slot's own, else 1. A value read through a migrator is at the migrator's version, and stored under the slot's * older one it would be migrated again on the next read. A value and version that change nothing hand back the * same envelope, so a subscriber or an autosave comparing by identity sees no change. */ export function setSlot(prefs: Preferences, name: string, value: unknown, version?: number | PreferenceMigrator): Preferences { const stored = toJson(value) if (stored === null && value !== null) return prefs const current = slotNamed(prefs, name) const at = typeof version === "object" && version !== null ? version.version : version const next: PreferenceSlot = { version: at ?? current?.version ?? 1, value: stored } if (current && current.version === next.version && sameJson(current.value, next.value)) return prefs return { ...prefs, slots: { ...prefs.slots, [name]: next } } } /** The envelope without the slot; the same envelope when there was none. */ export function removeSlot(prefs: Preferences, name: string): Preferences { if (!slotNamed(prefs, name)) return prefs const slots = { ...prefs.slots } delete slots[name] return { ...prefs, slots } } /** The boundary a slot is named under, `user` when it is under none. */ export function boundaryOf(prefs: Preferences, name: string): PreferenceBoundary { for (const key of PREFERENCE_BOUNDARIES) if (prefs.boundaries[key].includes(name)) return key return DEFAULT_BOUNDARY } /** The envelope with the slot named under one boundary and no other. */ export function withBoundary(prefs: Preferences, name: string, boundary: PreferenceBoundary): Preferences { if (boundaryOf(prefs, name) === boundary && prefs.boundaries[boundary].includes(name)) return prefs const boundaries = { template: [...prefs.boundaries.template], user: [...prefs.boundaries.user], session: [...prefs.boundaries.session] } for (const key of PREFERENCE_BOUNDARIES) boundaries[key] = boundaries[key].filter((entry) => entry !== name) boundaries[boundary].push(name) return { ...prefs, boundaries } } export interface PreferencesDiff { /** In `b` and not in `a`. */ added: string[] /** In `a` and not in `b`. */ removed: string[] /** In both with a different version or value. */ changed: string[] /** In both and the same. */ same: string[] } /** Which slots differ between two envelopes, by name, for a support view or a save prompt. */ export function diffPreferences(a: Preferences, b: Preferences): PreferencesDiff { const out: PreferencesDiff = { added: [], removed: [], changed: [], same: [] } const names = new Set([...Object.keys(a.slots), ...Object.keys(b.slots)]) for (const name of [...names].sort()) { const x = slotNamed(a, name) const y = slotNamed(b, name) if (x && !y) out.removed.push(name) else if (!x && y) out.added.push(name) else if (x && y) (x.version === y.version && sameJson(x.value, y.value) ? out.same : out.changed).push(name) } return out } /** The slots allowed out under a boundary: a template carries `template` slots only; the person's export carries `template` and `user`; nothing carries `session`. */ export function exportableSlots(prefs: Preferences, boundary: "template" | "user" = "user"): string[] { return Object.keys(prefs.slots).filter((name) => { const where = boundaryOf(prefs, name) return where === "template" || (boundary === "user" && where === "user") }) } /** * The envelope as JSON text, for a file or a clipboard. `template` keeps the slots shareable as a desk * template; `user` (the default) keeps those and the person's own. A `session` slot never goes out. * The boundaries travel with it, so the reader knows what it holds. */ export function exportPreferences(prefs: Preferences, options: { boundary?: "template" | "user"; indent?: number } = {}): string { const keep = new Set(exportableSlots(prefs, options.boundary ?? "user")) const slots = Object.fromEntries(Object.entries(prefs.slots).filter(([name]) => keep.has(name))) const out: Preferences = { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots, boundaries: prefs.boundaries } return JSON.stringify(out, null, options.indent ?? 2) } /** * Another envelope's slots merged into this one: the incoming slot wins where both have one, under the * boundary allowed in (`user` by default, `template` for a desk template), and never a `session` slot. * The boundaries the incoming envelope names for the slots it brings are taken; the rest stay. Null when * the text is not an envelope. */ export function importPreferences(target: Preferences, incoming: unknown, options: { boundary?: "template" | "user" } = {}): Preferences | null { const parsed = parsePreferences(incoming) if (!parsed) return null const allowed = new Set(exportableSlots(parsed, options.boundary ?? "user")) let out = target for (const [name, slot] of Object.entries(parsed.slots)) { if (!allowed.has(name)) continue out = setSlot(out, name, slot.value, slot.version) out = withBoundary(out, name, boundaryOf(parsed, name)) } return out } /** * Every slot brought up to the version its migrator reads: a slot older than `migrator.version` goes * through `migrate`, one it returns null for is dropped, a slot with no migrator or already current is * left alone, and a slot newer than the migrator's version is left alone too, since a newer build wrote * it. The same envelope comes back when nothing moved. */ export function migratePreferences(prefs: Preferences, migrators: PreferenceMigrators): Preferences { let out = prefs for (const [name, migrator] of Object.entries(migrators)) { const slot = slotNamed(prefs, name) if (!slot || slot.version >= migrator.version) continue const value = migrator.migrate(slot.value, slot.version) out = value === null ? removeSlot(out, name) : setSlot(out, name, value, migrator.version) } return out } /** * One slot's value at the version the consumer reads, through its migrator when it is older, or * undefined when the slot is missing, newer than the migrator reads, or dropped by the migration. The * envelope itself is not changed; call `migratePreferences` to write the result back. */ export function readSlot<V = PreferencesJson>(prefs: Preferences, name: string, migrator?: PreferenceMigrator): V | undefined { const slot = slotNamed(prefs, name) if (!slot) return undefined if (!migrator) return slot.value as V if (slot.version > migrator.version) return undefined if (slot.version === migrator.version) return slot.value as V const value = migrator.migrate(slot.value, slot.version) return value === null ? undefined : (value as V) } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import { WORKSPACE_PERSISTENCE_BOUNDARIES, type WorkspaceLayout } from "@/lib/workspace-layout"
import {
LayoutManager,
LayoutManagerItem,
LayoutManagerName,
LayoutManagerRenameField,
LayoutManagerActive,
LayoutManagerPanelCount,
LayoutManagerSavedAt,
LayoutManagerUnknownKinds,
LayoutManagerLoad,
LayoutManagerRename,
LayoutManagerDuplicate,
LayoutManagerDelete,
LayoutManagerSaveName,
LayoutManagerSave,
LayoutManagerTaken,
type LayoutTemplate,
} from "@/components/ui/layout-manager"
// A snapshot captured from a workspace with one Book panel.
const BOOK: WorkspaceLayout = {
version: 1,
kind: "tradecn-workspace",
dockview: {
grid: {
root: { type: "branch", data: [{ type: "leaf", data: { views: ["book-1"], activeView: "book-1", id: "1" }, size: 478 }], size: 115 },
width: 478, height: 115, orientation: "HORIZONTAL",
},
panels: { "book-1": { id: "book-1", contentComponent: "tradecn-panel", tabComponent: "props.defaultTabComponent", title: "Book" } },
activeGroup: "1",
},
panels: { "book-1": { kind: "book", title: "Book", state: { symbol: "ZN" } } },
boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES,
}
export default function LayoutManagerDemo() {
const [templates, setTemplates] = useState<LayoutTemplate[]>([{ id: "t-1", name: "Treasury book", layout: BOOK, savedAt: Date.parse("2026-09-22T14:00:00Z") }])
const [current, setCurrent] = useState(BOOK)
const [activeId, setActiveId] = useState<string | null>(null)
const [message, setMessage] = useState("Current snapshot: one ZN book.")
return (
<div className="w-[36rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<LayoutManager
templates={templates}
onTemplatesChange={(next) => {
const previous = templates.find((template) => template.id === activeId)
const active = next.find((template) => template.id === activeId)
const layout = JSON.stringify(active?.layout)
if (!active || (layout !== JSON.stringify(previous?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
setTemplates(next)
}}
current={current}
kinds={["book"]}
activeId={activeId}
onLoad={(layout, template) => {
setCurrent(layout)
setActiveId(template.id)
setMessage(`Selected ${template.name}.`)
}}
>
<div className="flex items-center gap-1"><LayoutManagerSaveName className="min-w-40 flex-1" /><LayoutManagerSave /></div>
<LayoutManagerTaken />
{templates.length === 0 ? <p className="text-muted-foreground">No saved layouts. Save the current one under a name.</p> : <ul className="divide-y divide-border rounded-md border border-border">
{templates.map((template) => <li key={template.id}>
<LayoutManagerItem templateId={template.id} className="px-2 py-2">
<LayoutManagerName /><LayoutManagerRenameField /><LayoutManagerActive />
<LayoutManagerPanelCount /><LayoutManagerSavedAt /><LayoutManagerUnknownKinds />
<div className="flex w-full flex-wrap items-center gap-1">
<LayoutManagerLoad /><LayoutManagerRename /><LayoutManagerDuplicate /><LayoutManagerDelete />
</div>
</LayoutManagerItem>
</li>)}
</ul>}
</LayoutManager>
<p role="status" className="text-muted-foreground">{message}</p>
</div>
)
}
Composition
Use the following composition to build a LayoutManager:
LayoutManager
├── LayoutManagerSaveName
├── LayoutManagerSave
├── LayoutManagerTaken
├── LayoutManagerImportTrigger
├── LayoutManagerImportContent
│ ├── LayoutManagerImportText
│ ├── LayoutManagerImportName
│ ├── LayoutManagerImportSubmit
│ └── LayoutManagerImportProblem
└── LayoutManagerItem
├── LayoutManagerName
├── LayoutManagerRenameField
├── LayoutManagerActive
├── LayoutManagerPanelCount
├── LayoutManagerSavedAt
├── LayoutManagerUnknownKinds
├── LayoutManagerLoad
├── LayoutManagerRename
├── LayoutManagerDuplicate
└── LayoutManagerDelete
LayoutManagerItem renders a named group. Wrap each item in an li when using a list.
Choose the collection markup, order, empty state, and action placement in your own JSX. Add application content inside the root or an item, and use the hooks for custom readings and controls.
Cards
Move save and import fields into a sidebar and arrange templates as cards.
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { WORKSPACE_PERSISTENCE_BOUNDARIES, type WorkspaceLayout } from "@/lib/workspace-layout"
import { LayoutManager, LayoutManagerItem, LayoutManagerName, LayoutManagerRenameField, LayoutManagerActive, LayoutManagerPanelCount, LayoutManagerSavedAt, LayoutManagerUnknownKinds, LayoutManagerLoad, LayoutManagerRename, LayoutManagerDuplicate, LayoutManagerDelete, LayoutManagerSaveName, LayoutManagerSave, LayoutManagerTaken, LayoutManagerImportText, LayoutManagerImportName, LayoutManagerImportSubmit, LayoutManagerImportProblem, useLayoutManagerItem, exportTemplate, type LayoutTemplate } from "@/components/ui/layout-manager"
// A snapshot captured from a workspace with one Book panel.
const BOOK: WorkspaceLayout = {
version: 1,
kind: "tradecn-workspace",
dockview: {
grid: {
root: { type: "branch", data: [{ type: "leaf", data: { views: ["book-1"], activeView: "book-1", id: "1" }, size: 478 }], size: 115 },
width: 478, height: 115, orientation: "HORIZONTAL",
},
panels: { "book-1": { id: "book-1", contentComponent: "tradecn-panel", tabComponent: "props.defaultTabComponent", title: "Book" } },
activeGroup: "1",
},
panels: { "book-1": { kind: "book", title: "Book", state: { symbol: "ZN" } } },
boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES,
}
function ExportButton({ onExport }: { onExport: (text: string) => void }) {
const { template } = useLayoutManagerItem()
return <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-xs" aria-label={`Export: ${template.name}`} onClick={() => onExport(exportTemplate(template))}>Export</Button>
}
export default function LayoutManagerCardsDemo() {
const [templates, setTemplates] = useState<LayoutTemplate[]>([
{ id: "t-1", name: "Morning", layout: BOOK, savedAt: Date.parse("2026-09-22T14:00:00Z") },
{ id: "t-2", name: "Afternoon", layout: BOOK, savedAt: Date.parse("2026-09-22T19:00:00Z") },
])
const [current, setCurrent] = useState(BOOK)
const [activeId, setActiveId] = useState<string | null>(null)
const [message, setMessage] = useState("Choose a template.")
const [exported, setExported] = useState("")
return <div className="w-[48rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<LayoutManager className="grid items-start gap-4 sm:grid-cols-[14rem_1fr]" templates={templates} onTemplatesChange={(next) => {
const before = templates.find((template) => template.id === activeId)
const after = next.find((template) => template.id === activeId)
const layout = JSON.stringify(after?.layout)
if (!after || (layout !== JSON.stringify(before?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
setTemplates(next)
}} current={current} kinds={["book"]} activeId={activeId} onLoad={(layout, template) => { setCurrent(layout); setActiveId(template.id); setMessage(`Selected ${template.name}.`) }}>
<aside className="flex min-w-0 flex-col gap-3">
<h3 className="font-medium">Save a snapshot</h3>
<LayoutManagerSaveName /><LayoutManagerSave /><LayoutManagerTaken />
<h3 className="mt-2 font-medium">Import a layout</h3>
<LayoutManagerImportText /><LayoutManagerImportName aria-label="Imported layout name" /><LayoutManagerImportSubmit /><LayoutManagerImportProblem />
</aside>
<div className="flex min-w-0 flex-col gap-3">
<p className="text-muted-foreground">Saved templates in reverse list order.</p>
{templates.length === 0 ? <p>No saved layouts.</p> : <ul className="grid gap-3">
{templates.toReversed().map((template) => <li key={template.id}>
<LayoutManagerItem templateId={template.id} className="flex-col items-stretch gap-3 rounded-md border border-border p-3">
<header className="flex flex-wrap items-center gap-2"><LayoutManagerName /><LayoutManagerRenameField /><LayoutManagerActive /></header>
<p className="flex flex-wrap gap-x-3 gap-y-1"><LayoutManagerPanelCount /><LayoutManagerSavedAt /></p>
<LayoutManagerUnknownKinds />
<div className="flex flex-wrap gap-1"><LayoutManagerRename /><LayoutManagerDuplicate /><ExportButton onExport={setExported} /></div>
<footer className="flex flex-wrap justify-between gap-2"><LayoutManagerLoad /><LayoutManagerDelete /></footer>
</LayoutManagerItem>
</li>)}
</ul>}
</div>
</LayoutManager>
<p role="status" className="text-muted-foreground">{message}</p>
{exported && <label className="flex flex-col gap-2">Exported layout JSON<textarea readOnly value={exported} rows={4} className="w-full rounded border border-input bg-background p-2 font-(family-name:--tradecn-font-mono) text-xs" /></label>}
</div>
}
Saving and restoring a workspace
Install workspace to save and restore panel arrangements. This example persists templates in browser storage and reports storage failures.
Copy DeskTab from the Workspace Usage example into an application workspace.tsx beside this example, outside components/ui.
import { DeskTab } from "./workspace"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { createPreferences, parsePreferences, withBoundary } from "@/lib/preferences"
import type { WorkspaceLayout } from "@/lib/workspace-layout"
import { LayoutManager, readLayoutTemplates, writeLayoutTemplates, LayoutManagerActive, LayoutManagerDelete, LayoutManagerDuplicate, LayoutManagerImportContent, LayoutManagerImportName, LayoutManagerImportProblem, LayoutManagerImportSubmit, LayoutManagerImportText, LayoutManagerImportTrigger, LayoutManagerItem, LayoutManagerLoad, LayoutManagerName, LayoutManagerPanelCount, LayoutManagerRename, LayoutManagerRenameField, LayoutManagerSave, LayoutManagerSaveName, LayoutManagerSavedAt, LayoutManagerTaken, LayoutManagerUnknownKinds, exportTemplate, useLayoutManager, useLayoutManagerItem, type LayoutTemplate } from "@/components/ui/layout-manager"
import { PanelContent } from "@/components/ui/panel"
import { Workspace, useWorkspacePanel, type WorkspaceApi } from "@/components/ui/workspace"
const STORAGE_KEY = "tradecn-layout-manager-example"
function Book() {
const panel = useWorkspacePanel()
return (
<PanelContent className="p-3">
<label className="flex flex-col gap-2 text-xs">
Symbol for {panel.title}
<input className="min-w-0 rounded border border-input bg-background px-2 py-1" value={String(panel.state.symbol ?? "")} onChange={(event) => panel.setState({ symbol: event.target.value })} />
</label>
</PanelContent>
)
}
const PANELS = { book: Book }
function seed(api: WorkspaceApi) {
api.addPanel({ kind: "book", id: "book-1", title: "Treasuries", state: { symbol: "ZN" }, focus: false })
}
export default function LayoutManagerWorkspaceDemo() {
const [api, setApi] = useState<WorkspaceApi | null>(null)
const [current, setCurrent] = useState<WorkspaceLayout | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [exported, setExported] = useState("")
const [initial] = useState(() => {
const empty = createPreferences({ template: ["layouts"] })
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === null) return { prefs: empty, message: "Save a template to keep it in this browser." }
const prefs = parsePreferences(stored)
return prefs
? { prefs: withBoundary(prefs, "layouts", "template"), message: "Saved template list restored." }
: { prefs: empty, message: "Stored templates could not be read. Starting with an empty list." }
} catch {
return { prefs: empty, message: "Browser storage could not be read. Starting with an empty template list." }
}
})
const [prefs, setPrefs] = useState(initial.prefs)
const [message, setMessage] = useState(initial.message)
const templates = readLayoutTemplates(prefs)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={!api} onClick={() => {
if (!api) return
api.addPanel({ kind: "book", id: "equities", title: "Equities", state: { symbol: "ES" }, position: { direction: "right" }, focus: false })
setCurrent(api.toLayout())
}}>Add equities book</button>
</div>
<div className="w-[42rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<Workspace
tabComponent={DeskTab}
className="h-56 rounded-md border border-border"
panels={PANELS}
seed={seed}
onReady={(next) => { setApi(next); setCurrent(next.toLayout()) }}
onLayoutChange={setCurrent}
onLayoutError={() => setMessage("Could not capture or restore the workspace.")}
watermark="No panels. Reset to default to start again."
/>
<LayoutManager
templates={templates}
onTemplatesChange={(nextTemplates) => {
const previous = templates.find((template) => template.id === activeId)
const active = nextTemplates.find((template) => template.id === activeId)
const layout = JSON.stringify(active?.layout)
if (!active || (layout !== JSON.stringify(previous?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
const next = writeLayoutTemplates(prefs, nextTemplates)
setPrefs(next)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
setMessage("Template list saved in this browser.")
} catch {
setMessage("Template list changed, but browser storage is unavailable.")
}
}}
current={current}
kinds={Object.keys(PANELS)}
activeId={activeId}
onLoad={(layout, template) => {
if (!api) return
const loaded = api.load(layout)
setCurrent(api.toLayout())
setActiveId(loaded ? template.id : null)
setMessage(loaded ? `Loaded ${template.name}.` : `Could not load ${template.name}.`)
}}
>
<LayoutManagerControls
onReset={() => {
if (!api) return
api.clear()
seed(api)
setCurrent(api.toLayout())
setActiveId(null)
setMessage("Default workspace restored. Saved templates are unchanged.")
}}
onExport={(text, template) => { setExported(text); setMessage(`Exported ${template.name}.`) }}
/>
</LayoutManager>
<p role="status" className="text-muted-foreground">{message}</p>
{exported && <label className="flex flex-col gap-2">Exported layout JSON<textarea readOnly value={exported} rows={5} className="w-full rounded border border-input bg-background p-2 font-(family-name:--tradecn-font-mono) text-xs" /></label>}
</div>
</>
)
}
export function LayoutManagerControls({ onReset, onExport }: { onReset?: () => void; onExport?: (text: string, template: LayoutTemplate) => void }) {
const { templates, labels } = useLayoutManager()
return <>
<div className="flex flex-wrap items-center gap-1">
<LayoutManagerSaveName className="min-w-40 flex-1" />
<LayoutManagerSave />
{onReset && <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" data-layout-reset="" onClick={onReset}>{labels.reset}</Button>}
<LayoutManagerImportTrigger />
</div>
<LayoutManagerTaken />
<LayoutManagerImportContent>
<LayoutManagerImportText />
<div className="flex items-center gap-1"><LayoutManagerImportName className="flex-1" /><LayoutManagerImportSubmit /></div>
<LayoutManagerImportProblem />
</LayoutManagerImportContent>
{templates.length === 0 ? <p className="text-muted-foreground">{labels.empty}</p> : <ul className="flex flex-col divide-y divide-border rounded-md border border-border">
{templates.map((template) => <li key={template.id}>
<LayoutManagerItem templateId={template.id} className="px-2 py-1.5">
<LayoutManagerName /><LayoutManagerRenameField /><LayoutManagerActive />
<LayoutManagerPanelCount /><LayoutManagerSavedAt /><LayoutManagerUnknownKinds />
<span className="ms-auto flex flex-wrap items-center gap-0.5">
<LayoutManagerLoad /><LayoutManagerRename /><LayoutManagerDuplicate />
{onExport && <TemplateExport onExport={onExport} />}
<LayoutManagerDelete />
</span>
</LayoutManagerItem>
</li>)}
</ul>}
</>
}
function TemplateExport({ onExport }: { onExport: (text: string, template: LayoutTemplate) => void }) {
const { template } = useLayoutManagerItem()
const { labels } = useLayoutManager()
return <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-xs" aria-label={`${labels.export}: ${template.name}`} onClick={() => onExport(exportTemplate(template), template)}>{labels.export}</Button>
}
API Reference
Props
LayoutManager renders a section with tabIndex={-1} and accepts native props and refs, except the native onLoad and onReset events. Its onLoad requests a workspace load.
| Prop | Type | Default | Purpose |
|---|---|---|---|
children |
ReactNode |
Required | Fields, template items, and application content. |
templates |
readonly LayoutTemplate[] |
Required | Controlled template list. |
onTemplatesChange |
(templates: LayoutTemplate[]) => void |
Required | Receives the whole list after an edit. |
current |
WorkspaceLayout | null |
null |
Layout captured by Save current. |
onLoad |
(layout: WorkspaceLayout, template: LayoutTemplate) => void |
Required | Requests a workspace load. |
kinds |
Iterable<string> |
- | Nonempty known kinds enable missing-kind warnings and confirmation. Read on every render: pass an array or Set, not a one-shot iterator. |
activeId |
string | null |
null |
Marks a template as loaded. |
now |
() => number |
Date.now |
Clock in milliseconds since the epoch. |
labels |
Partial<LayoutManagerLabels> |
DEFAULT_LAYOUT_MANAGER_LABELS |
Label overrides shared with the parts and hooks. |
className |
string |
- | Additional classes to apply to the root. |
LayoutManagerItem
Provides template readings and actions. It renders a div with role="group" and tabIndex={-1}, and accepts native props and refs.
A missing template renders nothing.
| Prop | Type | Default | Purpose |
|---|---|---|---|
templateId |
string |
Required | Identifies a template in the root list. |
children |
ReactNode |
Required | Readings, controls, and application content. |
className |
string |
- | Additional classes to apply to the item. |
Fields and controls
Inputs accept the installed Input's props and refs, except value and defaultValue. LayoutManagerImportText accepts textarea props with the same exceptions.
Drafts belong to the root or item hook. Change, click, blur, and key handlers run first. preventDefault() cancels the built-in action.
| Part | Element | Behavior |
|---|---|---|
LayoutManagerSaveName |
Input | Edits the save name. Enter saves. Composing text does not submit. Pair with LayoutManagerTaken. |
LayoutManagerSave |
Button | Saves when current and a nonblank name are available. |
LayoutManagerImportTrigger |
Button | Toggles import content and exposes its expanded state. |
LayoutManagerImportContent |
div |
Requires children. Renders them while import is open. Its id is managed internally. |
LayoutManagerImportText |
textarea |
Edits JSON and links to the import error. Pair with LayoutManagerImportProblem. |
LayoutManagerImportName |
Input | Edits the optional import name, named by labels.importName. |
LayoutManagerImportSubmit |
Button | Parses nonblank JSON and adds or replaces a template. |
LayoutManagerRenameField |
Input | Renders while renaming. Enter or blur commits. Escape cancels. |
LayoutManagerLoad |
Button | Loads, with confirmation for unknown kinds. |
LayoutManagerRename |
Button | Starts renaming and focuses the rename field. |
LayoutManagerDuplicate |
Button | Inserts a copy after its source. |
LayoutManagerDelete |
Button | Asks on the first press and deletes on the second. |
Buttons accept the installed Button's props, refs, and optional children. Omitted children use the corresponding label.
Custom Load/Delete content must show the confirmation state from useLayoutManagerItem().asking. Caller disabled props remain effective.
Rename, Duplicate, and Delete default accessible names include the action and template name. Use aria-label or aria-labelledby to customize them.
Mount one save field and import editor per root. Keep a LayoutManagerRenameField or custom rename editor mounted in each item that offers Rename. The import fields also work without a trigger or content wrapper, as in the Cards example.
A custom import dialog owns its opening, closing, and focus behavior.
Readings
Readings accept native props, refs, and className. Their children are managed. LayoutManagerActive and LayoutManagerUnknownKinds accept the installed Badge's props.
Use the hooks to format your own readings.
| Part | Element | Content |
|---|---|---|
LayoutManagerName |
span |
Template name. Wraps long names and hides while renaming. |
LayoutManagerActive |
Badge | Loaded label when the item matches activeId. |
LayoutManagerPanelCount |
span |
Panel count using labels.panels. |
LayoutManagerSavedAt |
time |
Positive timestamp in the browser's locale and time zone. dateTime is managed. |
LayoutManagerUnknownKinds |
Badge | Sorted, deduplicated missing kinds with a visible Needs label. |
LayoutManagerTaken |
p |
Save replacement warning with a managed id. |
LayoutManagerImportProblem |
p |
Import error with role="alert" and a managed id. |
useLayoutManager()
Reads the nearest root. LayoutManagerState exposes these values and operations:
| Member | Type | Description |
|---|---|---|
templates, labels |
readonly LayoutTemplate[], LayoutManagerLabels |
Controlled list and resolved labels. |
saveName, setSaveName |
string, (name: string) => void |
Save draft. |
canSave, nameTaken |
boolean |
Save availability and replacement warning. |
takenId |
string |
Warning id for a custom field or reading. |
save |
() => void |
Saves and clears the name. |
importing, setImporting |
boolean, (open: boolean, trigger?: HTMLElement) => void |
Import visibility and optional focus return target. |
importText, setImportText |
string, (text: string) => void |
JSON draft. Editing clears the error. |
importName, setImportName |
string, (name: string) => void |
Optional name draft. |
importProblem |
string | null |
Localized validation error. |
importProblemId, importContentId |
string |
Error and content ids for custom composition. |
add |
() => void |
Imports, clears drafts, and closes built-in import content on success. |
useLayoutManagerItem()
Reads the nearest item. LayoutManagerItemState exposes these values and operations:
| Member | Type | Description |
|---|---|---|
template |
LayoutTemplate |
Current template. |
active, panelCount |
boolean, number |
Loaded state and panel count. |
missingKinds |
readonly string[] |
Unknown panel kinds. |
asking |
"load" | "delete" | null |
Pending confirmation. |
renaming, name, setName |
boolean, string, (name: string) => void |
Rename state and draft. |
rename |
(trigger?: HTMLElement) => void |
Starts editing and records a focus return target. |
commitRename |
(options?: { restoreFocus?: boolean }) => void |
Commits editing. Focus restoration defaults to true. |
cancelRename |
() => void |
Cancels editing and restores focus. Calling this on blur can override the destination's focus. |
load, duplicate, remove |
() => void |
Performs the same actions as the public buttons, including confirmation. |
Hooks and coordinated parts throw outside their required root or item. They add no timers, persistence, subscriptions, or permission checks.
Custom controls own their disabled conditions.
For a custom rename input, add data-layout-rename-field="" so rename() can focus it. Preserve IME composition and let keys other than Enter and Escape reach application handlers. Pass { restoreFocus: false } on blur to keep its destination.
import { useLayoutManagerItem } from "@/components/ui/layout-manager"
function CustomName() {
const item = useLayoutManagerItem()
if (!item.renaming) return <span>{item.name}</span>
return (
<input
data-layout-rename-field=""
aria-label="Template name"
value={item.name}
onChange={(event) => item.setName(event.target.value)}
onBlur={() => item.commitRename({ restoreFocus: false })}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return
if (event.key === "Enter" || event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
if (event.key === "Enter") item.commitRename()
else item.cancelRename()
}
}}
/>
)
}
Template fields
All LayoutTemplate fields are required. Keep ids unique when supplying your own list.
| Field | Type | Purpose |
|---|---|---|
id |
string |
Row identity for actions and activeId. Generated ids use the lowest available t-1, t-2, and so on. |
name |
string |
Display name and save/import replacement key. |
layout |
WorkspaceLayout |
Workspace arrangement and panel records. |
savedAt |
number |
Milliseconds since the epoch, within the Date range. LayoutManagerSavedAt displays positive values in the browser's locale and time zone; a stored value that is missing, nonnumeric, non-finite, or beyond the range reads as never saved, and so does a template passed in directly with a value beyond the range. |
The list is yours
workspace emits layouts through onLayoutChange and stores nothing. Accept each new list from onTemplatesChange into templates. The manager keeps only transient form and confirmation state.
readLayoutTemplates and writeLayoutTemplates use a preferences slot named layouts by default. Writing returns an envelope with slot version 1 and value { version: 1, templates }. It neither persists the envelope nor assigns a boundary.
Put the slot under template with createPreferences or withBoundary to include it in desk-template exports. An unclassified slot defaults to user.
Preferences exports select whole slots. A layout's boundaries describe its contents without removing fields from a layout export.
Save, load, and the default
Save current and Enter in the save field capture current under the trimmed name. Both require a layout and a nonblank name.
Save clears the field after emitting the list.
Names match exactly, including case. A matching name shows a replacement warning. Saving replaces the first matching template's layout and timestamp while keeping its id and position.
A new name appends a template. Save stores the supplied layout reference, so treat layouts as immutable.
Load calls onLoad(layout, template). Call the workspace's api.load there. The manager does not update activeId or verify that loading succeeded.
Clear activeId when its saved layout changes to a snapshot other than current. Renaming and saving the current snapshot can retain the loaded marker. Compose Reset to default as a caller-owned button that clears and reseeds the workspace.
Refresh current from api.toLayout() on ready, after loading, and after reset so Save captures those changes immediately. The workspace does not emit onLayoutChange merely because a layout was restored.
Ordinary workspace edits arrive through its debounced onLayoutChange.
A kind the workspace does not have
Pass a nonempty kinds iterable to show a badge for unknown panel kinds, sorted and deduplicated by unknownPanelKinds. The first Load press becomes Load anyway?. The second calls onLoad.
The workspace draws placeholders for missing kinds. Omitting kinds or passing an empty iterable skips both the badge and confirmation.
Rename, duplicate, delete
Rename edits inline. Enter or blur commits a trimmed name. Escape cancels.
A blank name leaves the template unchanged. Renaming preserves the timestamp and allows a name already in use.
Changing the source name or layout externally cancels an open edit. Enter and Escape leave IME composition untouched.
Duplicate inserts a new id and timestamp immediately after the source, named Copy of <name>. It copies a valid layout through parseWorkspaceLayout. If parsing fails, the helper falls back to the source layout reference.
Duplicate names are allowed.
Delete becomes Delete? on the first press and removes the row on the second. Delete and missing-kind Load share one pending confirmation: another Delete or warning Load replaces it, and completing a load or delete clears it.
Other clicks, including Rename, Duplicate, and clicks outside the manager, do not cancel it. Removing the target or changing its layout cancels its confirmation. Equivalent copied layouts keep it.
Import and export
Import toggles fields for a layout's JSON and an optional name. Add requires nonblank JSON and parses it with parseWorkspaceLayout.
Invalid input shows an alert and leaves the list unchanged. Editing the JSON clears the alert. Success clears and closes the form.
Import uses the same name-replacement rule as Save, but has no taken-name warning or confirmation. A blank name becomes Imported <local date/time>. That generated name can also collide.
Import does not load the layout.
Use exportTemplate(template) in a caller-owned button to serialize the layout without its id, name, or timestamp. The caller handles the file, clipboard, or message.
Helpers
These helpers are exported from @/components/ui/layout-manager. Here templates means readonly LayoutTemplate[], prefs means Preferences, layout means WorkspaceLayout, and savedAt means a timestamp in milliseconds.
| Helper | Return type | Behavior |
|---|---|---|
parseLayoutTemplates(value: unknown) |
LayoutTemplate[] |
Reads an array or { templates: [...] }, including JSON text. Drops invalid entries and copies accepted layouts. |
readLayoutTemplates(prefs, slot = "layouts") |
LayoutTemplate[] |
Parses the slot's value. A missing slot gives []. |
writeLayoutTemplates(prefs, templates, slot = "layouts") |
Preferences |
Writes a JSON copy at LAYOUT_TEMPLATES_VERSION (1). The default slot is LAYOUT_TEMPLATES_SLOT. |
saveTemplate(templates, name: string, layout, savedAt: number) |
LayoutTemplate[] |
Trims the name, then replaces the first match or appends. Unlike the UI, accepts a blank name. |
renameTemplate(templates, id: string, name: string) |
LayoutTemplate[] |
Renames matching ids. Blank names leave entries unchanged. |
duplicateTemplate(templates, id: string, savedAt: number, name?: string, labels = DEFAULT_LAYOUT_MANAGER_LABELS) |
LayoutTemplate[] |
Inserts a copy after the first matching id. A missing id leaves entries unchanged. A nonblank custom name overrides labels.copyOf. |
deleteTemplate(templates, id: string) |
LayoutTemplate[] |
Removes all matching ids. |
exportTemplate(template: LayoutTemplate, indent = 2) |
string |
Serializes template.layout with the given indentation. |
importTemplate(templates, text: string, name: string, savedAt: number) |
LayoutTemplate[] | null |
Parses the layout and calls saveTemplate. Returns null for invalid input. |
Parsing requires nonempty string ids/names and layouts accepted by parseWorkspaceLayout. Missing or nonnumeric savedAt becomes 0. Ids and names are not deduplicated or trimmed.
Neither the wrapper's version nor the preference slot's version is checked by these readers. Invalid JSON or an unsupported list shape returns [].
List-editing helpers return new arrays, retaining unchanged entries. Custom menus and hotkeys supply warnings and confirmation.
Labels
labels overrides the region title and public parts. The import form's name field has its own key, importName, defaulting to Imported layout name, so the two name fields are distinct by default; matching values you supply, or aria-label overrides, are yours. All v1 keys remain available through useLayoutManager().labels, including empty, export, and reset for caller-owned content.
taken and copyOf interpolate {name}, panels uses {n}, and unknownKinds uses {kinds}. The generated Imported prefix and date/time formatting are not label overrides.
The title names the outer region unless an accessible name is supplied through native props. Rows expose data-layout-template=<id> and active rows have data-active="true".
The import error uses role="alert".
Focus
The root and items use tabIndex={-1} as programmatic focus targets. They stay out of the Tab order unless you override it.
A focused Save or Add button that becomes disabled moves focus to the save field, falling back to the root when that field is absent.
The rename field receives focus when editing starts. Enter and Escape return focus to the initiating control, or the item if that control is gone or disabled.
Blur commits without moving focus from its destination. Removing a focused item moves focus to the save field, falling back to the root when that field is absent.
A successful import through LayoutManagerImportContent restores focus to its trigger when focus would otherwise be lost, falling back to the save field or root. If you replace the built-in field's blur behavior, your handler owns closing the edit and choosing its focus destination.
What it does not do
The manager never calls the workspace API or writes storage. Stored/imported layouts pass through parseWorkspaceLayout, while a Load click forwards the supplied template as-is.
The workspace's api.load parses again and returns false if parsing or dock restoration fails. Either failure leaves the workspace empty and calls onLayoutError, so recovery is the application's: check the result in onLoad and load a known-good layout when it is false. The workspace demo only reports the failure.
Tokens
Installation adds stale for the unknown-kinds badge when absent, with the mono font token, the accessible pair, and the hyperlegible remap.
Built on shadcn's badge, button, and input.
This page is docs/layout-manager.md at v2.0.0.