Documentation
Guides

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:

  • to is 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 at index (-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;
  • select forces the selection; without it the target's autoSelectTab rule 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.

Add tabs
Gallery

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).

Close tabs
Gallery

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).

Rename tabs
Gallery

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.

Pinned tabs
Gallery

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.

External tab switcher
Gallery

Tab data and behaviour

Three commands change an existing tab, each taking only what changes:

  • tab.configure changes 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. A null flag or limit removes the tab's own value, so the layout default applies again. Its label changes with tab.rename (above).
  • tab.set-data changes some of its data: a shallow patch, whose keys replace the tab's while the others stay ({ tabId, data: { dirty: true } }).
  • tab.set-component switches it to another component, with that component's whole data.

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 data

The command reference lists every payload field.

All together

The IDE workbench combines all of it: new file, close with a dirty check, rename, pin.

IDE workbench
Gallery