Documentation
API: core (@fragiola/dockable)

Model

The layout model is an immutable state tree created from a JSON v1 document, read with get and is, and changed only by commands with run.

Model<T> (from @fragiola/dockable) holds the layout: the main layout's tree, its borders, the popout window layouts, the layout defaults and each layout's active and maximized tabset. Its state is a tree of plain, frozen objects; a command replaces it with a new one. It needs no DOM, so layout logic runs and is testable in plain Node.

The core API pages import from @fragiola/dockable, the framework-free core. A React app imports the same names from @fragiola/dockable-react, which re-exports the whole core (Installation).

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

type Types = {
    tabs: {
        editor: { path: string; dirty: boolean };
        log: undefined;
    };
};

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

const model = createModel<Types>(json);
model.get("selected-tab-by", { tabsetId: "editors" })?.data; // typed by the registry
model.is("tabset-maximized", { tabsetId: "editors" }); // false
model.run("tab.close", { tabId: "readme" });
const saved = model.get("layout-json"); // a LayoutJson<Types> again

Every member is one verb, and each verb takes a key and a payload, the way a command does:

memberdoes
statethe current state (below)
run(command, payload, options?)changes the layout: runs a command through the middleware chain
dispatch(input, options?)run for a command given as untrusted JSON { command, payload }, validated first
can(command, payload, options?)answers whether run would succeed now: a boolean
check(command, payload, options?)returns what run would return (its value, or why it is refused), without changing anything
get(key, payload?)reads something: a node, its parent, the selected tab, settings, the JSON (Reading)
is(key, payload)asks a yes/no question (Asking)
use(middleware)adds a middleware around every command; returns the function that removes it
subscribe(listener)listens to every committed command; returns the function that removes it

Every method is bound: const { run, get } = model works. Never change the state yourself: it is frozen, and every change goes through a command (The model and commands). This page covers creating a model and reading it; run, dispatch, can, check, middleware and change events are on The command bus, the commands themselves on Commands, and the document format on Layout JSON. What the model is next to an engine: Model and engine.

Creating a model

function createModel<T extends DockableTypes = AnyTypes>(
    json?: LayoutJson<T>,
    options?: ModelOptions<T>,
): Model<T>;

createModel validates the document, fills what it omits, generates the missing ids and tidies the tree. Without a document it creates an empty layout: a root row with one empty, active tabset. An invalid document throws a LayoutValidationError listing every problem with its JSON path (Validation); for input you do not trust, check it first with validateLayout, or load it into an existing model with dispatch, which returns the problems instead of throwing.

T is your type registry (below). Pass it explicitly: get, run and middleware are typed by it.

ModelOptions

optiontypedescription
createIdCreateIdgenerates the id of a node or window created without one; default `${kind}-${n}`
dataSchemas{ [K in ComponentOf<T>]?: JsonSchema }a JSON Schema per tab component: loading, tab.add, tab.set-data, tab.set-component and layout.load validate that component's data with it (invalid_payload, or a LayoutValidationError at creation)
freezebooleanfreeze every state object (Object.freeze, deeply); default true. With false the state is not frozen, but must still never be mutated
import { createModel, type FromSchema } from "@fragiola/dockable";

const logSchema = {
    type: "object",
    properties: { maxLines: { type: "integer", minimum: 1 } },
    required: ["maxLines"],
    additionalProperties: false,
} as const;

type Types = {
    tabs: { log: FromSchema<typeof logSchema> }; // { maxLines: number }
};

const model = createModel<Types>(json, {
    createId: (kind) => `${kind}-${crypto.randomUUID()}`,
    dataSchemas: { log: logSchema },
});

Ids

Every node and window has an explicit id in the state; reading never assigns one. A node created without an id (in a document, or by tab.add, a split or a popout) gets one when it is created:

  • CreateId is (kind: IdKind) => string, and IdKind is NodeKind | "window": the kind the id is for ("row", "tabset", "tab" or "window").
  • The default generator is deterministic and needs no crypto: `${kind}-${n}` with one counter per kind (tab-1, tabset-2), skipping ids already in use.
  • A custom generator is asked again when it returns an empty string or an id in use (up to 100 times, then the default takes over).
  • A border without an id is border_<location> (there is one border per side).
  • Ids are unique across all nodes and windows, and MAIN_LAYOUT ("main") is reserved.

