dashfoo

Drag and dock

How dragging a tab or a whole tabset resolves to a stack or a directional split. The landing zones, the pipeline, and the gates. Pointer-only.

Two things can be dragged in dashfoo: a single tab, picked up from its button in the strip, and a whole tabset, picked up from the grip in its toolbar. Both share the same landing zones, and which outcome fires depends entirely on where the pointer is when you let go:

  • Over a tab strip. The drop stacks into that tabset. A tab lands at a specific insertion index, with a thin insertion line marking the slot; a tabset merges all of its tabs onto the end of the target strip.
  • Over a tabset body. The drop splits the tabset, creating a new region to the left, right, top, or bottom.

This guide explains how the pointer position resolves to one of those outcomes, the pipeline that carries a drag from the internal drag hooks to a committed model change, and the gates that turn each outcome on or off.

The geometry lives in @dashfoo/core (resolveDockTarget) and @dashfoo/react (the drag adapter). The drag adapter is the only module in the library that touches @dnd-kit. It drives the framework-agnostic @dnd-kit/dom core imperatively (no React bindings). Everything it touches downstream is pure and unit-tested.

Pointer-only. Drag-docking uses a pointer sensor; there is no keyboard drag. The arrow keys are already bound to roving-tabindex tab navigation, and dnd-kit's keyboard nudge model conflicts with that, so keyboard docking would need its own interaction design. All other chrome (select, close, rename, maximize, overflow) stays fully keyboard-operable.

The two landing zones

Every drop resolves to one DockLocation, the union the reducer understands:

type DockLocation = "center" | "split-left" | "split-right" | "split-top" | "split-bottom";

center stacks. split-* splits. The pointer's position inside a tabset's rect picks which one.

Over a tab strip: stack at an insertion index

When the pointer is inside the element marked data-dashfoo="tabstrip", the drop is always a stack. The adapter gathers the strip's data-dashfoo="tab" rects (excluding the dragged tab itself) and the pure math in tab-insertion.ts finds the first tab whose horizontal midpoint sits to the right of the pointer:

const insertionIndex = (rects: ReadonlyArray<Rect>, pointerX: number): number => {
  const found = rects.findIndex((rect) => pointerX < rect.x + rect.width / 2);
  return found === -1 ? rects.length : found;
};

If the pointer is past every midpoint, the index is the end of the strip.

The dragged tab excludes itself from this measurement. Its own slot never counts toward the order, so the index is measured against the tabs it will land among, not the ones currently rendered. The same index is used at commit time; see Why the index needs no adjustment.

The resulting intent carries location: "center", the target tabset id, and the computed index:

{ index: insertionIndex(tabRects(strip, draggedId), point.x), location: "center", targetId: id }

The on-screen feedback is a thin vertical line (insertionLineRect) centered on the slot boundary, spanning the strip's height and clamped so it never leaves the strip. The slot is measured against whole tab items (label plus close button), so the "after the last tab" position sits past the last close button rather than between the label and the close.

For a tabset dragged by its grip, a strip drop still resolves to center, but the index does not apply: the committed moveTabset appends every tab from the dragged tabset to the end of the target strip. See Dragging a whole tabset.

Over a tabset body: split in a direction

Below the strip, the tabset's own rect decides between stacking and splitting. resolveDockTarget in @dashfoo/core measures the pointer's fractional distance from each of the four edges. If the pointer is inside the central area, the drop stacks; if it lands within the outer band (22% of the rect by default), it splits toward the closest edge:

const resolveDockTarget = (pointer: Point, rect: Rect, opts?: BandOptions): DockLocation => {
  const band = opts?.bandFraction ?? DEFAULT_BAND_FRACTION; // 0.22
  // a zero-size tabset has no meaningful edges; dividing by its width/height
  // yields NaN distances that would silently resolve to a bogus split.
  if (rect.width <= 0 || rect.height <= 0) {
    return "center";
  }
  const distances = edgeDistances(pointer, rect);
  const min = Math.min(distances.left, distances.right, distances.top, distances.bottom);

  if (min > band) {
    return "center";
  }
  return `split-${closestEdge(distances)}`;
};

