Status-aware tabs
Tabs that show what their content is doing, such as unsaved changes, errors or live counts, read from the tab's typed data.
A tab button and its content are rendered in different places (the strip and the panel layer),
so a tab cannot read its content's state through props. Share it another way. Two patterns: the
tab's own data, changed with a command, and a store of your own keyed by tab id.
1. The status in the tab's data
A tab's data is typed by its component (see Typed data). Put the
status there, next to the tab's name:
import { createModel, type LayoutJson, type TabOf } from "@fragiola/dockable-react";
type Status = "healthy" | "degraded" | "down";
type Types = {
tabs: {
monitor: { status: Status; incidents: number };
document: { dirty: boolean };
};
};
const model = createModel<Types>(json); // json: LayoutJson<Types>The content writes it with tab.set-data. data is a patch: the keys it passes replace the tab's,
the others stay, and the result is checked against the tab's component:
import { type TabNode, useDockable } from "@fragiola/dockable-react";
function Editor({ tab }: { tab: TabNode<"document", Types["tabs"]["document"]> }) {
const { model } = useDockable<Types>();
const setDirty = (dirty: boolean) => {
// only when the flag changes, not on every keystroke
if (tab.data.dirty !== dirty) {
model.run("tab.set-data", { tabId: tab.id, data: { dirty } });
}
};
// … call setDirty(true) on edit, setDirty(false) on save
}The tab reads tab.data. It is a union over the components, so data narrows on
tab.component, with no cast:
function StatusTab({ tab }: { tab: TabOf<Types> }) {
const status = tab.component === "monitor" ? tab.data.status : undefined;
const dirty = tab.component === "document" && tab.data.dirty;
return (
<Dockable.Tab node={tab} data-status={status} data-modified={dirty ? "" : undefined}>
{tab.label}
{tab.component === "monitor" && tab.data.incidents > 0 ? (
<span className="badge">{tab.data.incidents}</span>
) : null}
</Dockable.Tab>
);
}Forwarded data-* props follow the same convention as the package's own: present or absent.
Style them like the built-in state: data-modified:italic,
.tab[data-status="down"] { color: red }.
The strip re-renders with the model, so the TabList child function always sees the current
data. Because the status is in the model, it is:
- saved with the layout:
model.get("layout-json")writes it, and a restored layout shows it; - in every window: a popped-out tab reads the same model;
- a command: a middleware sees it,
model.subscribereports it, and an undo history built on the model records it (see Undo and redo).
Use it for changes a user would recognize as a step (saved, modified, failed), not for a value that ticks every second. For those, the next pattern.
2. A store keyed by tab id
Values that change often and do not belong to the layout (a live counter, a progress bar) live
in a small external store, keyed by tab.id. The content writes; the tab reads.
import { useSyncExternalStore } from "react";
type Live = { unread?: number };
const values = new Map<string, Live>();
const listeners = new Set<() => void>();
export const liveStore = {
set(id: string, value: Live) {
values.set(id, { ...values.get(id), ...value });
for (const listener of listeners) listener();
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
export function useLive(id: string): Live | undefined {
return useSyncExternalStore(liveStore.subscribe, () => values.get(id));
}// in the tab strip
function LiveTab({ tab }: { tab: TabOf<Types> }) {
const live = useLive(tab.id);
return (
<Dockable.Tab node={tab}>
{tab.label}
{live?.unread ? <span className="badge">{live.unread}</span> : null}
</Dockable.Tab>
);
}
// in the content
liveStore.set(tab.id, { unread: 3 });The content stays mounted across moves (and popouts), and a tab's id never changes within its
model, so the content keeps writing to the same key wherever its tab goes. Drop a closed tab's
entry from model.subscribe when you need to (event.command === "tab.close").
Closing a dirty tab
A middleware on tab.close can refuse to close a tab with unsaved changes. It sees every close,
from a button, a key or a drop zone, and model.can("tab.close", …) then answers false, so a
close button can hide itself:
import { veto } from "@fragiola/dockable-react";
model.use((ctx, next) => {
if (ctx.command === "tab.close") {
const tab = ctx.get("node-by", { id: ctx.payload.tabId });
if (tab?.type === "tab" && tab.component === "document" && tab.data.dirty) {
return veto("Save the document before closing it.");
}
}
return next();
});ctx.get("node-by", …) returns a node of the registry, so tab.data narrows once type and component are
checked. More on closing, and on asking the user first, in Tabs.
A live dashboard
The ops monitor example drives tab badges from streaming data (each service panel writes its alert level into its tab's data when the level changes) and keeps its incident region locked with a middleware.
Tab overflow
When a strip has more tabs than fit, the ones that do not fit go to a menu of yours and the selected tab stays in view. Or wrap and scroll.
Testing your layout
Unit-test the model in Node through its commands and middleware, and drive the rendered layout with Playwright through data-layout-path.