Restricting drops
Decide what can be dragged where with node flags and a middleware veto, which the drag also asks through model.can.
Two mechanisms decide what can be dragged and where it can land, and they work together: the
flags on nodes, which the commands enforce, and middleware (model.use), which can veto
any command. A drag uses both the same way: for every target under the pointer it builds the
command the drop would run and asks model.can, so a refused target never shows the outline, and
a drop (or a command from your own code) is refused by the same rules.
1. Flags
Set them per node in the JSON, or for every node of a kind through the layout's defaults
(defaults.tab.draggable, defaults.tabset.droppable, …):
| field | on | effect |
|---|---|---|
draggable | tab | whether the tab can move (tab.move refuses it, and the Tab is not draggable when false) |
draggable | tabset | whether the whole tabset can move (tabset.move) |
droppable | tabset, border | whether tabs can be dropped into it (a center drop) |
splittable | tabset | whether dropping on an edge of this tabset splits it to create a new tabset |
closable | tabset | false also refuses merging the whole tabset into another one |
pinned | tab | a pinned tab cannot be dragged out of its tabset, nor into a border |
poppable | tab | false refuses moving the tab into a popout window |
edgeDock | defaults.layout | whether a drag offers docking to the edges of the layout |
{
"type": "tabset",
"id": "console",
"droppable": false,
"splittable": false,
"children": [{ "component": "console", "label": "Console", "draggable": false }]
}Change them at runtime with commands: tabset.configure { tabsetId, droppable: false } for one
tabset (a "lock layout" toggle), tab.configure for a tab, border.configure for a border, or
layout.configure { defaults: { tabset: { droppable: false } } } for all. A null value
removes a node's own value, so the default applies again.
2. A middleware veto
For rules that depend on what is placed and where, add a middleware. Every drop runs one of three commands, so the rule guards those:
tab.move{ tabId, to, location, index }: a tab of the layout;tabset.move{ tabsetId, to, location, index }: a whole tabset (dragged by its header);tab.add{ component, data, …, to, location, index }: a new tab (a drag source, a foreign drag, or a transfer from another layout).
to is the target's id: a tabset, a border, or a row (a drop at the layout's edge is to its root
row, with an edge location; code may also pass a layout id such as MAIN_LAYOUT). Return veto(message) to refuse, or next() to let it through:
import {
createModel,
type Middleware,
type TabAddPayload,
type TabOf,
veto,
} from "@fragiola/dockable-react";
type Types = {
tabs: { doc: { group: "tools" | "docs" }; console: undefined };
tabset: { kind: "tools" | "docs" };
};
const toolsStayTogether: Middleware<Types> = (ctx, next) => {
// what is placed: an existing tab (tab.move) or a new one (tab.add)
let tab: TabOf<Types> | TabAddPayload<Types> | undefined;
let to: string;
if (ctx.command === "tab.move") {
const node = ctx.get("node-by", { id: ctx.payload.tabId });
tab = node?.type === "tab" ? node : undefined;
to = ctx.payload.to;
} else if (ctx.command === "tab.add") {
tab = ctx.payload; // the new tab itself: component, data and placement
to = ctx.payload.to;
} else {
return next();
}
const target = ctx.get("node-by", { id: to });
// tabs of the "tools" group only go into tools tabsets
if (tab?.component === "doc" && tab.data.group === "tools") {
return target?.type === "tabset" && target.data?.kind === "tools"
? next()
: veto("Tools stay in a tools tabset");
}
return next();
};
const model = createModel<Types>(json);
model.use(toolsStayTogether); // returns the function that removes itctx.command narrows ctx.payload, and ctx.get("node-by", …) / ctx.get("node-parent-by", …) read the
layout as the command sees it. A middleware belongs to the model: install it once, right after createModel. Loading
another layout (layout.load, an undo step, a
restore) keeps the same model, so the rule stays.
How the drag asks
During a drag, the layout builds the command each target would run and asks model.can, which
runs your middleware with ctx.dryRun === true and commits nothing. A middleware must not cause
side effects in a dry run (no dialog, no state change): check ctx.dryRun before doing anything
but deciding. The answer is kept for the rest of the drag while the layout does not change, so a
rule should depend on the command and the layout, not on something that changes mid-drag.
Over a refused target, the drop indicator hides (or moves to the next target that accepts), and a
drop does nothing. The real drop runs the command through model.run, so your middleware sees it
once more, this time with ctx.dryRun === false: a veto there still stops it.
Rules from code
The same middleware refuses a command run from code, and the result says why:
const result = model.run("tab.move", { tabId: "notes", to: "reference" });
if (!result.ok) console.log(result.error.code, result.error.message); // "vetoed", "Tools stay in …"A rule the layout itself enforces (a flag, a pinned tab) comes back as refused; a middleware
veto as vetoed. More on middleware in the command bus.
Keeping the main layout filled
The main layout always keeps a tabset, but nothing keeps a tab in it: drag every tab into a border
or a popout and the main area is an empty tabset. That is by design, since some apps want it. If
yours does not, veto the move that would empty it. A middleware can call next() first and look
at the result: after next(), ctx.get reads the layout as the command left it, and a veto
returned then commits nothing.
import { type Middleware, type Node, veto } from "@fragiola/dockable-react";
/** whether a row or a tabset holds a tab, at any depth */
const holdsTabs = (node: Node<Types> | undefined): boolean =>
node?.type === "tabset"
? node.children.length > 0
: node?.type === "row"
? node.children.some(holdsTabs)
: false;
const moves = new Set(["tab.move", "tabset.move", "tab.popout", "tabset.popout"]);
const keepMainFilled: Middleware<Types> = (ctx, next) => {
if (!moves.has(ctx.command)) return next();
const root = { id: ctx.state.root.id };
const before = holdsTabs(ctx.get("node-by", root));
const result = next();
// the main layout as the move left it: a veto now commits nothing
return result.ok && before && !holdsTabs(ctx.get("node-by", root))
? veto("The main layout keeps at least one tab")
: result;
};
model.use(keepMainFilled);The drag asks the same rule, so a border refuses the last tab of the main layout while it hovers,
and model.can("tab.move", { tabId, to: "border_start" }) answers false for it. Closing the last
tab is not a move, so it still works; add "tab.close" to the set to keep that one too.
Feedback for a refused target
Over a target that a rule refuses, the outline hides, the browser shows its "not allowed" cursor,
and Dockable.Root, DropIndicator and the refusing TabSet (or Border) get
data-drop-refused. Style them to say why: see
Drop zones and drop feedback and the locked-regions
example above, where a refusing tabset turns red with a lock over its whole area. The overlay is a
child of the TabSet with a z-index (panels are portalled after the tabsets), shown by the
tabset's own attribute: the root has data-drop-refused too, so read it through a group on the
tabset rather than any ancestor.