# @inklu/dock > A headless, accessible workspace dock for React: docked and expanded modes, tabbed panels, dirty-tracked forms, drag resize and URL state. - Install: `npm install @inklu/dock` - Agent skill: `npx skills add nkurunziza-saddy/inklu --skill inklu-dock` ## Docs - [Full reference](https://inklu.saddy.me/dock/llms-full.txt): every export, option and pattern, as Markdown - [Documentation](https://inklu.saddy.me/dock): Panels, provider options, forms, guards, URL state, styling and the API - [Try it out](https://inklu.saddy.me/dock/try-it-out): A live dock with every option exposed ## Optional - [Source](https://github.com/nkurunziza-saddy/inklu/tree/main/packages/dock) - [npm](https://www.npmjs.com/package/@inklu/dock) ## Setup prompt Set up a workspace dock (side panel / multi-tab surface) using the `@inklu/dock` package. Do not invent APIs. If something here is ambiguous, read https://inklu.saddy.me/dock before guessing. ### 1. Install `npm install @inklu/dock` Requires React 19. ### 2. Register your panel types Call `createDock` once, at module scope, with every panel type the app can open. `definePanel` exists only to infer the payload type — it returns its argument as-is. ```tsx "use client"; import { createDock, definePanel } from "@inklu/dock"; import "@inklu/dock/styles.css"; interface RecordRef { id: string; kind: string; } export const dock = createDock({ record: definePanel({ title: (payload) => `${payload.kind} #${payload.id}`, component: ({ payload }) =>
Content for {payload.id}
, url: ["kind", "id"], // round-trips through the URL — string-valued keys only }), }); ``` `createDock` returns a self-contained object: `dock.Provider`, `dock.Viewport`, lower-level `dock.Surface` / `dock.TabStrip`, and the `dock.use()` hook. A page can host more than one independent dock by calling `createDock` more than once. ### 3. Mount the provider and viewport ```tsx export function App() { return (
{/* your app content */}
); } ``` `dock.Viewport` reserves layout width in `docked` mode and renders the dock surface (tabs, resize handle, panel bodies) once anything is open. `dock.Provider` takes `confirmClose`, `limit` (default 5), `onLimit`, `onOpen`/`onClose`/`onActiveChange` callbacks, `session` (reopen every tab on reload), `url`/`widthStore` persistence adapters, and width bounds (`defaultWidth`, `minWidth`, `maxWidth`, `expandedWidth`, `maxExpandedWidth`) as props — all read live, so changing them takes effect immediately. ### 4. Open, close, and update panels `dock.use()` is a hook — call it during render, not inside a handler: ```tsx const { open, close, closeAll, closeOthers, replace, select, update, exists, expand, collapse, setWidth } = dock.use(); open("record", { kind: "task", id: "101" }); // → panel id, or null if refused ``` - `open` returns `null` when the type isn't registered, or the dock is at its limit and nothing is evictable (`onLimit` fires in that case). - Opening a payload whose identity matches an already-open panel re-selects that tab instead of duplicating it. Identity comes from (in order): an explicit `options.id` passed to `open`, a declared `key(payload)`, a fingerprint of `serialize`/`url`, a conventional `payload.id` field, or else a fresh id every call. - `replace`, `close`, `closeAll`, and `closeOthers` are async — they consult the outgoing panel's `beforeClose` guard before resolving, and resolve to `false`/`null` if it vetoes. - `update(panelId, patch)` shallow-merges `patch` into a panel's payload. - `open` moves focus into the panel it opened: to the element marked `data-dock-autofocus` in its content, or to the panel itself. Mark a form's first field instead of focusing it from an effect. Pass `{ focus: false }` for opens the user didn't trigger (server events); URL and session restores never take focus. ### 5. Forms (dirty tracking + close guard) `useDockForm` gives a panel draft state, dirty tracking, and a close guard in one hook: ```tsx import { DockFormFooter, useDockForm } from "@inklu/dock"; function RecordPanel({ payload }: { payload: { id: string; title: string } }) { const form = useDockForm({ initial: { title: payload.title }, onSubmit: async (values) => saveRecord(payload.id, values), }); return ( <> form.set("title", e.target.value)} /> ); } ``` The close guard is registered only while `form.isDirty`, so a clean form does not block tab-limit eviction. Closing (or navigating away from) a dirty panel is blocked unless `confirmDiscard` (or the provider's `confirmClose`) approves it. For a manual guard on a panel that isn't a form, use `useDockLifecycle({ dirty, beforeClose })` — it must be called from inside a panel's `component`. ### 6. Reading state Reactive state is read through separate hooks, not through `dock.use()`. Prefer `useDockSelector` for a slice: it re-renders only when that slice changes, while `useDockState` re-renders on every change, each step of a resize drag included: ```tsx import { useActivePanel, useDockSelector, useDockState, useDockTabs } from "@inklu/dock"; const isOpen = useDockSelector((state) => state.panels.length > 0); const { panels, activeId, mode, width, expandedWidth } = useDockState(); const tabs = useDockTabs(); // what renders const active = useActivePanel(); ``` ### Behaviour to rely on, not re-implement - `docked` mode sits beside the app and reserves width via padding; `expanded` mode lifts the same dock into a modal — backdrop, focus trap, Escape, scroll lock, background inerting. It renders inside ``'s own stacking context rather than portalling to `document.body`. - The tab strip already implements the WAI-ARIA tabs pattern: roving `tabIndex`, arrow-key/Home/End navigation, Delete/Backspace to close, and middle-click to close. Do not add your own keyboard handlers on top of it. - The resize handle supports both pointer drag and keyboard (arrow keys, Home/End, double-click to reset) with live `aria-value*` attributes. It's already wired up inside `` — no extra markup needed. - URL persistence is on by default (``'s `url` prop) and only mirrors the *selected* panel — the rest of the open tabs are session-local. Pass `url={null}` to disable it, or a custom `DockUrlSync` (via `createUrlSync`) to change where it's stored. - At the tab limit, the least-recently-active panel that is both clean and unguarded is evicted automatically. A panel with a registered `beforeClose` guard is treated as un-evictable, because `open` is synchronous and cannot await a guard's answer. - Styling is entirely data-attribute driven (`data-dock-*`, plus `data-slot` for shadcn-style targeting) and CSS-variable driven (`--dock-*`). There is no `className`-per-element API — do not invent one. ### Checklist - [ ] `createDock` called once, at module scope, with every panel type - [ ] `` wraps ``, which wraps the app's content - [ ] `dock.use()` called during render, not inside a click handler - [ ] Panels with unsaved edits use `useDockForm` (or `useDockLifecycle`'s `beforeClose`) instead of a raw `window.confirm` on unmount - [ ] No manual keyboard handlers layered on the tab strip or resize handle - [ ] No hand-rolled focus trap or scroll lock for expanded mode — it's built in - [ ] Styling goes through `data-dock-*` attributes and `--dock-*` variables, not a new className API