Ids are stable: a node keeps its id when it moves, and layout.load keeps the mounted content of every tab whose id survives. Keep references by id, not by layout path.

Reading: get

model.get(key, payload) reads the current state. The key says what it returns, and the payload says whose, so the call reads as one sentence. A key that takes an id ends in -by, and the payload's field completes it: id when it is the id of what the key returns (model.get("node-by", { id })), <entity>Id otherwise (model.get("node-parent-by", { nodeId }), model.get("selected-tab-by", { tabsetId })). A key whose only input is a layout has no -by: its layoutId is optional, MAIN_LAYOUT (the default) or a popout window's id (model.get("tabsets")). A key that takes nothing has a plain name (model.get("all-tabs")). An unknown id reads undefined.

keypayloadreturnsreads
"node-by"{ id }Node<T> | undefineda node by its id, in O(1); check its type to narrow it
"node-parent-by"{ nodeId }ParentNode<T> | undefineda node's parent: a row, a tabset or a border
"layout-id-by"{ nodeId }string | undefinedthe layout a node is in: MAIN_LAYOUT (borders included) or a window id
"root-row"{ layoutId? }RowNode<T> | undefineda layout's root row
"window-by"{ id }WindowLayout<T> | undefineda popout window's layout by its id
"windows"nonereadonly WindowLayout<T>[]the popout windows' layouts, in the order they opened (model.state.windows)
"borders"nonereadonly BorderNode<T>[]the borders, in the layout's order (model.state.borders). Borders belong to the main layout, so the key takes no layoutId
"all-tabs"noneTabOf<T>[]every tab of the model: the main layout's, its borders' and the windows', in tree order
"tabs"{ layoutId? }TabOf<T>[]the tabs of a layout (the main layout's include its borders'), in tree order
"tabsets"{ layoutId? }TabsetNode<T>[]the tabsets of a layout, in tree order
"selected-tab-by"{ tabsetId }, { borderId } or { layoutId }TabOf<T> | undefinedthe tab a tabset shows; the tab a border's open panel shows (none while it is closed); the selected tab of a layout's active tabset. Exactly one of the three, always: { layoutId: MAIN_LAYOUT } for the main layout
"active-tabset"{ layoutId? }TabsetNode<T> | undefineda layout's active tabset
"default-tabset"{ layoutId? }TabsetNode<T> | undefinedthe tabset to place into when no target is given: the layout's active tabset, else its first in tree order (never another layout's). window.close docks a window's tabs into the main layout's. A tab.add or tab.move with to: <layoutId> places at the layout's root row instead (a new tabset for center); to add into this tabset, pass its id as to
"maximized-tabset"{ layoutId? }TabsetNode<T> | undefineda layout's maximized tabset
"tab-settings-by"{ tabId }ResolvedTab | undefineda tab's effective settings (below)
"tabset-settings-by"{ tabsetId }ResolvedTabset | undefineda tabset's effective settings
"border-settings-by"{ borderId }ResolvedBorder | undefineda border's effective settings
"layout-settings"noneResolvedLayoutthe layout-wide settings
"layout-json"noneLayoutJson<T>the state as a layout document (below)
"commands"nonereadonly CommandInfo[]every command with its description and JSON Schemas (The command bus)

A payload field that names an entity reads only that kind: selected-tab-by with { tabsetId } set to a border's id reads undefined. A tab is selected and a tabset is active (Selected and active).

state is a property, not a key: model.state is the current LayoutState<T>, immutable, a new object after every change, with unchanged subtrees shared (compare with ===).

Asking: is

model.is(key, payload) answers a yes/no question about the current state: every state a primitive shows as a data-* attribute that comes from the model alone, and where a node lives. A key reads as the question after "is" (tabset-active: is the tabset active?), and its payload is the id of the entity it names first. An unknown id, or the id of another kind of node, answers false.

