Documentation
Guides

Drop zones and drop feedback

Custom places to drop a tab outside the layout, and the data that tells you where a drag would land and whether it may.

A drag of the layout tells you, as data, where it would land and whether it may: the targeted tabset, the side it would dock to, the insertion point in a tab strip, and a target that a rule refused. And the drop does not have to land in the layout: a drop zone takes it and hands you the tab.

Drop zones

A Dockable.DropZone is an element that takes a drag of the layout. It can sit anywhere in the page:

Drop zones
Gallery
import { Dockable, type DragSubject } from "@fragiola/dockable-react";

<Dockable.DropZone
    model={model} // Model<Types>: the zone's drags are typed by the same registry
    // take tabs the model would close (not pinned, closable, no middleware veto)
    accepts={(drag) => drag.kind === "tab" && model.can("tab.close", { tabId: drag.tab.id })}
    onDrop={(drag) => {
        if (drag.kind === "tab") model.run("tab.close", { tabId: drag.tab.id });
    }}
    className="opacity-50 data-drop-active:opacity-100 data-drop-over:bg-palette-base"
>
    Close
</Dockable.DropZone>

accepts and onDrop receive a DragSubject<Types>, a union discriminated by kind:

kindfieldthe drag is
"tab"tab: TabOf<Types>a tab of the layout (its data narrows on tab.component)
"tabset"tabset: TabsetNode<Types>a whole tabset of the layout
"new"tab: TabInitOf<Types>a tab not created yet: a Dockable.DragSource, or a foreign drag accepted by onExternalDrag

Without accepts, a zone takes every drag of its model.

  • data-drop-active is on the zone while a drag it would take is in progress: arm it, or show it only then.
  • data-drop-over is on it while the pointer is over it. The layout hides its outline meanwhile, since the drop will not land in it.
  • onDrop receives the drag. Nothing moves by itself: run the command the zone stands for (tab.close, tab.move, tab.popout, or something of your app's) with model.run, so the model's middleware sees it. Asking model.can in accepts makes the zone arm only for drags its command would take.

A zone lives anywhere in the page, outside Dockable.Root too: it needs only the model. The useDropZone({ model, accepts, onDrop }) hook is the same thing for an element of your own.

Where the drag would land

While a drag is over the layout, the targeted tabset has data-drop-target, and data-drop-location says where the drag would dock: center, top, bottom, start or end. A drop into a tab strip also marks the TabList with data-drop-target and gives data-drop-index, the insertion point.

Drop target highlight
Gallery
<Dockable.TabSet
    node={tabset}
    className={[
        "data-drop-target:ring-2 data-drop-target:ring-palette-ring",
        "data-[drop-location=start]:shadow-[inset_4px_0_0_var(--palette-ring)]",
    ].join(" ")}
>

To draw an insertion caret between two tabs, read the same state from React: the tabset's state (useTabSet(node).state, or the TabSet's render / className function) has dropTarget, dropLocation, dropRefused and dropIndex, the insertion point while the drag aims at its strip, and a tab whose position equals dropIndex gets the caret. The state only changes when the answer for that tabset changes, so tabsets do not re-render on every pointer move.

Refused drops

Every candidate target is the command the drop would run (tab.move, tabset.move, or tab.add for a new tab), and the layout asks the model with model.can. A target is refused when the model refuses that command: a node flag (droppable, splittable, a pinned tab) or a middleware veto (see Restricting drops). Over a refused target:

  • the outline hides (DropIndicator is not visible) and the browser shows its "not allowed" cursor;
  • Dockable.Root and DropIndicator get data-drop-refused;
  • the refusing tabset gets data-drop-refused too.
model.use(lockedRegions); // a middleware that vetoes some moves

<Dockable.Root model={model}>
    {/* … */}
    <div className="hidden in-data-drop-refused:block">Not allowed here</div>
</Dockable.Root>

The locked-regions example shows a "not allowed" badge this way.