Panel
Frame a book, chart, or blotter with a header and hotkey scope. Add symbol editing, link groups, and a popout as needed.
import { Panel, PanelContent, PanelHeader, PanelTitle } from "@/components/ui/panel"
export default function PanelDemo() {
return (
<Panel kind="orders" className="h-40 w-72 max-w-full">
<PanelHeader>
<PanelTitle>Orders</PanelTitle>
</PanelHeader>
<PanelContent className="p-3 text-sm">No open orders.</PanelContent>
</Panel>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/panel#v2.0.0
npx shadcn@latest add tradecn/ui/panel#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/panel#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/panel#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 inputnpx shadcn@latest add inputyarn dlx shadcn@latest add inputbunx --bun shadcn@latest add input -
Copy the files into your project:
components/ui/ panel.tsx import { cn } from "cn" import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode, type SyntheticEvent } from "react" import { createPortal } from "react-dom" import { Input } from "@/components/ui/input" import { HotkeyScope, useMaybeHotkeys } from "@/hooks/use-hotkeys" import type { Popout } from "@/hooks/use-popout" import { cycleLinkGroup, type LinkGroup, type LinkGroupId } from "@/lib/link-group" // The frame around one thing on a trading screen: a book, a chart, a blotter. // // A panel is a hotkey scope, so its keys come with it: `kind="book"` is the scope // `panel:book`, shared by every book on the screen, and the book with focus is the // one that answers a key. Palette actions registered with that scope are offered when // the palette opens from inside a book; with several books, register them with // `within: useHotkeyScope()` so the book that held focus is the one that runs. The header is a drag handle for whatever lays panels out, and the things in it that take // a click keep that click from starting a drag. interface PanelContextValue { titleId: string } const PanelContext = createContext<PanelContextValue | null>(null) // The layout around a panel listens for one of these on the header to start a drag. const keepFromDrag = { onPointerDown: stop, onMouseDown: stop, onTouchStart: stop } function stop(event: SyntheticEvent) { event.stopPropagation() } // The consumer's handler runs first, and cancelling it lets the press reach the header's drag. // Only a cancellation the handler itself adds opts out: a press an ancestor capture already // prevented still stays away from the drag. function guardFromDrag<E extends SyntheticEvent>(own?: (event: E) => void) { return (event: E) => { const alreadyPrevented = event.defaultPrevented own?.(event) if (alreadyPrevented || !event.defaultPrevented) event.stopPropagation() } } export type PanelState = "error" | "drag-target" | "active" | "inactive" | "auto" export interface PanelProps extends Omit<ComponentProps<"div">, "ref"> { /** The kind of panel, `book` or `chart`. It is the hotkey scope `panel:<kind>`, shared by every instance. */ kind: string /** Draw the active border. Left out, the panel is active while focus is inside it. */ active?: boolean /** Something is being dragged over this panel. */ dragTarget?: boolean /** Draw the error border. It outranks the other two. */ error?: boolean } export function Panel({ kind, active, dragTarget = false, error = false, className, children, ...props }: PanelProps) { const titleId = useId() const state: PanelState = error ? "error" : dragTarget ? "drag-target" : active === undefined ? "auto" : active ? "active" : "inactive" return ( <PanelContext.Provider value={{ titleId }}> <HotkeyScope role="region" aria-labelledby={props["aria-label"] ? undefined : titleId} {...props} scope={`panel:${kind}`} data-slot="tradecn-panel" data-kind={kind} data-state={state} className={cn( "flex min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-border bg-card text-card-foreground outline-none lining-nums tabular-nums", "data-[state=active]:border-panel-active data-[state=auto]:focus-within:border-panel-active", "data-[state=drag-target]:border-panel-drag-target data-[state=drag-target]:bg-panel-drag-target/10", "data-[state=error]:border-panel-error", className, )} > {children} </HotkeyScope> </PanelContext.Provider> ) } /** The strip along the top, and the handle a layout drags the panel by: aim it at `[data-panel-handle]`. */ export function PanelHeader({ className, ...props }: ComponentProps<"div">) { return <div data-panel-handle="" className={cn("flex h-7 shrink-0 items-center gap-1.5 border-b border-border bg-muted/40 px-2 text-xs select-none", className)} {...props} /> } /** Names the panel, to the eye and to a screen reader. */ export function PanelTitle({ className, ...props }: ComponentProps<"div">) { const panel = useContext(PanelContext) return <div id={panel?.titleId} className={cn("truncate font-medium", className)} {...props} /> } /** Your buttons, pushed to the far end of the header. A press on one does not start a drag unless your handler cancels. */ export function PanelActions({ className, onPointerDown, onMouseDown, onTouchStart, ...props }: ComponentProps<"div">) { return <div className={cn("ml-auto flex items-center gap-0.5", className)} {...props} onPointerDown={guardFromDrag(onPointerDown)} onMouseDown={guardFromDrag(onMouseDown)} onTouchStart={guardFromDrag(onTouchStart)} /> } export function PanelContent({ className, ...props }: ComponentProps<"div">) { return <div className={cn("min-h-0 flex-1 overflow-auto", className)} {...props} /> } export interface SymbolTagProps { value: string | null /** Enter on a draft that is not blank and not what is already showing. */ onCommit: (symbol: string) => void /** Trims and upper-cases by default. Pass your own for symbols where case matters. */ normalize?: (raw: string) => string /** False refuses the draft: the field stays open and marks itself invalid. */ validate?: (symbol: string) => boolean /** Shown while there is no symbol. */ placeholder?: string /** Controlled edit mode, for a hotkey that opens the field. */ editing?: boolean onEditingChange?: (editing: boolean) => void disabled?: boolean label?: string className?: string } const defaultNormalize = (raw: string) => raw.trim().toUpperCase() /** * The panel's symbol. Click it, or press Enter on it, to type another: Enter commits, Escape and * clicking away put it back. When the symbol changes and it was not typed here, the tag rings once * in `panel-sync`, which is how a panel says it followed its link group. */ export function SymbolTag({ value, onCommit, normalize = defaultNormalize, validate, placeholder = "symbol", editing: editingProp, onEditingChange, disabled = false, label = "Symbol", className }: SymbolTagProps) { const [ownEditing, setOwnEditing] = useState(false) const editing = (editingProp ?? ownEditing) && !disabled const [draft, setDraft] = useState("") const [invalid, setInvalid] = useState(false) const [wasEditing, setWasEditing] = useState(false) const root = useRef<HTMLSpanElement>(null) const input = useRef<HTMLInputElement>(null) const button = useRef<HTMLButtonElement>(null) // Set by the keyboard exits, so focus goes back to the tag and stays in the panel's scope. Clicking away keeps the click's focus. const refocus = useRef(false) // The field is open and has not been left yet. Some engines blur an input as it is removed, and that must not count as clicking away. const open = useRef(false) const typedHere = useRef(value) // Opened from outside (a hotkey): start from what is showing, same as a click. if (editing !== wasEditing) { setWasEditing(editing) if (editing) { setDraft(value ?? "") setInvalid(false) } } function setEditing(next: boolean) { if (editingProp === undefined) setOwnEditing(next) onEditingChange?.(next) } function leave(restoreFocus: boolean) { if (!open.current) return open.current = false refocus.current = restoreFocus setEditing(false) } function commit() { const next = normalize(draft) if (!next || next === value) return leave(true) if (validate && !validate(next)) return setInvalid(true) typedHere.current = next onCommit(next) leave(true) } function onKeyDown(event: KeyboardEvent<HTMLInputElement>) { if (event.key !== "Enter" && event.key !== "Escape") return // Claimed, so a hotkey dispatcher listening further out leaves it alone. event.preventDefault() event.stopPropagation() if (event.key === "Enter") commit() else leave(true) } useLayoutEffect(() => { if (editing) { open.current = true input.current?.focus() input.current?.select() } else if (refocus.current) { refocus.current = false button.current?.focus() } }, [editing]) useLayoutEffect(() => { if (typedHere.current === value) return typedHere.current = value const el = root.current if (!el || typeof el.animate !== "function") return if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return el.animate([{ boxShadow: "0 0 0 1px var(--panel-sync)" }, { boxShadow: "0 0 0 1px transparent" }], { duration: 900, easing: "ease-out" }) }, [value]) return ( <span ref={root} {...keepFromDrag} data-symbol-tag="" data-editing={editing} className={cn("inline-flex h-5 items-center rounded-sm font-(family-name:--tradecn-font-mono) text-xs", className)}> {editing ? ( <Input ref={input} value={draft} onChange={(event) => { setDraft(event.target.value) setInvalid(false) }} onKeyDown={onKeyDown} onBlur={() => leave(false)} aria-label={label} aria-invalid={invalid || undefined} spellCheck={false} autoComplete="off" autoCapitalize="characters" className="h-5 w-24 rounded-sm px-1 py-0 font-(family-name:--tradecn-font-mono) text-xs md:text-xs" /> ) : ( <button ref={button} type="button" disabled={disabled} aria-label={value ? `${label} ${value}, change` : `${label}, none, set`} onClick={() => setEditing(true)} className={cn("h-5 rounded-sm px-1 font-semibold outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none", !value && "font-normal text-muted-foreground")} > {value ?? placeholder} </button> )} </span> ) } const LINK_CLASS: Record<LinkGroupId, string> = { 1: "bg-link-1", 2: "bg-link-2", 3: "bg-link-3", 4: "bg-link-4", } export interface LinkGroupDotProps extends Omit<ComponentProps<"button">, "onChange" | "children"> { group: LinkGroup /** Click moves to the next group, Shift+click to the one before. */ onGroupChange: (group: LinkGroup) => void } /** Which link group the panel is in, as a color and a number, because color is never the only channel. */ export function LinkGroupDot({ group, onGroupChange, className, onClick, onPointerDown, onMouseDown, onTouchStart, ...props }: LinkGroupDotProps) { const name = group === null ? "Not linked" : `Link group ${group}` return ( <button type="button" onPointerDown={guardFromDrag(onPointerDown)} onMouseDown={guardFromDrag(onMouseDown)} onTouchStart={guardFromDrag(onTouchStart)} aria-label={`${name}, change`} title={name} data-link-group={group ?? "none"} className={cn( "inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-xs leading-none font-semibold text-black/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/50", group === null ? "border border-dashed border-muted-foreground/60" : LINK_CLASS[group], className, )} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) onGroupChange(cycleLinkGroup(group, event.shiftKey ? -1 : 1)) }} > {group} </button> ) } export interface PanelPopoutProps { /** From `usePopout()`. */ popout: Popout /** What the page shows in the panel's place while it is out. */ placeholder?: ReactNode children?: ReactNode } /** * Renders its children in the page or in the popout's window, wherever `popout` says, without * remounting them. Inside a `HotkeysProvider` the popout's document gets the same keys. */ export function PanelPopout({ popout, placeholder, children }: PanelPopoutProps) { const { slotRef, isOpen, host, window: popoutWindow } = popout const hotkeys = useMaybeHotkeys() const target = popoutWindow?.document useEffect(() => (hotkeys && target ? hotkeys.attach(target) : undefined), [hotkeys, target]) return ( <> <div ref={slotRef} className="contents" /> {isOpen ? placeholder : null} {host ? createPortal(children, host) : null} </> ) }hooks/use-link-group.tsx import { createContext, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react" import { createBroadcastChannelTransport, createLinkGroupStore, cycleLinkGroup, normalizeSymbol, type LinkGroup, type LinkGroupStore, type LinkTransport } from "@/lib/link-group" // React bindings for the link-group store: a provider that owns one and keeps it connected, and a // hook that gives a panel its group and its symbol. const StoreContext = createContext<LinkGroupStore | null>(null) export interface LinkGroupProviderProps { /** Bring your own, to seed it or to share one between roots. One is created otherwise, and `transport` and `channel` are ignored. */ store?: LinkGroupStore /** How a link reaches other windows. BroadcastChannel by default; `null` keeps links inside this window. Read once. */ transport?: LinkTransport | null /** The BroadcastChannel name for the default transport. Read once. */ channel?: string children?: ReactNode } export function LinkGroupProvider({ store, transport, channel, children }: LinkGroupProviderProps) { const [own] = useState(() => createLinkGroupStore({ transport: transport === undefined ? createBroadcastChannelTransport(channel) : transport })) const value = store ?? own useEffect(() => value.connect(), [value]) return <StoreContext.Provider value={value}>{children}</StoreContext.Provider> } /** The store from the nearest provider. */ export function useLinkGroupStore(): LinkGroupStore { const store = useContext(StoreContext) if (!store) throw new Error("useLinkGroup needs a <LinkGroupProvider> above it") return store } export interface UseLinkGroupOptions { /** Controlled group, for a layout that persists it. */ group?: LinkGroup defaultGroup?: LinkGroup onGroupChange?: (group: LinkGroup) => void /** What the panel shows before anyone sets anything. */ defaultSymbol?: string | null /** The symbol this panel shows changed, by its own hand or through its group. The moment to persist. */ onSymbolChange?: (symbol: string | null) => void /** Recorded on the group with every write from here: usually the panel's id. */ source?: string } export interface LinkGroupHandle { group: LinkGroup symbol: string | null /** Sets this panel's symbol and, when it is linked, its group's. */ setSymbol: (symbol: string | null) => void setGroup: (group: LinkGroup) => void /** Unlinked, 1, 2, 3, 4, unlinked. */ cycleGroup: (step?: 1 | -1) => void } /** * A panel's group and symbol. Unlinked, the symbol is the panel's own. Linked, it is the group's: * joining a group that has a symbol adopts it, joining an empty one gives it this panel's, and * leaving keeps whatever was showing. */ export function useLinkGroup(options: UseLinkGroupOptions = {}): LinkGroupHandle { const { group: controlled, defaultGroup = null, onGroupChange, defaultSymbol = null, onSymbolChange, source = null } = options const store = useLinkGroupStore() const [ownGroup, setOwnGroup] = useState<LinkGroup>(defaultGroup) const group = controlled !== undefined ? controlled : ownGroup const [symbol, setOwnSymbol] = useState<string | null>(() => normalizeSymbol(defaultSymbol)) const linked = useSyncExternalStore( store.subscribe, () => (group === null ? null : store.get(group)), () => null, ) // Follow the group once it has something to say, a clear included. State set during render, so the // panel never paints the symbol it just left behind. if (linked && (linked.version > 0 || linked.symbol !== null) && linked.symbol !== symbol) setOwnSymbol(linked.symbol) // A group nobody has written to takes the symbol of the first panel to join it. useEffect(() => { if (group !== null) store.seed(group, symbol, source) }, [store, group, symbol, source]) const notify = useRef(onSymbolChange) useEffect(() => { notify.current = onSymbolChange }) const told = useRef(symbol) useEffect(() => { if (told.current === symbol) return told.current = symbol notify.current?.(symbol) }, [symbol]) const setSymbol = useCallback( (next: string | null) => { const value = normalizeSymbol(next) setOwnSymbol(value) if (group !== null) store.set(group, value, source) }, [store, group, source], ) const setGroup = useCallback( (next: LinkGroup) => { if (controlled === undefined) setOwnGroup(next) onGroupChange?.(next) }, [controlled, onGroupChange], ) const cycleGroup = useCallback((step: 1 | -1 = 1) => setGroup(cycleLinkGroup(group, step)), [group, setGroup]) return { group, symbol, setSymbol, setGroup, cycleGroup } }hooks/use-popout.ts import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" // A panel in a window of its own, without losing the panel. // // The content is rendered through a portal into one host element that never changes. The host sits // in a slot in the page while the popout is closed and in the popout's body while it is open, and // moving a DOM node does not remount what React rendered into it. State, subscriptions, and context // all carry over, in both directions. // // The popout runs in the opener's JavaScript, so it is one React tree and one set of stores. What // does not come along by itself is the page's CSS, which is copied in and kept in step with the // opener's root element, where a theme class lives. export interface PopoutOptions { title?: string /** Inner size in CSS pixels. Default 640 by 420. */ width?: number height?: number left?: number top?: number /** The window name. Opening a name that is already open reuses that window. A new window each time by default. */ name?: string /** Copy the page's stylesheets, and mirror the root element's attributes, into the popout. Default true. */ copyStyles?: boolean /** Instead of `window.open`, for a shell that makes its own windows. It must hand back a same-origin window synchronously. */ openWindow?: (features: string) => Window | null onOpen?: (popout: Window) => void /** Closed from either side: `close()`, the window's own close button, the opener going away, or the reveal after an `Activity` hide closed the window. */ onClose?: () => void /** The browser refused the window. `open()` has to run inside a click or a key press. */ onBlocked?: () => void } export interface Popout { isOpen: boolean window: Window | null /** Render into this with `createPortal`. Null on a server. */ host: HTMLElement | null /** Put this on an empty element where the content sits while the popout is closed. */ slotRef: (node: HTMLElement | null) => void /** False when the browser blocked it. */ open: () => boolean close: () => void } function features(options: PopoutOptions): string { const parts = ["popup=yes", `width=${Math.round(options.width ?? 640)}`, `height=${Math.round(options.height ?? 420)}`] if (options.left !== undefined) parts.push(`left=${Math.round(options.left)}`) if (options.top !== undefined) parts.push(`top=${Math.round(options.top)}`) return parts.join(",") } /** Makes `to`'s root element carry the attributes of `from`'s, which is where a theme class lives. */ export function mirrorRoot(from: Document, to: Document) { for (const name of to.documentElement.getAttributeNames()) if (!from.documentElement.hasAttribute(name)) to.documentElement.removeAttribute(name) for (const name of from.documentElement.getAttributeNames()) to.documentElement.setAttribute(name, from.documentElement.getAttribute(name) ?? "") } function copyStyles(from: Document, to: Document) { for (const node of from.querySelectorAll<HTMLElement>('link[rel="stylesheet"], style')) { const copy = to.importNode(node, true) // The property is the absolute URL; the attribute may be relative, and the popout is about:blank. if (copy.tagName === "LINK") (copy as HTMLLinkElement).href = (node as HTMLLinkElement).href to.head.appendChild(copy) } to.body.className = from.body.className mirrorRoot(from, to) } // In the page the host takes no box of its own; in the popout it is the page. function place(host: HTMLElement, slot: HTMLElement | null, popout: Window | null) { // A window that died between renders reads as closed: the host goes back to the // slot instead of into a dead document. const target = popout && !popout.closed ? popout : null host.dataset.popout = target ? "open" : "closed" host.style.cssText = target ? "display:block;height:100vh" : "display:contents" if (target) target.document.body.appendChild(host) else if (slot) slot.appendChild(host) else host.remove() } export function usePopout(options: PopoutOptions = {}): Popout { const [host] = useState<HTMLElement | null>(() => (typeof document === "undefined" ? null : document.createElement("div"))) const [slot, setSlot] = useState<HTMLElement | null>(null) const [popout, setPopout] = useState<Window | null>(null) const live = useRef<Window | null>(null) const latest = useRef(options) useEffect(() => { latest.current = options }) useLayoutEffect(() => { if (host) place(host, slot, popout) }, [host, slot, popout]) // Every window whose close has been reported, so no path reports one twice — // a close in the opening commit, or a close-open-close chain ahead of the // reconcile, included. const closeReported = useRef<WeakSet<Window>>(new WeakSet()) const finish = useCallback((target: Window) => { if (live.current !== target) return live.current = null closeReported.current.add(target) setPopout(null) latest.current.onClose?.() }, []) useEffect(() => { if (!popout) return // pagehide is also a reload, which leaves an empty about:blank behind: close that too. const closed = () => { finish(popout) popout.close() } const shut = () => popout.close() popout.addEventListener("pagehide", closed) window.addEventListener("pagehide", shut) let observer: MutationObserver | null = null if (latest.current.copyStyles !== false && typeof MutationObserver !== "undefined") { observer = new MutationObserver(() => mirrorRoot(document, popout.document)) observer.observe(document.documentElement, { attributes: true }) } return () => { popout.removeEventListener("pagehide", closed) window.removeEventListener("pagehide", shut) observer?.disconnect() } }, [popout, finish]) // Unmounting the panel takes its window with it. useEffect( () => () => { live.current?.close() live.current = null }, [], ) // Effect cleanup is not unmount: an Activity hide (or Fast Refresh) runs the cleanup // above and closes the window while this hook's state survives. Reconcile on revival, // so isOpen tells the truth and close() stops returning early on a null handle. Only a // null live handle reconciles — an open() that ran earlier in the same revival, from a // child effect or a keypress in the transition gap, already owns live and must not be // clobbered — and a dead window that is still the live one goes through finish, the // same path its close listener would take. useEffect(() => { if (!popout) return if (live.current === null) { // The hide's cleanup closed the window without the close listener firing: // complete the lifecycle so every onOpen still meets its onClose. setPopout(null) if (!closeReported.current.has(popout)) { closeReported.current.add(popout) latest.current.onClose?.() } } else if (live.current === popout && popout.closed) finish(popout) else if (live.current !== popout && popout.closed && !closeReported.current.has(popout)) { // A newer window took over during the reveal — a child's effect or a keypress in // the gap — and state will settle on it. Complete the dead one's pair here, once. closeReported.current.add(popout) latest.current.onClose?.() } }, [popout, finish]) const open = useCallback(() => { if (live.current && !live.current.closed) { live.current.focus() return true } const o = latest.current const next = o.openWindow ? o.openWindow(features(o)) : window.open("", o.name ?? "_blank", features(o)) if (!next) { o.onBlocked?.() return false } if (o.title !== undefined) next.document.title = o.title if (o.copyStyles !== false) copyStyles(document, next.document) live.current = next setPopout(next) o.onOpen?.(next) return true }, []) const close = useCallback(() => { const target = live.current if (!target) return finish(target) target.close() }, [finish]) return { isOpen: popout !== null, window: popout, host, slotRef: setSlot, open, close } }hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }lib/link-group.ts // Link groups: panels that wear the same color show the same symbol. // // The store holds one symbol per group and knows nothing about React. A write bumps the group's // version and, while the store is connected, goes out on a transport so other windows follow. The // transport is an interface with two methods: BroadcastChannel here, a desktop shell's own events // in an app that has them. // // Windows come and go, so a store that connects says hello and the others answer with what they // hold: a window opened late shows the group's symbol instead of nothing. When two windows write at // once, the higher version wins, and on a tie the higher window id, so both sides pick the same one. export type LinkGroupId = 1 | 2 | 3 | 4 /** `null` is unlinked. */ export type LinkGroup = LinkGroupId | null export const LINK_GROUPS: readonly LinkGroupId[] = [1, 2, 3, 4] export interface LinkGroupState { group: LinkGroupId symbol: string | null /** Who wrote it: whatever the writer passed to `set`, usually a panel's id. */ source: string | null /** Counts writes. Between windows the higher version wins. 0 is never written, or only seeded. */ version: number } export type LinkMessage = | { kind: "tradecn-link"; type: "set"; origin: string; group: LinkGroupId; symbol: string | null; source: string | null; version: number } | { kind: "tradecn-link"; type: "hello"; origin: string } export interface LinkTransport { post(message: LinkMessage): void /** Deliver what other windows post. Returns the unsubscribe. */ subscribe(cb: (message: LinkMessage) => void): () => void } export interface LinkGroupStore { /** This store's id on the transport. */ readonly id: string /** The same object until the group changes, so it is safe as a `useSyncExternalStore` snapshot. */ get(group: LinkGroupId): LinkGroupState /** Blank is null. Writing the symbol a group already holds changes nothing. */ set(group: LinkGroupId, symbol: string | null, source?: string | null): void /** * Give a group that nobody has written to a first symbol, from a panel that joined it holding one. * A seed stays in this window and any real write replaces it, here or from another window, so a * symbol restored from storage never overrules the one people are looking at. */ seed(group: LinkGroupId, symbol: string | null, source?: string | null): void subscribe(cb: () => void): () => void /** Start listening to the transport and ask the other windows what they hold. Returns the disconnect. */ connect(): () => void } export interface LinkGroupStoreOptions { /** Omit or pass null to keep links inside this window. */ transport?: LinkTransport | null id?: string } /** Unlinked, then 1 to 4, then unlinked again. `step` -1 goes the other way. */ export function cycleLinkGroup(group: LinkGroup, step: 1 | -1 = 1): LinkGroup { const order: LinkGroup[] = [null, ...LINK_GROUPS] const at = Math.max(0, order.indexOf(group)) return order[(at + step + order.length) % order.length]! } export function normalizeSymbol(symbol: string | null | undefined): string | null { const next = typeof symbol === "string" ? symbol.trim() : "" return next ? next : null } function isGroupId(value: unknown): value is LinkGroupId { return LINK_GROUPS.includes(value as LinkGroupId) } /** Messages arrive from outside the page; take nothing on trust. */ export function isLinkMessage(value: unknown): value is LinkMessage { if (!value || typeof value !== "object") return false const m = value as Record<string, unknown> if (m.kind !== "tradecn-link" || typeof m.origin !== "string" || !m.origin) return false if (m.type === "hello") return true if (m.type !== "set") return false return isGroupId(m.group) && (m.symbol === null || typeof m.symbol === "string") && (m.source === null || typeof m.source === "string") && typeof m.version === "number" && Number.isFinite(m.version) } function makeId(): string { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID() return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` } export function createLinkGroupStore(options: LinkGroupStoreOptions = {}): LinkGroupStore { const id = options.id ?? makeId() const transport = options.transport ?? null const states = new Map<LinkGroupId, LinkGroupState>(LINK_GROUPS.map((group) => [group, { group, symbol: null, source: null, version: 0 }])) // Which store wrote each group's current state: the tie-break when two versions are equal. const origins = new Map<LinkGroupId, string>() const listeners = new Set<() => void>() let connections = 0 let disconnect: (() => void) | null = null function emit() { for (const cb of listeners) cb() } function announce(state: LinkGroupState) { if (connections > 0) transport?.post({ kind: "tradecn-link", type: "set", origin: origins.get(state.group) ?? id, group: state.group, symbol: state.symbol, source: state.source, version: state.version }) } function receive(message: LinkMessage) { if (!isLinkMessage(message) || message.origin === id) return if (message.type === "hello") { for (const state of states.values()) if (state.version > 0) announce(state) return } const current = states.get(message.group)! if (message.version < current.version) return if (message.version === current.version && (message.symbol === current.symbol || message.origin <= (origins.get(message.group) ?? ""))) return origins.set(message.group, message.origin) states.set(message.group, { group: message.group, symbol: normalizeSymbol(message.symbol), source: message.source, version: message.version }) emit() } return { id, get: (group) => states.get(group)!, set(group, symbol, source = null) { const current = states.get(group) if (!current) return const next = normalizeSymbol(symbol) // Writing a seed's own symbol still counts: it is the first real write, and other windows have not heard it. if (next === current.symbol && (current.version > 0 || next === null)) return const state = { group, symbol: next, source, version: current.version + 1 } origins.set(group, id) states.set(group, state) emit() announce(state) }, seed(group, symbol, source = null) { const current = states.get(group) const next = normalizeSymbol(symbol) if (!current || current.version > 0 || current.symbol !== null || next === null) return states.set(group, { group, symbol: next, source, version: 0 }) emit() }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, connect() { if (!transport) return () => {} if (connections++ === 0) { disconnect = transport.subscribe(receive) transport.post({ kind: "tradecn-link", type: "hello", origin: id }) } let done = false return () => { if (done) return done = true if (--connections > 0) return disconnect?.() disconnect = null } }, } } export interface CallbackTransportOptions { /** Send a message to the other windows: a desktop shell's event, a socket, a worker's port. */ send: (message: LinkMessage) => void /** * Deliver what the other windows send to `deliver`, and return the way to stop. Called once, for the * first subscriber; its return is called when the last subscriber leaves. What arrives is checked * before it reaches a store, so hand over whatever the channel carries. */ receive: (deliver: (message: unknown) => void) => () => void } /** * A transport over two functions, for a shell whose windows are separate JavaScript contexts and * cannot share a `BroadcastChannel`: each window runs its own provider, and the shell's own events * carry the links between them. */ export function createCallbackTransport({ send, receive }: CallbackTransportOptions): LinkTransport { const listeners = new Set<(message: LinkMessage) => void>() let stop: (() => void) | null = null return { post(message) { send(message) }, subscribe(cb) { listeners.add(cb) if (!stop) { stop = receive((message) => { if (isLinkMessage(message)) for (const l of [...listeners]) l(message) }) } return () => { listeners.delete(cb) if (listeners.size || !stop) return stop() stop = null } }, } } /** Every same-origin window and tab that opens this channel name shares links. The channel opens with the first subscriber and closes with the last. */ export function createBroadcastChannelTransport(name = "tradecn-link"): LinkTransport { const listeners = new Set<(message: LinkMessage) => void>() let channel: BroadcastChannel | null = null const onMessage = (event: MessageEvent<unknown>) => { if (isLinkMessage(event.data)) for (const cb of listeners) cb(event.data) } return { post(message) { channel?.postMessage(message) }, subscribe(cb) { listeners.add(cb) if (!channel && typeof BroadcastChannel !== "undefined") { channel = new BroadcastChannel(name) channel.addEventListener("message", onMessage) } return () => { listeners.delete(cb) if (listeners.size || !channel) return channel.removeEventListener("message", onMessage) channel.close() channel = null } }, } }lib/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.toLowerCase().split("+") : []) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = declaredSteps } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) clearPending() emit() } } }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } } -
Add the tokens to your stylesheet:
:root { --link-1: oklch(0.53 0.2 25); --link-2: oklch(0.5 0.15 250); --link-3: oklch(0.5 0.13 150); --link-4: oklch(0.52 0.12 85); --panel-active: var(--color-primary); --panel-drag-target: var(--color-ring); --panel-error: var(--color-destructive); --panel-sync: oklch(0.5 0.12 220); --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 { --link-1: oklch(0.7 0.19 25); --link-2: oklch(0.7 0.14 250); --link-3: oklch(0.74 0.14 150); --link-4: oklch(0.85 0.15 90); --panel-active: var(--color-primary); --panel-drag-target: var(--color-ring); --panel-error: var(--color-destructive); --panel-sync: oklch(0.78 0.12 220); --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 { Panel, PanelContent, PanelHeader, PanelTitle } from "@/components/ui/panel"
export default function PanelDemo() {
return (
<Panel kind="orders" className="h-40 w-72 max-w-full">
<PanelHeader>
<PanelTitle>Orders</PanelTitle>
</PanelHeader>
<PanelContent className="p-3 text-sm">No open orders.</PanelContent>
</Panel>
)
}
Give the panel a height; PanelContent fills what the header leaves and scrolls overflow. PanelTitle also names the region for assistive technology. Basic framing needs no provider.
Composition
Panel
├── PanelHeader
│ ├── PanelTitle
│ ├── SymbolTag
│ ├── LinkGroupDot
│ └── PanelActions
└── PanelContent
PanelPopout wraps the panel for a popout. The header, title, actions, and content accept div props, including children and className. PanelContent fills the remaining height and scrolls overflow.
Linked symbols
Book A and Book B start in group 1; Book C is unlinked. Edit either linked symbol to ZN, ZB, or ES and press Enter. Both linked books follow, while Book C keeps its own symbol. Invalid symbols stay open for correction; Escape cancels the edit.
Click a group dot to cycle forward, or Shift-click to go back. Joining group 1 adopts its symbol; leaving keeps the symbol currently displayed. transport={null} isolates this preview's provider. Omit it to use the default cross-window channel.
import { LinkGroupProvider, useLinkGroup } from "@/hooks/use-link-group"
import type { LinkGroup } from "@/lib/link-group"
import { LinkGroupDot, Panel, PanelContent, PanelHeader, PanelTitle, SymbolTag } from "@/components/ui/panel"
const SYMBOLS = new Set(["ZN", "ZB", "ES"])
function Book({ title, group, symbol }: { title: string; group: LinkGroup; symbol: string }) {
const link = useLinkGroup({ source: title, defaultGroup: group, defaultSymbol: symbol })
return (
<Panel kind="book" className="h-36 w-56 max-w-full">
<PanelHeader>
<PanelTitle>{title}</PanelTitle>
<SymbolTag label={`${title} symbol`} value={link.symbol} onCommit={link.setSymbol} validate={(value) => SYMBOLS.has(value)} />
<LinkGroupDot group={link.group} onGroupChange={link.setGroup} aria-label={`${title}, ${link.group === null ? "not linked" : `link group ${link.group}`}, change`} />
</PanelHeader>
<PanelContent className="p-3 text-sm">Following {link.symbol}.</PanelContent>
</Panel>
)
}
export default function PanelLinkedDemo() {
return (
<LinkGroupProvider transport={null}>
<div className="flex w-fit max-w-full flex-wrap justify-center gap-3">
<Book title="Book A" group={1} symbol="ZN" />
<Book title="Book B" group={1} symbol="ZN" />
<Book title="Book C" group={null} symbol="ES" />
</div>
</LinkGroupProvider>
)
}
Scoped hotkeys
Click Refresh in either book, then press r. Only that book's counter changes. You can also focus its button with Tab before pressing the key. The shared binding names the panel kind; each handler belongs to the instance that contains it.
Call useHotkey in a child of Panel, as BookContent does here. Calling it in the component that returns Panel puts the handler outside that panel's scope. The counters stand in for refresh requests; replace the handler with your application's action.
import { useState } from "react"
import { HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import { Panel, PanelActions, PanelContent, PanelHeader, PanelTitle } from "@/components/ui/panel"
const BINDINGS: HotkeyBinding[] = [
{ id: "book.refresh", keys: "r", scope: "panel:book", description: "Refresh book" },
]
function BookContent({ title }: { title: string }) {
const [requests, setRequests] = useState(0)
const refresh = () => setRequests((count) => count + 1)
useHotkey("book.refresh", refresh)
return (
<>
<PanelHeader>
<PanelTitle>{title}</PanelTitle>
<PanelActions>
<button type="button" aria-label={`Refresh ${title}`} className="rounded px-1 hover:bg-muted" onClick={refresh}>Refresh</button>
</PanelActions>
</PanelHeader>
<PanelContent className="p-3 text-sm">
<p role="status">Refresh requests: {requests}</p>
</PanelContent>
</>
)
}
export default function PanelHotkeysDemo() {
return (
<HotkeysProvider bindings={BINDINGS}>
<div className="flex w-fit max-w-full flex-wrap justify-center gap-3">
<Panel kind="book" className="h-36 w-64 max-w-full"><BookContent title="Book A" /></Panel>
<Panel kind="book" className="h-36 w-64 max-w-full"><BookContent title="Book B" /></Panel>
</div>
</HotkeysProvider>
)
}
Border states
By default, focus inside a panel activates its border. Focus the Note field to see it, or use the controls above the preview to force active or inactive. Drag target overrides active, and Error overrides both. The checkboxes can be combined to show that priority.
These controls set presentation props; they do not implement dragging or an error workflow. The header is a drag handle explains the layout integration.
import { useState } from "react"
import { Panel, PanelContent, PanelHeader, PanelTitle } from "@/components/ui/panel"
export default function PanelStatesDemo() {
const [active, setActive] = useState("auto")
const [dragTarget, setDragTarget] = useState(false)
const [error, setError] = useState(false)
return (
<>
<div data-demo-controls className="text-xs">
<label className="flex items-center gap-2">
Active border
<select className="rounded border border-border bg-background px-2 py-1" value={active} onChange={(event) => setActive(event.target.value)}>
<option value="auto">Follow focus</option>
<option value="active">Always active</option>
<option value="inactive">Always inactive</option>
</select>
</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={dragTarget} onChange={(event) => setDragTarget(event.target.checked)} />Drag target</label>
<label className="flex items-center gap-1"><input type="checkbox" checked={error} onChange={(event) => setError(event.target.checked)} />Error</label>
</div>
<Panel kind="states" className="h-40 w-72 max-w-full" active={active === "auto" ? undefined : active === "active"} dragTarget={dragTarget} error={error}>
<PanelHeader><PanelTitle>Border states</PanelTitle></PanelHeader>
<PanelContent className="p-3 text-sm">
<label className="flex flex-col gap-2">Note<input className="min-w-0 rounded border border-input bg-background px-2 py-1" placeholder="Focus here" /></label>
</PanelContent>
</Panel>
</>
)
}
Popout panel
Increment the counter, then pop it out. Its count survives the move, and i still increments it while focus is inside the panel. Bring it back from either window, or close the popout window itself. The panel fills the popout because it uses h-full.
PanelPopout moves one portal host between documents, preserving React state and provider context. Here, HotkeysProvider supplies the shortcut in both windows. Hiding the panel under an Activity boundary closes its window the way unmounting does, without onClose, and isOpen stays stale while hidden. On reveal the panel is back inline, isOpen reads false, onClose runs once, and it can be popped out again. Popout covers styling, lifecycle, and portal limitations.
import { useState } from "react"
import { HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import { usePopout } from "@/hooks/use-popout"
import type { HotkeyBinding } from "@/lib/hotkeys"
import { Panel, PanelActions, PanelContent, PanelHeader, PanelPopout, PanelTitle } from "@/components/ui/panel"
const BINDINGS: HotkeyBinding[] = [
{ id: "counter.increment", keys: "i", scope: "panel:counter", description: "Increment counter" },
]
function Counter() {
const [count, setCount] = useState(0)
const increment = () => setCount((value) => value + 1)
useHotkey("counter.increment", increment)
return (
<PanelContent className="space-y-3 p-3 text-sm">
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={increment}>Increment</button>
<p role="status">Count: {count}</p>
</PanelContent>
)
}
function PopoutPanel() {
const [blocked, setBlocked] = useState(false)
const popout = usePopout({ title: "Counter", width: 360, height: 240, onBlocked: () => setBlocked(true) })
return (
<div className="w-72 max-w-full space-y-2">
<div className="h-48">
<PanelPopout popout={popout} placeholder={
<div className="flex h-full items-center justify-center rounded border border-dashed border-border">
<button type="button" className="rounded border border-border px-3 py-2 text-sm hover:bg-muted" onClick={popout.close}>Bring counter back</button>
</div>
}>
<Panel kind="counter" className="h-full">
<PanelHeader>
<PanelTitle>Counter</PanelTitle>
<PanelActions>
<button type="button" className="rounded px-1 hover:bg-muted" onClick={popout.isOpen ? popout.close : () => { setBlocked(false); popout.open() }}>
{popout.isOpen ? "Bring back" : "Pop out"}
</button>
</PanelActions>
</PanelHeader>
<Counter />
</Panel>
</PanelPopout>
</div>
{blocked && <p role="alert" className="text-sm">Popout blocked. Allow popups and try again.</p>}
</div>
)
}
export default function PanelPopoutDemo() {
return <HotkeysProvider bindings={BINDINGS}><PopoutPanel /></HotkeysProvider>
}
API Reference
A panel is a hotkey scope
| Panel prop | Type | Default | Purpose |
|---|---|---|---|
kind |
string |
Required | Hotkey scope panel:<kind>, shared by panels of this kind. |
active |
boolean |
Focus within | Controls the active border; false suppresses it even with focus inside. |
dragTarget |
boolean |
false |
Shows the drag target border and background. |
error |
boolean |
false |
Shows the error border. |
className |
string |
None | Styles and sizes the panel. |
| Other div props | Omit<ComponentProps<"div">, "ref"> |
None | Includes children, event handlers, and accessible labels. |
Declare a binding once for panel:book; the handler under the focused book answers it. Clicking the panel aims the keyboard at it. Palette actions with that scope appear when opened from inside a book — and with several books on the screen, register each book's action with within: useHotkeyScope(), called in a child of Panel the way useHotkey is, so the book that held focus is the one that runs; without it, the latest registration answers. Panel renders without HotkeysProvider, but hotkeys need the provider. See use-hotkeys for binding options.
Panel renders a region named by PanelTitle, or by an explicit aria-label. Give every panel one of the two: without either, the region's aria-labelledby points at nothing. Border priority is error, then dragTarget, then active. data-state is error, drag-target, active, inactive, or auto; auto uses CSS focus-within.
The header is a drag handle
Point your layout's handle selector at [data-panel-handle] on PanelHeader. The panel does not drag itself. SymbolTag, LinkGroupDot, and PanelActions stop pointerdown, mousedown, and touchstart from reaching React handlers on the header and bubbling native listeners above the element where React handles the event: the React root, or the nearest portal container, such as a Workspace panel's. Capture listeners still receive them, and so does any native listener at or below that element — the header itself, a container a drag library watches, or document when React renders into document itself — because React handles events after the native pass reaches that element. Give such a library its cancel or filter selector for these controls. On LinkGroupDot and PanelActions, your own handler runs first, and a preventDefault() it adds lets the press through to the header's drag; SymbolTag exposes no press handlers. They do not stop dragstart; HTML drag and drop needs a separate handle beside these controls.
PanelActions aligns your buttons at the header's far end. Supply their icons; shadcn resolves the icon library from your project's iconLibrary setting.
SymbolTag
| Prop | Type | Default | Purpose |
|---|---|---|---|
value |
string | null |
Required | Current symbol. |
onCommit |
(symbol: string) => void |
Required | Receives an accepted, changed symbol; update value to display it. |
normalize |
(raw: string) => string |
Trim and uppercase | Converts the draft before comparison and validation; override for case-sensitive symbols such as BRK.b. |
validate |
(symbol: string) => boolean |
None | Returning false keeps the field open with aria-invalid. |
placeholder |
string |
"symbol" |
Text when value is null. |
editing |
boolean |
Internal state | Controls edit mode, for example from a hotkey. |
onEditingChange |
(editing: boolean) => void |
None | Receives requests to open or close the field. |
disabled |
boolean |
false |
Prevents editing, including when editing is true. |
label |
string |
"Symbol" |
Accessible input label and prefix for the button's label. |
className |
string |
None | Styles the tag wrapper. |
Click or press Enter on the tag to edit with the current symbol selected. Enter submits; an empty or unchanged normalized draft closes without committing. Escape or blur cancels. Keyboard exits restore focus to the tag; blur leaves focus where it moved. With controlled editing, update it in onEditingChange to complete the exit.
Typing on the closed tag does not open it, so single-key panel bindings still work. While editing, Enter and Escape call preventDefault and stopPropagation, preventing editing-scope bindings from also firing.
Dialog behavior is inferred from library source, not covered by the browser matrix: Base UI (1.8.0) listens for Escape on the document in the bubble phase, so cancelling the edit should leave the dialog open. Radix's capture-phase listener runs first, so the same Escape can close its dialog.
External value changes show a 900 ms panel-sync ring, including changes from a link group. The initial value and locally committed value do not ring. Reduced motion or an unavailable animation API disables the ring.
Link groups
useLinkGroup() lives in hooks/use-link-group and usePopout in hooks/use-popout, with createCallbackTransport and createLinkGroupStore in lib/link-group. useLinkGroup() throws outside a LinkGroupProvider, on the server as everywhere. It returns { group, symbol, setSymbol, setGroup, cycleGroup }. A LinkGroup is 1 | 2 | 3 | 4 | null, where null means unlinked.
| Hook option | Type | Default | Purpose |
|---|---|---|---|
group |
LinkGroup |
Internal state | Controlled group. |
defaultGroup |
LinkGroup |
null |
Initial uncontrolled group. |
onGroupChange |
(group: LinkGroup) => void |
None | Receives requests from setGroup or cycleGroup; update a controlled group here. |
defaultSymbol |
string | null |
null |
Initial symbol, trimmed; blank becomes null. |
onSymbolChange |
(symbol: string | null) => void |
None | Reports displayed symbol changes for persistence. |
source |
string |
None | Writer identity stored with group writes, usually the panel id. |
setSymbol(string | null) trims without changing case; blank becomes null. Unlinked, it changes this panel alone. Linked, it changes the group, including clears. Joining adopts the group's symbol or clear; an untouched group takes the first joiner's nonblank symbol as a local seed. Seeds do not cross windows and yield to real writes, so restored defaults cannot overwrite another window's written symbol. Leaving keeps the displayed symbol.
Persist the group and symbol yourself. onSymbolChange skips an unchanged initial symbol, but can fire during mounting if joining a group changes it.
cycleGroup(step?) cycles through unlinked, 1, 2, 3, 4, then unlinked; step is 1 by default or -1 for reverse. LinkGroupDot uses this order on click, reversed by Shift+click. It shows a number and an accessible label as well as a color from --link-1 to --link-4.
| LinkGroupDot prop | Type | Default | Purpose |
|---|---|---|---|
group |
LinkGroup |
Required | Displayed group. |
onGroupChange |
(group: LinkGroup) => void |
Required | Receives the next group. |
onClick |
MouseEventHandler<HTMLButtonElement> |
None | Runs before cycling; preventDefault() cancels it. |
className |
string |
None | Styles the button. |
| Other button props | Omit<ComponentProps<"button">, "onChange" | "children"> |
None | Includes disabled, accessible labels, and event handlers. |
Between windows
| LinkGroupProvider prop | Type | Default | Purpose |
|---|---|---|---|
store |
LinkGroupStore |
New store | Supply a store; its transport takes precedence over transport and channel. |
transport |
LinkTransport | null |
BroadcastChannel | Cross-window transport; null keeps links local. Read at initialization. |
channel |
string |
"tradecn-link" |
Default channel name, read at initialization. |
children |
ReactNode |
None | Panels sharing the store. |
Providers on the same origin and channel exchange symbols when BroadcastChannel is available. The provider connects its store while mounted. A transport opens its channel with the first subscriber and closes it with the last. Incoming messages are validated.
For separate desktop webviews, createCallbackTransport({ send, receive }) adapts the shell's event bus to LinkTransport, whose methods are post(message) and subscribe(callback). Each window runs its own provider; the shell forwards messages between them.
| Callback transport option | Type | Default | Purpose |
|---|---|---|---|
send |
(message: LinkMessage) => void |
Required | Sends to other windows. |
receive |
(deliver: (message: unknown) => void) => () => void |
Required | Starts on the first subscriber; its returned function stops listening after the last. Messages are validated before delivery. |
createLinkGroupStore(options?) provides the store without React. Pass it to the provider, or call connect() yourself and retain the returned disconnect function.
| Store option | Type | Default | Purpose |
|---|---|---|---|
transport |
LinkTransport | null |
null |
Links stay local unless supplied. |
id |
string |
Generated id | Store identity used to break cross-window version ties. |
Connecting requests other windows' written symbols, excluding seeds. Concurrent writes settle on the higher version, then the higher origin id. A write made just after reloading, before replies arrive, can still be replaced by an older window's state.
Popout
usePopout() and PanelPopout move a panel into a window of its own and back, without remounting it.
See Popout panel for a complete example. Call usePopout inside a component. Add HotkeysProvider for shortcuts or LinkGroupProvider if the panel uses linked symbols. PanelPopout accepts:
| Prop | Type | Default | Purpose |
|---|---|---|---|
popout |
Popout |
Required | Handle from usePopout. |
placeholder |
ReactNode |
None | Content left in the page while open. |
children |
ReactNode |
None | Content moved between the page and window. |
usePopout(options?) accepts:
| Option | Type | Default | Purpose |
|---|---|---|---|
title |
string |
Unchanged | Window title, set on opening. |
width, height |
number |
640, 420 |
Requested inner size in CSS pixels, rounded. |
left, top |
number |
Browser decides | Requested screen position in CSS pixels, rounded. |
name |
string |
"_blank" |
Window name; a matching name reuses a window. |
copyStyles |
boolean |
true |
Copies styles and mirrors root attributes. |
openWindow |
(features: string) => Window | null |
window.open |
Shell adapter; must return a same-origin window synchronously. |
onOpen |
(popout: Window) => void |
None | Runs after obtaining the window, before React moves the host. |
onClose |
() => void |
None | Runs from close(), the popout's pagehide, or the reveal after an Activity hide closed the window; unmount cleanup does not call it. |
onBlocked |
() => void |
None | Runs when the opener returns null. |
The handle exposes isOpen, window, host, slotRef, open(), and close(). PanelPopout handles the host and slot for you; without a host, as on the server, it renders no children.
Call open() from a click or key press. It returns false when blocked and true on success; calling it while open focuses the existing window. Closing or reloading the popout returns the panel to the page. Unmounting the hook or leaving the opener closes the window. Give the panel h-full to fill the popout host.
One portal host moves between documents, preserving React state, subscriptions, and context. The popout shares the opener's JavaScript and stores, so link groups need no transport. PanelPopout attaches hotkeys to the popout document when under HotkeysProvider.
Stylesheets and the body's class are copied on opening. Root attributes stay synchronized, so theme toggles follow. copyStyles: false disables both copying and mirroring.
- Browser state such as focus, scroll positions, and CSS animations can reset during the move; iframes can reload.
- Portals targeting the opener's
document.bodystill open there, including menus, tooltips, or dialogs configured that way. - Stylesheets hot-reloaded after opening are not copied again.
- Separate JavaScript contexts cannot share this portal. Render panels in each window and connect them with
LinkTransport.
Tokens
The install adds missing panel-active, panel-drag-target, panel-error, panel-sync, and link-1 through link-4 tokens for borders, the sync ring, and group colors, with the mono font token, the accessible pair, and the hyperlegible remap.
What it does not do
Use workspace for layout, docking, resizing, and tabs. Your app owns persistence and fetching data for the selected symbol.
Built on shadcn's input.
This page is docs/panel.md at v2.0.0.