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> againEvery member is one verb, and each verb takes a key and a payload, the way a command does:
| member | does |
|---|---|
state | the 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
| option | type | description |
|---|---|---|
createId | CreateId | generates 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) |
freeze | boolean | freeze 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:
CreateIdis(kind: IdKind) => string, andIdKindisNodeKind | "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.
| key | payload | returns | reads |
|---|---|---|---|
"node-by" | { id } | Node<T> | undefined | a node by its id, in O(1); check its type to narrow it |
"node-parent-by" | { nodeId } | ParentNode<T> | undefined | a node's parent: a row, a tabset or a border |
"layout-id-by" | { nodeId } | string | undefined | the layout a node is in: MAIN_LAYOUT (borders included) or a window id |
"root-row" | { layoutId? } | RowNode<T> | undefined | a layout's root row |
"window-by" | { id } | WindowLayout<T> | undefined | a popout window's layout by its id |
"windows" | none | readonly WindowLayout<T>[] | the popout windows' layouts, in the order they opened (model.state.windows) |
"borders" | none | readonly 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" | none | TabOf<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> | undefined | the 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> | undefined | a layout's active tabset |
"default-tabset" | { layoutId? } | TabsetNode<T> | undefined | the 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> | undefined | a layout's maximized tabset |
"tab-settings-by" | { tabId } | ResolvedTab | undefined | a tab's effective settings (below) |
"tabset-settings-by" | { tabsetId } | ResolvedTabset | undefined | a tabset's effective settings |
"border-settings-by" | { borderId } | ResolvedBorder | undefined | a border's effective settings |
"layout-settings" | none | ResolvedLayout | the layout-wide settings |
"layout-json" | none | LayoutJson<T> | the state as a layout document (below) |
"commands" | none | readonly 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.
| key | payload | answers |
|---|---|---|
"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].xThere 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:
| type | fields |
|---|---|
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 |
ResolvedLayout | rootOrientation, 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:
| kind | field | built-in |
|---|---|---|
tab | closable, draggable, renamable | true |
tab | poppable | false |
tab, tabset | minWidth, minHeight / maxWidth, maxHeight | 1 / 99999 |
tabset | droppable, draggable, splittable, maximizable, closable, deleteWhenEmpty, autoSelectTab | true |
border | size / minSize / maxSize | 200 / 1 / 99999 |
border | mode | "docked" |
border | autoHide, autoSelectTabWhenClosed / droppable, autoSelectTabWhenOpen | false / true |
layout | rootOrientation | "horizontal" |
layout | edgeDock / edgeDockMargin / edgeDockLength | true / 10 / 100 |
The layout's own defaults are the defaults of the document (Defaults),
changed at runtime with layout.configure.
Saving and loading
| member | description |
|---|---|
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.load | the 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' includedThe 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:
| field | type | description |
|---|---|---|
tabs | object | each tab component's data type, keyed by the component name (an interface or a type literal) |
tabset | unknown | the data type of tabsets (optional) |
border | unknown | the data type of borders (optional) |
row | unknown | the data type of rows (optional) |
AnyTypes ({ tabs: Record<string, unknown> }) is the default when you pass none: any component,
unknown data.
| type | description |
|---|---|
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> |
NodeKind | the 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.
Hooks
The hooks under the primitives. useDockable gives you the model and the engine, useModelState reads the layout, the rest wire parts into your markup.
The command bus
How the model applies commands. Run them typed or as untrusted JSON, dry-run them, wrap them in middleware, batch them and listen to every commit.