Documentation
Concepts

The model and commands

The layout is an immutable JSON-shaped state. Every change is a command that runs through a middleware chain, and every commit emits one event.

The model is the source of truth. What you render is a function of its state, and the state changes only through commands: model.run("tab.close", { tab: "t1" }). Every command, whether your code runs it or a primitive does (a click, a drop, a splitter drag), goes through the same middleware chain, and every commit emits one event.

JSON in, JSON out

import { createModel, type LayoutJson } from "@fragiola/dockable";

type Types = { tabs: { editor: { name: string; path: string }; log: { name: string } } };

const json: LayoutJson<Types> = {
    version: 1,
    root: {
        type: "row",
        children: [
            {
                type: "tabset",
                id: "editors",
                children: [{ id: "readme", component: "editor", data: { name: "README.md", path: "/README.md" } }],
            },
        ],
    },
};

const model = createModel<Types>(json); // LayoutJson -> Model<Types>
const saved: LayoutJson<Types> = model.toJSON(); // Model -> LayoutJson (a writable copy)

A layout document is JSON v1: version: 1, a root row, and optionally defaults, the active and maximized tabsets, borders and popout windows. A tab names its component and carries its data, typed by the Types registry (Typed data). Every field is in the JSON model reference.

  • Ids are explicit. A node without an id gets one at load (tab-1, tabset-2, …, or your createId option), and toJSON() writes it, so a round trip is stable: createModel(model.toJSON()).state deep-equals model.state. JSON.stringify(model) works too.
  • Loading validates. createModel checks the document against the JSON v1 schema (duplicate ids, two borders on one side, an active naming a missing tabset, …) and throws a LayoutValidationError whose issues list every problem with its JSON path. validateLayout(json) returns the same issues instead of throwing, for code that must not throw.
  • The model loads in plain Node. It needs no DOM, so it works in tests, on a server, or in a worker.

The state is plain data

model.state is the whole layout as readonly plain objects: no classes, no getters, no methods. A node is told apart by its type field ("row", "tabset", "tab", "border"), and its fields are read directly:

const tabset = model.get("editors");
if (tabset?.type === "tabset") {
    tabset.children; // readonly TabOf<Types>[]
    tabset.selected; // the index of the selected tab, -1 when none
    tabset.weight; // its relative size in its row
}

The state is immutable. A command never changes it in place: it produces a new state that shares every untouched node with the previous one, and the objects are frozen. So a node you hold is a snapshot (read the model again to see a later change), before === after tells you nothing changed, and keeping an old state (for undo, a diff, a test) costs nothing.

The state holds only the layout. Measured rects, scroll positions, which tabs have rendered: that is view state, kept by the engine and keyed by node id (Geometry).

Running commands

A command is a name and a JSON payload. model.run is typed by your registry: the payload is checked against the command, and data against the component.

model.run("tab.select", { tab: "readme" });
model.run("tab.close", { tab: "readme" });
model.run("tab.add", { component: "log", data: { name: "Log" }, to: "editors", select: true });
model.run("tab.move", { tab: "log", to: "editors", location: "right" }); // split beside it
model.run("tab.update", { tab: "log", component: "log", data: { name: "Build log" } });
model.run("tabset.maximize", { tabset: "editors", value: true });

Every command returns a CommandResult, and never throws on bad input:

const result = model.run("tab.close", { tab: "readme" });
if (result.ok) {
    result.value.tab; // the command's value
} else {
    result.error.code; // "not_found", "refused", "vetoed", "invalid_payload", …
    result.error.message;
    result.error.path; // a JSON pointer into the payload, e.g. "/tab"
}

refused means a rule of the layout forbids it: a pinned tab cannot close, a tab whose enableDrag is false cannot move, a tabset with enableDrop: false takes no drop. The permission flags are enforced by the commands themselves, so code cannot bypass them either.

run is bound, so it can be passed around on its own. Inside Dockable.Root, useDockable() returns it (with the model):

import { useDockable } from "@fragiola/dockable-react";

function CloseButton({ tabId }: { tabId: string }) {
    const { run } = useDockable<Types>();
    return (
        <button type="button" aria-label="Close" onClick={() => run("tab.close", { tab: tabId })}>
            ×
        </button>
    );
}

Outside the root, call model.run directly: the model is all a command needs, mounted or not.

The rest of the bus:

  • model.dispatch({ command, payload }) runs a command given as untrusted JSON (a stored layout, a message from a server, an AI tool call): the envelope and the payload are validated before anything runs.
  • model.can(command, payload) returns what run would return, without committing or emitting anything. Enable a button with model.can("tabset.maximize", { tabset, value: true }).ok.
  • model.run("batch", { commands: [...] }) runs several commands as one atomic step: if one fails, none applies, and the batch emits a single event.
  • run(command, payload, { transient: true }) marks a step of a continuous gesture (the splitters run row.resize this way on every pointer move); { meta } passes free-form information to middleware and listeners.
  • model.commands() lists every command with its description and the JSON Schemas of its payload and result.