keypayloadanswers
"tab-selected"{ tabId }the tab is the one its tabset or border shows
"tab-pinned"{ tabId }the tab is pinned
"tabset-active"{ tabsetId }the tabset is its layout's active tabset
"tabset-maximized"{ tabsetId }the tabset is its layout's maximized tabset
"tabset-empty"{ tabsetId }the tabset has no tabs
"node-hidden-by-maximize"{ nodeId }a tabset or row is hidden because another tabset of its layout is maximized: every other tabset, and every row not on the path to the maximized one (Dockable hides them instead of moving the maximized tabset, so nothing remounts)
"node-in-window"{ nodeId }the node lives in a popout window's layout
"border-empty"{ borderId }the border has no tabs
"border-open"{ borderId }the border has a selected tab, so its panel is open
"border-overlay"{ borderId }the border's panel opens over the layout (mode: "overlay")
"row-root"{ rowId }the row is its layout's root row

The keys are typed: an unknown key or a wrong payload field does not compile, and get's result is typed by its key and your registry. The key types are exported as ModelGetKey, ModelIsKey, ModelGetMap<T> and ModelIsMap, with ModelGetPayload<T, K>, ModelGetResult<T, K> and ModelIsPayload<K>; QueryArgs<P> makes the payload optional when it has no required field, NoPayload is the payload of a key that takes none, and SelectedTabByPayload is the payload of selected-tab-by.

Nodes are plain objects with a type field ("row", "tabset", "tab", "border"), no classes and no methods. A tab of TabOf<T> narrows on component, so its data is typed:

function title(tab: TabOf<Types>): string {
    switch (tab.component) {
        case "editor":
            return tab.data.dirty ? `${tab.label} •` : tab.label;
        case "log":
            return tab.label;
    }
}

const node = model.get("node-by", { id: "editors" });
if (node?.type === "tabset") {
    node.children.map(title);
}

In React, read through useModelState or useDockable so a component re-renders when the state changes (Hooks).

Settings

A node stores only the behaviour fields it sets itself. The effective value of a field is:

node.x ?? state.defaults[kind].x ?? BUILT_IN[kind].x

There is no other inheritance: a tab does not inherit from its tabset. The *-settings keys of get apply the rule and return every field, resolved:

typefields
ResolvedTab ("tab-settings-by")closable, draggable, poppable, renamable, pinned, minWidth, minHeight, maxWidth, maxHeight
ResolvedTabset ("tabset-settings-by")droppable, draggable, splittable, maximizable, closable, deleteWhenEmpty, autoSelectTab, minWidth, minHeight, maxWidth, maxHeight
ResolvedBorder ("border-settings-by")size, minSize, maxSize, mode, autoHide, droppable, autoSelectTabWhenOpen, autoSelectTabWhenClosed, show
ResolvedLayoutrootOrientation, edgeDock, edgeDockMargin, edgeDockLength (from model.get("layout-settings"))

A resolved border's size, minSize and maxSize are its panel's effective values: the selected tab's own borderWidth (start and end borders) or borderHeight (top and bottom) replaces size, and the tab's size limits narrow minSize and maxSize. show is true unless the border sets it to false.

const tab = model.get("selected-tab-by", { tabsetId: "editors" });
if (tab && model.get("tab-settings-by", { tabId: tab.id })?.closable) {
    // render a close button
}

BUILT_IN is the last step of the rule, the frozen built-in value of every field:

kindfieldbuilt-in
tabclosable, draggable, renamabletrue
tabpoppablefalse
tab, tabsetminWidth, minHeight / maxWidth, maxHeight1 / 99999
tabsetdroppable, draggable, splittable, maximizable, closable, deleteWhenEmpty, autoSelectTabtrue
bordersize / minSize / maxSize200 / 1 / 99999
bordermode"docked"
borderautoHide, autoSelectTabWhenClosed / droppable, autoSelectTabWhenOpenfalse / true
layoutrootOrientation"horizontal"
layoutedgeDock / edgeDockMargin / edgeDockLengthtrue / 10 / 100

