Documentation
Guides

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.

Locked regions
Gallery

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, …):

fieldoneffect
draggabletabwhether the tab can move (tab.move refuses it, and the Tab is not draggable when false)
draggabletabsetwhether the whole tabset can move (tabset.move)
droppabletabset, borderwhether tabs can be dropped into it (a center drop)
splittabletabsetwhether dropping on an edge of this tabset splits it to create a new tabset
closabletabsetfalse also refuses merging the whole tabset into another one
pinnedtaba pinned tab cannot be dragged out of its tabset, nor into a border
poppabletabfalse refuses moving the tab into a popout window
edgeDockdefaults.layoutwhether 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 it

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