In a corner, the nearer of the two edges wins. "center" stacks at the end of the tabset; split-${edge} splits toward that edge. The result is already a DockLocation, the same vocabulary zoneRect and dockZonePolygons speak, so nothing has to convert it.

Canvas×Detail×
split-left
split-top
split-right
centerstack into the tab strip
split-bottom
  • center
  • split-left
  • split-right
  • split-top
  • split-bottom
The five dock locations over a tabset. In the real hit-test the seams between zones run diagonally from each corner, so in a corner the nearer edge wins; dropping on the tab strip itself is also a center drop, inserted at the pointed-at slot.

A center drop over the body appends rather than inserting at a mid-strip slot. The adapter sets index to the count of remaining tabs so the tab lands last.

The indicator for a split highlights the matching half of the tabset: split-left paints the left half, split-top the top half, and so on (zoneRect).

To see or paint the whole partition at once, dockZonePolygons(rect) enumerates the five hit regions as polygons: the inner center rect and four edge trapezoids whose seams run diagonally from each rect corner to the matching inner corner. It shares the band default with resolveDockTarget, so a map painted from the polygons always agrees with the live hit-test. The demo's Docking page uses it (with useDropIntent) for a drop-zone visualization that paints every candidate region during a drag.

Dragging a whole tabset

Each tabset renders a grip button in its toolbar (data-dashfoo="tabset-grip", aria-label="Move tabset"). Dragging the grip picks up the entire tabset, tabs and all, and resolves against the same landing zones. The drag-preview chip is labeled with the tabset's active tab name.

The grip is controlled by the draggableTabsets prop on <DashfooLayout>. It defaults to true; set it to false and the grip is not rendered, so whole-tabset dragging is off. The grip is also hidden while the tabset is maximized.

A grip drop commits a moveTabset action (a tab drop commits moveNode). The reducer treats the two locations differently:

  • center merges: every tab from the dragged tabset is appended to the end of the target tabset, and the now-empty source tabset is removed. The insertion index is ignored.
  • split-* moves: the whole tabset is detached and placed beside the target, reusing the parent row when the orientation already matches, otherwise wrapping both in a new row, the same placeBesideTarget path a tab split uses.

Dropping a tabset onto itself is a no-op; the adapter suppresses the intent before the machine ever sees it, and the reducer guards sourceId === targetId independently.

Internally the grip registers with dnd-kit under the id grip-<tabsetId>, so it never collides with the tabset's own registered id; the real tabset id rides in the draggable's data and becomes the moveTabset source.

Dragging in from outside

Both drags above move nodes that already live in the model. External drag sources add a third kind: an element outside the layout (a widget list, a palette) that inserts a new tab when dropped on a tabset.

Wrap the layout and the sources in DashfooDragProvider so they share one drag manager and one drag lifecycle, and register each source with useExternalTabSource:

import { createTabId, tab } from "@dashfoo/core";
import { DashfooDragProvider, DashfooLayout, useExternalTabSource } from "@dashfoo/react";

const WidgetCard = ({ component, name }: { component: string; name: string }) => {
  const { ref } = useExternalTabSource({
    createTab: () => tab(component, name, { id: createTabId() }),
    label: name,
  });
  return <div ref={ref}>{name}</div>;
};

const App = () => (
  <DashfooDragProvider>
    <WidgetCard component="metrics" name="Metrics" />
    <DashfooLayout defaultModel={model} components={registry} />
  </DashfooDragProvider>
);

An external drag resolves against the same landing zones (stack on a strip or body, split on an edge), but the drop commits an addNode action carrying the TabNode that createTab returned, instead of a moveNode. createTab runs once per drag (at drag start) and must mint a fresh id each call; the result is validated against the tab schema, and an invalid node warns and cancels the drag. A drop outside any tabset is a no-op.