Every command, its payload and its errors are in the commands reference; the bus itself (ordering, re-entrancy, error codes) is in the command bus reference.

Intercepting: middleware

model.use(middleware) adds a function that runs around every command: yours, and the ones the primitives run (a click selecting a tab, a drop moving one, a splitter resizing a row). It sees a context and a next function, and can do three things:

import { type Middleware, veto } from "@fragiola/dockable";

const policy: Middleware<Types> = (ctx, next) => {
    // veto: return an error without calling next(); the "readme" tab cannot be closed
    if (ctx.command === "tab.close" && ctx.payload.tab === "readme") {
        return veto("The README stays open.");
    }
    // rewrite: assign a new payload, then call next() (it is validated again)
    if (ctx.command === "tab.add") {
        ctx.payload = { ...ctx.payload, select: true };
    }
    // observe: call next() and look at its result
    return next();
};

const remove = model.use(policy); // returns the function that removes it

Checking ctx.command narrows ctx.payload to that command's payload. The context also carries the committed state, get(id) and parentOf(id) (as the command sees them, inside a batch too), meta, transient, and dryRun.

A drag is not special. While you drag, the engine asks model.can for the command each drop target would run, on every dragover: a middleware veto hides the drop indicator over that target, and a drop runs the command through the chain again. One policy covers drags, keyboard, menus and code. See Restricting drops.

No side effects in a dry run

model.can runs the middleware chain with ctx.dryRun set to true, and the drop indicator calls it on every dragover. Log, notify or fetch only when ctx.dryRun is false.

Middleware is synchronous, and runs outermost first (the first one added wraps the others). A middleware that throws yields a middleware_error result, and nothing is committed.

Reacting: events

model.subscribe(listener) is called once per commit, with a CommandEvent: the command, its payload and result, the state before and after, transient and meta (and, for a batch, the commands it ran). It is the place to persist the layout, sync it or log it:

const unsubscribe = model.subscribe((event) => {
    if (!event.transient) {
        localStorage.setItem("layout", JSON.stringify(model.toJSON()));
    }
});

While a splitter is dragged live, the engine runs row.resize as transient on every pointer move, and each one emits an event. Skip them (as above) or debounce: the gesture ends with a normal, non-transient command. Persistence and undo/redo are both built on these events.

In React, useModelState(selector) subscribes a component to a slice of the state, and re-renders it only when that slice changes:

const count = useModelState<Types, number>((state, model) => model.tabs().length);

Reading the model

The queries read the current state; each is O(1) or a walk of the tree, and safe during render.

model.state; // LayoutState<Types>: root, borders, windows, active, maximized, defaults
model.get("readme"); // a node by id
model.parentOf("readme"); // its row, tabset or border
model.layoutOf("readme"); // MAIN_LAYOUT, or a window id
model.root(); // a layout's root row (default the main layout)
model.tabs(); // every tab, in tree order: TabOf<Types>[]
model.tabsets(); // a layout's tabsets
model.selectedTab("editors"); // a tabset's (or border's) selected tab
model.activeTabset(); // the active tabset of a layout
model.maximizedTabset(); // the maximized tabset of a layout
model.isHiddenByMaximize("tools"); // hidden because another tabset is maximized
model.resolve(tab); // a node's behaviour flags, resolved through the layout defaults
model.resolveLayout(); // the layout-wide settings, resolved

A flag a node does not set comes from the layout defaults (then from the built-in value), so read it with model.resolve(node), not from the node: model.resolve(tab).enableClose. To know whether something is allowed right now (the defaults, the rules and your middleware together), ask model.can: model.can("tab.close", { tab: tab.id }).ok.

The primitives re-render when the state changes, so reading the model inside the layout is always current. Outside it, subscribe (model.subscribe, or useSyncExternalStore over it).

Replacing the layout: layout.load

A saved layout, an undo step or a remote update replaces the whole state with one command, in place:

const result = model.run("layout.load", { layout: savedJson });
if (result.ok) {
    result.value.added; // node and window ids only in the new layout
    result.value.removed; // ids only in the old one
}

The model keeps its identity; only its state changes, so Dockable.Root keeps its engine. Nodes keep their identity by id: a tab whose id is in both layouts keeps its mounted content (its moveable element, its scroll position), wherever it moves. Only the removed tabs unmount.

layout.load validates the document like createModel, but returns the problems instead of throwing: invalid_payload with every issue, its paths under /layout. For stored text, run it through model.dispatch, which validates the whole input:

const result = model.dispatch({ command: "layout.load", payload: { layout: JSON.parse(text) } });

One model, several layouts

A model holds the main layout (MAIN_LAYOUT, "main": the root row, the borders, the active and maximized tabsets) and one window layout per popout window (model.state.windows, each with its own root row). Queries that depend on the layout take a layout id and default to the main one, and useDockable().layoutId says which layout a component renders in.

See it

Layout lab
Gallery
Command console
Gallery