Typed data
Declare what each tab component holds once, in a Types registry, and the JSON, the commands and the render functions are all typed by it, with no casts.
A tab names a component (what it shows) and carries data (its state: a file path, a chart's series, its name). Dockable never reads the data: it is yours. You declare its type once, in a registry, and everything that touches a tab is typed by it: the layout JSON, the command payloads, the queries and the functions you render with.
The registry
A registry maps each tab component to the type of its data. It can also type the optional data
of tabsets, borders and rows:
type Types = {
tabs: {
editor: { name: string; path: string; dirty: boolean };
chart: { name: string; series: string[] };
log: { name: string };
};
tabset: { name?: string };
};That is all a registry is: a type, with nothing to register at runtime. An interface works as
well as a type literal. It satisfies DockableTypes; without one, the default is AnyTypes
(any component name, unknown data).
Typing the model
Pass the registry to createModel, and the layout JSON is checked against it:
import { createModel, type LayoutJson } from "@fragiola/dockable";
const json: LayoutJson<Types> = {
version: 1,
root: {
type: "row",
children: [
{
type: "tabset",
id: "main",
data: { name: "Editors" },
children: [{ component: "editor", data: { name: "a.ts", path: "/a.ts", dirty: false } }],
},
],
},
};
const model = createModel<Types>(json); // Model<Types>A tab whose data does not match its component, or a component the registry does not name, is a
compile error. data is required on a tab whose data type does not accept undefined, and
optional otherwise.
Narrowing tab.data
A tab of the registry is a TabOf<Types>: a union with one member per component, discriminated by
component. Checking the component narrows the data:
import type { TabOf } from "@fragiola/dockable";
function title(tab: TabOf<Types>): string {
switch (tab.component) {
case "editor":
return tab.data.dirty ? `${tab.data.name} •` : tab.data.name; // the editor's data
case "chart":
return `${tab.data.name} (${tab.data.series.length})`; // the chart's data
case "log":
return tab.data.name;
}
}Every query returns typed tabs (model.tabs(), model.selectedTab(id), a tabset's children),
and so does every children function of the React parts. A tab of one known component is a
TabNode<K, D>: TabNode<"editor", Types["tabs"]["editor"]>.
The same switch is the component factory: it picks what a panel renders.
function TabContent({ tab }: { tab: TabOf<Types> }) {
switch (tab.component) {
case "editor":
return <Editor path={tab.data.path} />;
case "chart":
return <Chart series={tab.data.series} />;
case "log":
return <Log />;
}
}Typed payloads
The commands are typed by the same registry. tab.add takes a component and its data, and
tab.update replaces a tab's data with a new value for its component:
model.run("tab.add", { component: "chart", data: { name: "Sales", series: [] }, to: "main" });
// @ts-expect-error: `path` is not in the chart's data
model.run("tab.add", { component: "chart", data: { name: "Sales", path: "/x" }, to: "main" });
const tab = model.selectedTab("main");
if (tab?.component === "editor") {
// `data` is the whole new value, not a patch: keep the rest of it
model.run("tab.update", { tab: tab.id, component: "editor", data: { ...tab.data, dirty: true } });
}tabset.configure, border.configure and row.configure take data typed by the registry's
tabset, border and row entries. The types behind them are exported for your own helpers:
PayloadOf<Types, "tab.add"> is a command's payload, ResultOf<Types, "tab.add"> its result, and
TabInitOf<Types> a new tab (a component and its data), handy for templates:
import type { TabInitOf } from "@fragiola/dockable";
const newChart: TabInitOf<Types> = { component: "chart", data: { name: "Chart", series: [] } };
model.run("tab.add", { ...newChart, to: "main", select: true });Validating data at runtime
Types vanish at runtime. A layout restored from storage, a payload from a server or a tool call
from an AI assistant is untrusted: its data may not match what your components expect. Register a
JSON Schema per component with dataSchemas, and the model validates data wherever it enters:
import { createModel, type JsonSchema } from "@fragiola/dockable";
const dataSchemas: { [K in keyof Types["tabs"]]: JsonSchema } = {
editor: {
type: "object",
properties: { name: { type: "string" }, path: { type: "string", minLength: 1 }, dirty: { type: "boolean" } },
required: ["name", "path"],
},
chart: {
type: "object",
properties: { name: { type: "string" }, series: { type: "array", items: { type: "string" } } },
required: ["name", "series"],
},
log: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
};
const model = createModel<Types>(json, { dataSchemas });createModelthrows aLayoutValidationErrorlisting every tab whose data fails, with its JSON path (validateLayout(json, { dataSchemas })returns the same issues without throwing).tab.add,tab.updateandlayout.loadreturninvalid_payload, with the path of the offendingdata. Nothing changes.
The schemas use the JSON Schema subset the model's validator supports (type, properties,
required, items, enum, const, anyOf, oneOf, minimum, minLength, …).
Why names, icons and class names live in data
The model holds the layout: which tabs exist, where they are, what they allow. It holds no text and no styling. A tab's name is text your app renders (and translates); an icon, a help text or a class name is how your app draws the tab. So they are fields of your data, typed by your registry, and your markup reads them:
<Dockable.TabList<Types> aria-label={tabset.data?.name ?? "Tabs"}>
{(tab) => (
<Dockable.Tab node={tab} className={tab.component === "log" ? "tab tab-log" : "tab"}>
<TabIcon component={tab.component} />
{tab.data.name}
</Dockable.Tab>
)}
</Dockable.TabList>The same goes for UI permissions the model does not enforce: whether a tab may be renamed, or shows
a pin item in its menu, is a field of its data (renamable: true), and renaming it is tab.update
with the new name. Permissions the model does enforce (enableClose, enableDrag,
enablePopout, pinned, a tabset's enableDrop, …) stay node fields, because the commands check
them.
The type argument on children functions
Dockable.Root takes a Model<Types>, but a child cannot infer the root's registry through React
context. The parts that hand nodes to a children function take it as a type argument:
<Dockable.Root model={model}>
<Dockable.Row<Types>>{renderNode}</Dockable.Row>
<Dockable.Panels<Types>>
{(tab) => (
<Dockable.Panel node={tab}>
<TabContent tab={tab} />
</Dockable.Panel>
)}
</Dockable.Panels>
<Dockable.Popout<Types>>{() => <Dockable.Row<Types>>{renderNode}</Dockable.Row>}</Dockable.Popout>
</Dockable.Root>The same holds for Dockable.TabList<Types>, Dockable.Borders<Types>, and the hooks:
useDockable<Types>() returns a Model<Types> and a typed run, and
useModelState<Types, S>(selector) a typed state. Without the argument, a part uses AnyTypes:
it still works, and tab.data is unknown.
Zero casts
With a registry, a layout needs no as anywhere:
- the JSON is a
LayoutJson<Types>, checked where you write it; tab.datanarrows ontab.component, in queries, children functions and listeners;- command payloads are checked against the command and the component, and a middleware's
ctx.payloadnarrows onctx.command; - the
renderprop'srefis a callback ref that fits any element, sorender={(props) => <section {...props} />}needs no cast either.
When data comes from outside your code, validate it (dataSchemas, validateLayout,
model.dispatch) instead of casting it: the check is then real at runtime too.