The demo's docking page pairs each draggable card with an "Add" button that calls addTab on the imperative handle. Pointer drag is the only drag input, so the button is the keyboard path.

How resolution layers

For a pointer over a tabset, the adapter's resolveIntent runs the checks in a fixed order:

  1. No-op filter. A tabset dragged onto itself, or the sole tab of a tabset dropped back onto that same tabset, resolves to no intent at all (shouldAllowDrop). The drop changes nothing once empties collapse, so the indicator never appears.
  2. Tab strip. Inside the strip, return a center stack at the insertion index.
  3. Tabset body. Otherwise resolve center (append) or split-* from the tabset rect.
const resolveIntent = (
  targetId: string,
  element: HTMLElement,
  point: Point,
  draggedId?: string,
): DropIntent | null => {
  const tabIds = [...element.querySelectorAll<HTMLElement>('[data-dashfoo="tab"]')].map(
    (tab) => tab.dataset.tabId ?? "",
  );
  if (!shouldAllowDrop(draggedId, targetId, tabIds)) {
    return null;
  }
  const intent = intentForTabset(targetId, element, point, draggedId);
  // When splitting is disabled, a drop over the body stacks instead of splits.
  if (!splitDock && intent.location.startsWith("split-")) {
    return { location: "center", targetId };
  }
  return intent;
};

The pipeline

A drag travels through five stages. The split is deliberate: the @dnd-kit/dom pointer sensor owns input, dragDockMachine owns the interaction lifecycle and never touches the document, and the reducer owns the model.

drag hooks      →  dnd-kit/dom  →  dragDockMachine  →  COMMIT        →  reducer
  (tab + grip +    (pointer +      (OVER / DROP        (moveNode |      (action
   external         hit-test)       lifecycle)          moveTabset |     applied)
   draggables)                                          addNode)

1. The drag hooks mark the sources

Three internal hooks in drag-hooks.tsx wire the DOM to the drag system. None of them is exported from @dashfoo/react. They are described here to explain the flow, not as API.

  • useTabDraggable registers each tab button as a @dnd-kit/dom Draggable, created imperatively in an effect and keyed by the tab id. The tab's name rides along as the drag-preview label.
  • useTabsetDraggable does the same for the grip, under the dnd-kit id grip-<tabsetId> with the real tabset id in the draggable's data.
  • useTabsetDroppable registers each tabset as a @dnd-kit/dom Droppable with a custom occlusion-aware collision detector: the winner is the tabset containing the topmost element under the pointer (elementFromPoint), so a tabset covered by a float never becomes a target. dnd-kit's built-in detectors are pure rect geometry and stay unused: they know nothing about paint order.

2. The adapter reads the pointer and hit-tests

DashfooDragProvider constructs one @dnd-kit/dom DragDropManager (minus the Accessibility plugin and the keyboard sensor), starts one dragDockMachine actor, and subscribes to the manager's monitor once for the whole tree. On dragstart it reads the source's data to decide the subject kind (a grip carries type: "tabset", an external source carries type: "external", a tab carries its own id) and sends START. On dragmove and on every collision pulse it takes the drop target dnd-kit's collision pass resolved, looks up the layer that registered it, asks that layer to resolve an intent, and sends OVER. On dragend it re-resolves from the final operation state, sends a last OVER, then DROP.

Each Layout.DragLayer is one such layer: the main tree, plus one per float. A layer registers only how its own tabsets turn a pointer into an intent and where a committed action goes, keyed by a layer id that also namespaces its droppable registrations. It holds no drag state, so a pointer move costs one intent resolution rather than one per mounted layer. A Layout.DragLayer with no provider above it mounts its own, so it still works standalone.