The layout's own defaults are the defaults of the document (Defaults), changed at runtime with layout.configure.

Saving and loading

memberdescription
model.get("layout-json")the current state as a LayoutJson<T>: a writable copy with version: 1, without the empty parts. createModel(model.get("layout-json")) gives an equal state
toLayoutJson(state)the same for any state: one kept from before (event.before, an undo step), to load it back
layout.loadthe command that replaces the whole layout in place with a document

layout.load keeps the model (and so your middleware, listeners and the rendered view) and replaces its state. A node is the same node when its id is in both states, so the tabs whose ids survive keep their mounted content; the result lists the addedNodeIds and removedNodeIds.

import { toLayoutJson } from "@fragiola/dockable";

// restore something you saved (validated like any untrusted JSON)
const restored = model.dispatch({
    command: "layout.load",
    payload: { layout: JSON.parse(text) },
});
if (!restored.ok) console.warn(restored.error.issues);

// go back to a state you kept
const previous = model.state;
model.run("layout.load", { layout: toLayoutJson(previous) });

Saving and restoring and Undo and redo build on these.

ModelHandle

The part of a model that does not depend on its registry: dispatch(input, options?), get("commands") and get("layout-json") (the document as unknown). Every Model<T> is a ModelHandle. APIs that join models of different registries take one: a drag group's transfers name their from and to models as ModelHandles; compare them with your own models by reference (===).

MAIN_LAYOUT

MAIN_LAYOUT is "main", the id of the main layout: the state's root, active, maximized and its borders. A popout window's layout id is its window's id. Every get key whose only input is an optional layoutId (tabs, tabsets, root-row, active-tabset, default-tabset, maximized-tabset) and every primitive that takes a layout id defaults to it, and no node or window may use it as an id.

import { MAIN_LAYOUT } from "@fragiola/dockable";

for (const windowLayout of model.get("windows")) {
    model.get("tabs", { layoutId: windowLayout.id }); // the tabs of one popout window
}
model.get("tabs", { layoutId: MAIN_LAYOUT }); // the main layout's tabs, its borders' included

The type registry

The model, its commands and the React primitives are generic over one type, the registry that says what data each tab component holds (Typed data):

type Types = {
    tabs: {
        editor: { path: string; dirty: boolean };
        log: undefined;
    };
    tabset: { name: string };
};

DockableTypes is what a registry must satisfy:

fieldtypedescription
tabsobjecteach tab component's data type, keyed by the component name (an interface or a type literal)
tabsetunknownthe data type of tabsets (optional)
borderunknownthe data type of borders (optional)
rowunknownthe data type of rows (optional)

AnyTypes ({ tabs: Record<string, unknown> }) is the default when you pass none: any component, unknown data.

typedescription
ComponentOf<T>the component names of a registry ("editor" | "log")
TabDataOf<T, K>the data type of component K
TabsetDataOf<T>the data type of tabsets (unknown when the registry declares none)
BorderDataOf<T>the data type of borders
RowDataOf<T>the data type of rows
TabOf<T>a tab of the registry: a union of TabNode<K, TabDataOf<T, K>> over its components, so data narrows on component
TabInitOf<T>a new tab of the registry (JSON, tab.add, a drag source): a union of TabInit<K, D>
DataField<D>{ data?: D } when D allows undefined, { data: D } otherwise: data is required exactly when its type requires it
Node<T>any node: RowNode<T> | TabsetNode<T> | TabOf<T> | BorderNode<T>
NodeKindthe kinds of node: "row" | "tabset" | "tab" | "border"
ParentNode<T>what a node can be the child of: RowNode<T> | TabsetNode<T> | BorderNode<T>
TabContainer<T>what holds tabs: TabsetNode<T> | BorderNode<T>
FromSchema<S>the TypeScript type a JSON Schema of the supported subset describes (declare it as const); it does not follow $ref

FromSchema lets one schema be both the runtime check (dataSchemas) and the data type, as in the options example. The node types themselves (TabNode, TabsetNode, RowNode, BorderNode, WindowLayout, LayoutState) are on Layout JSON.