Adding and closing tabs
Add, close, rename and pin tabs with commands on the model, and build the buttons, icons and text yourself.
Every tab operation is a command run on the model (model.run), through its middleware. The
package gives you the commands and the node state; the buttons, icons and text are yours.
The snippets on this page share one registry: each tab component and the type of its data
(see Typed data). A tab's name is not data: every tab has a label,
a string the model keeps and your markup renders ({tab.label}); the packages never render it.
type Types = {
tabs: {
editor: { path: string; dirty?: boolean };
chart: undefined;
};
};Adding a tab
tab.add creates a tab from its component, its label and its data (checked against the
registry) and places it with to, location, index and select:
tois a tabset, a border, a row, or a layout id (MAIN_LAYOUT, meaning its root row);location"center"(the default) adds the tab to that tabset or border atindex(-1, the default, appends it); an edge ("top","bottom","start","end") of a tabset splits it and puts the tab in a new tabset beside it, and an edge of a layout docks a new tabset along that edge;selectforces the selection; without it the target'sautoSelectTabrule decides.
The result is a CommandResult: { ok: true, value: { tabId } } with the new tab's id, or
{ ok: false, error } saying why nothing was added.
import { MAIN_LAYOUT, useDockable } from "@fragiola/dockable-react";
function AddTabButton() {
const { model } = useDockable<Types>();
const add = () => {
// the tabset the user last pressed in, else the first one
const target = model.get("default-tabset");
model.run("tab.add", {
component: "chart",
label: "Untitled", // a chart has no data
to: target?.id ?? MAIN_LAYOUT,
select: true,
});
};
return (
<button type="button" onClick={add}>
New chart
</button>
);
}useDockable works anywhere inside Dockable.Root, including a toolbar you render as a child of
the root or inside a tabset header. A "+" button per tabset uses that tabset's id instead of the
active one. Outside the root, call model.run on the model you created: no engine is needed.
Closing a tab
tab.close removes a tab. It is refused (error.code === "refused") for a pinned tab and for
one whose closable resolves to false (on the tab, or through defaults.tab.closable). Ask
before rendering the button: model.can answers whether run would succeed, middleware included,
without changing anything (model.check returns what it would return).
import { Dockable, type TabOf, useDockable } from "@fragiola/dockable-react";
function CloseButton({ tab }: { tab: TabOf<Types> }) {
const { model } = useDockable<Types>();
if (!model.can("tab.close", { tabId: tab.id })) return null;
return (
<button
type="button"
// the tab is the tab stop: Ctrl+Delete on it closes it from the keyboard
tabIndex={-1}
aria-label={`Close ${tab.label}`}
// keeps the press from activating the tabset
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation(); // a click on the tab would select it
model.run("tab.close", { tabId: tab.id });
}}
>
<XIcon aria-hidden="true" />
</button>
);
}
<Dockable.Tab node={tab}>
{tab.label}
<CloseButton tab={tab} />
</Dockable.Tab>A focused tab also closes with Ctrl+Delete (the closeTab binding of the
key map) when model.can accepts it; the tab moves focus to a neighbour
first.
When the last tab of a tabset closes, the tabset is removed, unless its deleteWhenEmpty or
closable resolves to false, or it is the main layout's last tabset (that one stays, empty, so
there is always somewhere to add a tab). tabset.close closes a whole tabset: its closable tabs close, and
the tabset goes once it is empty (the result lists the closed ids).
Confirming a close
A rule that must hold however the close was issued (a button, a menu, the keyboard, your own code)
is a middleware. This one vetoes closing a modified editor, asks, and runs the close again once
the user agrees, marked by meta:
import { veto } from "@fragiola/dockable-react";
model.use((ctx, next) => {
// a dry run (`model.can`) passes: the close is offered, and asked about when it runs
if (ctx.command !== "tab.close" || ctx.dryRun || ctx.meta?.confirmed === true) {
return next();
}
const tab = ctx.get("node-by", { id: ctx.payload.tabId });
if (tab?.type !== "tab" || tab.component !== "editor" || !tab.data.dirty) {
return next();
}
const id = tab.id;
void askToDiscard(tab.label).then((ok) => {
if (ok) model.run("tab.close", { tabId: id }, { meta: { confirmed: true } });
});
return veto("the file has unsaved changes");
});ctx.command narrows ctx.payload (here to { tabId: string }), and ctx.get("node-by", …) reads nodes
as the command sees them. The IDE workbench does this with a dialog. More on
middleware in the command bus.
Renaming a tab
A tab's name is its label, so renaming is one command, whatever the tab's component:
tab.rename with the new label. Nothing else of the tab changes. Whether a tab may be renamed is
the tab's renamable (true unless the tab or defaults.tab sets it to false), and tab.rename
refuses a tab that is not; ask model.can before you open your rename UI. What a valid name is
stays yours (here, no empty name).
if (model.can("tab.rename", { tabId: tab.id, label: tab.label })) {
startRename(tab);
}
// on commit
if (value.trim() !== "") {
model.run("tab.rename", { tabId: tab.id, label: value.trim() });
}The editing UI is yours: an input that replaces the tab's label on double click (or F2),
committing on Enter or blur. The input lives inside the Tab, so stop its key and pointer events
from reaching the tab (the tab handles arrows, Enter and Space, and starts drags on pointer down).
Pinning a tab
tab.pin with value: true pins a tab of a tabset (a tab in a border is refused). A pinned tab
moves to the end of the pinned run at the start of its strip, cannot be closed (tab.close refuses
it) and cannot be dragged out of its tabset. The tab carries data-pinned, so pinned tabs can
look different (icon-only, no close button).
model.run("tab.pin", { tabId: tab.id, value: tab.pinned !== true });Whether a tab offers pinning at all is your choice; keep it in the tab's data if it varies.
Selecting a tab from code
tab.select selects a tab (a click or Enter on the tab runs it). In a tabset, the tabset also
becomes the active one; in a border, the border's panel opens. tabset.activate makes a tabset
active without changing its selection.
Code outside the layout follows the selection the same way it changes it: through the model.
Ask which tab the user is looking at, the selected tab of the default tabset (the active one,
else the first), with useModelState and the model: it works outside Dockable.Root, and
re-renders the controls only when the answer changes, never the layout beside them:
function Controls({ model }: { model: Model<Types> }) {
const current = useModelState(
(_state, m) => {
const tabset = m.get("default-tabset");
return tabset ? m.get("selected-tab-by", { tabsetId: tabset.id }) : undefined;
},
{ model },
);
// …
}Keep what outside controls change in the tab's data (a counter here), and tab.set-data it: the
tab and the controls both read it, so a click inside the tab and one outside stay in sync.
Tab data and behaviour
Three commands change an existing tab, each taking only what changes:
tab.configurechanges the tab's own fields: its behaviour flags and its size limits (closable,draggable,poppable,renamable,minWidth, …). A key you leave out stays as it is. Anullflag or limit removes the tab's own value, so the layout default applies again. Itslabelchanges withtab.rename(above).tab.set-datachanges some of itsdata: a shallow patch, whose keys replace the tab's while the others stay ({ tabId, data: { dirty: true } }).tab.set-componentswitches it to another component, with that component's wholedata.
With dataSchemas registered on createModel, the resulting data is validated first.
model.run("tab.configure", { tabId: tab.id, closable: false });
model.run("tab.set-data", { tabId: tab.id, data: { dirty: true } });
model.run("tab.set-component", { tabId: tab.id, component: "chart" }); // a chart has no dataThe command reference lists every payload field.
All together
The IDE workbench combines all of it: new file, close with a dirty check, rename, pin.