The data-dashfoo="drag-preview" chip is positioned by dnd-kit's Feedback plugin in overlay mode: the chip element is handed to Feedback's overlay accessor once at mount, so the dragged tab itself is never promoted or placeholder-cloned and per-move positioning never re-renders React. The drop animation is disabled: drops settle immediately.

The final recompute means the drop uses the authoritative final pointer, not whatever the last dragmove happened to report, so the committed location matches where the user released.

3. dragDockMachine runs the lifecycle

The XState machine has two states, idle and dragging, and its context is one DragState rather than a pair of independent nullables:

type DragState =
  { kind: "idle" } | { drop: DropResolution | null; kind: "dragging"; subject: DragSubject };

START stashes the subject (its kind is "tab", "tabset", or "external", the last carrying the TabNode to insert) and enters dragging. Each OVER assigns the live DropResolution ({ intent, scope }, where scope names the layer that resolved it; the indicator reads the intent). A drop target with no subject is not expressible, so "is this drop valid" and "what does it do" are one total function:

const dropAction = (drag: DragState): { action: Action; scope: string } | null => {
  if (drag.kind !== "dragging" || drag.drop === null) {
    return null;
  }
  const { intent, scope } = drag.drop;
  const { subject } = drag;
  if (subject.kind === "external") {
    return { action: { ...intent, tab: subject.tab, type: "addNode" }, scope };
  }
  if (subject.kind === "tabset") {
    // no `index`: a tabset merges or docks beside, never into a strip slot
    const { index, ...target } = intent;
    return { action: { ...target, sourceId: subject.id, type: "moveTabset" }, scope };
  }
  return { action: { ...intent, sourceId: subject.id, type: "moveNode" }, scope };
};

The DROP guard is dropAction(context.drag) !== null, and the emitted COMMIT carries the same result, so the guard and the action cannot disagree.

CANCEL (or a canceled drop) returns to idle and clears the context. The machine never mutates the model; it only emits the action.

4. COMMIT forwards the action

The provider subscribes to the machine's COMMIT emission and forwards the action (moveNode for a tab drag, moveTabset for a grip drag, addNode for an external source) to the layer named by the emission's scope, which sends it to that layer's onCommit or, without one, to the Layout.Root above it. DashfooLayout wires that straight to store.dispatch.

5. The reducer applies the move

The reducer deep-copies the model (structuredClone, so the input is never mutated), then applies the action. For moveNode it removes the source tab from wherever it lives and re-inserts it at the drop target: center stacks into the target tabset at index; split-* creates a new tabset beside the target (reusing the parent row when the orientation already matches, otherwise wrapping both in a new row). For moveTabset it merges (center) or detaches and re-places the whole tabset (split-*), as described in Dragging a whole tabset. After the action, self-healing invariants run (normalize) so the result is always a valid, canonical model.

Why the index needs no adjustment

moveNode removes the source tab before inserting it:

const [removed] = source.container.children.splice(source.index, 1);
insertTab(draft, removed, { id: action.targetId, index: action.index, location: action.location });

Because the drag adapter already excluded the dragged tab when measuring the insertion index, action.index indexes the post-removal array directly. No off-by-one correction is needed when a tab moves within its own strip.

Gates

These flags decide what a drag can do. Some are global, some per tab, some are layout props.

GateScopeWhere it livesDefaultEffect when off
editablewhole layout<DashfooLayout> proponNo drags start, and the layout is not a drop target (see below).
enableSplitDockglobalglobal.enableSplitDockonA drop over a tabset body stacks instead of splitting.
draggableTabswhole layout<DashfooLayout> proponNo tab can be picked up; tab.enableDrag becomes moot.
tabEnableDragglobalglobal.tabEnableDragonTree-wide default behind tab.enableDrag.
enableDragper tabtab.enableDragonThat tab can't be picked up at all.
draggableTabsetswhole layout<DashfooLayout> proponNo grip is rendered; tabsets can't be dragged whole.

The editable gate

editable={false} makes the layout static. On the source side every internal draggable is disabled, so no drag can start from it. On the target side the adapter gates at drag time: resolveIntent returns null for a non-editable layout (no dock indicator and no drop target) and the COMMIT subscription drops the action, so even a drag that started before editable flipped off, or an external-source drag arriving through a shared DashfooDragProvider, cannot land a structural change. A sibling editable layout under the same provider still accepts those drags.

Global gate

DashfooLayout reads the global flag and passes it to DragProvider. It defaults to enabled. The check is !== false, so an absent flag means on:

const splitDock = store.model.global.enableSplitDock !== false;

When enableSplitDock is off, resolveIntent rewrites any split-* result back to a center stack, so a drop over the body lands as a tab.

Per-tab gate

A tab opts out of dragging by setting enableDrag: false in the model. The tab button passes that straight into the internal useTabDraggable hook as its disabled argument, with the tab's name as the chip label:

const { ref } = useTabDraggable(tab.id, tab.enableDrag === false, tab.name);

The hook returns only a ref. Dragging state is not part of its return value: the views derive it from the machine's subject (useDragSubject, also internal) by comparing the subject's id to the tab's, which drives the data-dragging attribute on the source tab's data-dashfoo="tab-item" wrapper while the overlay chip follows the pointer.

A disabled tab is rendered like any other but can't be picked up, so it can never become the source of a moveNode.

Tabset grip gate

draggableTabsets is a <DashfooLayout> prop, default true. When false, the grip button never renders, so moveTabset can never originate from a drag. The grip also disappears while its tabset is maximized, since a maximized tabset has nowhere to dock.

Styling the indicator

The drop indicator carries data-dashfoo="dock-indicator". Following the headless contract, the adapter sets only position and size inline; every visual property is an overridable CSS custom property with a neutral fallback:

VariableDefaultApplies to
--dashfoo-dock-filloklch(0.556 0 0 / 0.18)insertion line + split band
--dashfoo-dock-borderoklch(0.708 0 0 / 0.75)insertion line + split band
--dashfoo-dock-border-width1pxinsertion line + split band
--dashfoo-dock-radius6pxsplit band
--dashfoo-dock-line-radius2pxinsertion line
--dashfoo-dock-transitionleft 60ms, top 60ms, width 60ms, height 60msboth forms

--dashfoo-dock-transition controls how the indicator glides between positions; set it to none to disable the movement, for example under prefers-reduced-motion. Override these variables on your theme's [data-dashfoo="dock-indicator"] rule to control the look. The theming guide has worked recipes.

Rendering your own indicator

When CSS variables aren't enough, the drag is fully observable from the outside. useDropIntent() returns the live drop intent: { targetId, location, index? }, or null when nothing is dragging or the pointer is over no valid target. useDragSubject() returns what is being dragged. Both read the provider's drag actor, so they work anywhere under a layout or anywhere under a DashfooDragProvider, including a custom overlay mounted beside the layout rather than inside it. Outside a provider they return null. Combine them with dockZonePolygons from @dashfoo/core (the exact hit-region partition resolveDockTarget uses) and the data-dashfoo="tabset" / "tabstrip" / "tab" markup to paint anything from a custom highlight to a full drop-zone map. The demo's Docking page renders a voronoi-style visualization of every candidate region this way.

See also

  • resolveDockTarget in @dashfoo/core for the band math and BandOptions.
  • tab-insertion.ts in @dashfoo/react for the insertion index, the insertion line, and the no-op drop filter.
  • drag-adapter.tsx in @dashfoo/react for the manager, the hit-testing, and the indicator.
  • dragDockMachine in @dashfoo/core for the interaction lifecycle.
  • The moveNode, moveTabset, and insertTab paths in the reducer for how a drop reshapes the model.
  • Controlled mode and history for what happens to the committed action after dispatch.