Documentation
Guides

External drag

Drag things from outside the layout into it - a sidebar of widgets, a palette, files from the OS - each drop adding a new tab.

A tab does not have to exist before it is dragged. Two paths create one on drop:

  • Dockable.DragSource: an element of your page (a sidebar item, a palette entry) that carries the tab to create.
  • onExternalDrag on Dockable.Root: accepts drags that did not start in your UI at all, such as files from the OS, links, or another library's draggable elements.

Both drop through the tab.add command, so everything else keeps working: your middleware sees (and may veto or rewrite) the add, the same middleware decides during the drag where it may land, the drop indicator follows the pointer, and the new tab can land in a tabset's centre, on its edges, in its tab strip or at the layout edge.

Widget sidebar
Gallery

A sidebar of widgets

A Dockable.DragSource needs the layout's model and the tab to create: a typed tab.add init (component and its data, and optionally an id and flags; the drop supplies the placement). It does not have to be inside Dockable.Root:

import { createModel, Dockable, type LayoutJson, type Model } from "@fragiola/dockable-react";
import { useState } from "react";

type Types = { tabs: { chart: { metric: string } } };

function Sidebar({ model }: { model: Model<Types> }) {
    return (
        <ul>
            <Dockable.DragSource
                model={model}
                // a function builds the tab at each drag start; `data` is checked against `component`
                tab={() => ({ component: "chart", label: "Revenue", data: { metric: "revenue" } })}
                // the new tab's id, or undefined when the add was refused
                onDrop={(id) => id && console.log(`added ${id}`)}
                render={<li />}
                className="cursor-grab data-dragging:opacity-50"
            >
                Revenue chart
            </Dockable.DragSource>
        </ul>
    );
}

export function App({ json }: { json: LayoutJson<Types> }) {
    const [model] = useState(() => createModel<Types>(json));
    return (
        <div className="flex h-dvh">
            <Sidebar model={model} />
            <Dockable.Root model={model} className="flex-1">
                {/* Row, Panels, DropIndicator… */}
            </Dockable.Root>
        </div>
    );
}

The panel renders the new tab like any other: pick the content from tab.component, where tab.data narrows to that component's type.

While the drag is over the layout, the source has data-dragging and so does the root: style the tabsets as drop targets with in-data-dragging: (Tailwind) or [data-layout-path="/layout"][data-dragging] [role="tablist"] (CSS).

Keyboard users

Native drag and drop cannot be done from the keyboard. Make the source a button and let a press run the same command, into the active tabset:

import type { TabInitOf } from "@fragiola/dockable-react";

const widget: TabInitOf<Types> = { component: "chart", label: "Revenue", data: { metric: "revenue" } };

const addToActiveTabset = () => {
    const target = model.get("default-tabset");
    if (target) model.run("tab.add", { ...widget, to: target.id, select: true });
};

<Dockable.DragSource model={model} tab={widget} render={<button type="button" onClick={addToActiveTabset} />}>
    Revenue chart
</Dockable.DragSource>

model.run works anywhere, inside the root or not, and goes through the same middleware as the drop.

Files and other foreign drags

onExternalDrag is called when a drag the layout did not start enters it (once per entry). Return { tab, onDrop? } (the tab to create, and a callback after the drop), or undefined to ignore the drag. Type it with OnExternalDrag<Types>:

import type { OnExternalDrag } from "@fragiola/dockable-react";

type Types = { tabs: { file: undefined } };

const files = new Map<string, File>(); // a File is not JSON: keep it outside the model, by tab id

const onExternalDrag: OnExternalDrag<Types> = (event) => {
    // until the drop, browsers expose only the types, not the data
    if (!event.dataTransfer?.types.includes("Files")) return undefined;
    return {
        tab: { component: "file", label: "File" },
        onDrop: (id, drop) => {
            const file = drop.dataTransfer?.files[0];
            if (!id || !file) return; // refused by a rule or a middleware
            model.run("tab.rename", { tabId: id, label: file.name }); // named after the file
            files.set(id, file);
        },
    };
};

<Dockable.Root model={model} onExternalDrag={onExternalDrag}>

The example opens what you drop where you drop it, by kind: a CSV becomes a table (its rows are small JSON, so they go in the tab's data) with a chart beside it, an image a viewer (the File stays outside the model), and a saved layout replaces the current one. A layout from a file is untrusted, so it goes through model.dispatch, which validates it first: a broken file is an error result, never a broken layout. With no files at hand, "Open sample files" runs the same code.

Open files from your desktop
Gallery

Read the payload (files, getData("text/uri-list"), …) in onDrop: during the drag the browser hides it. An external drag ends when it leaves every layout; the next one asks again.

A native drag that starts inside the layout's own content is never offered to onExternalDrag: selected text, or the items of two lists in a tab that use their own drag and drop. It stays your content's, so even a handler that accepts everything does not turn it into a tab, and a layout nested in a tab of another layout keeps the drags that start in it.

Dockable's own drags carry the MIME type DRAG_TYPE ("application/x-dockable") on the data transfer, and the layout claims only drags that carry it; every other drag is offered to onExternalDrag, or ignored. Another library's drop target can tell a Dockable drag apart the same way.

Where a new tab may land

The drop rules are middleware on tab.add (and on tab.move, for tabs already in the layout). During the drag, the layout asks the same middleware with model.can for every target under the pointer, so a refused target shows no drop outline. ctx.payload of a tab.add is the new tab itself, placement included, so a rule reads its component:

import { type Middleware, veto } from "@fragiola/dockable-react";

type Types = { tabs: { "kpi-sales": undefined; "kpi-churn": undefined; chart: undefined } };

const kpis: Middleware<Types> = (ctx, next) => {
    if (ctx.command !== "tab.add") return next();
    const { component, to, location = "center" } = ctx.payload;
    const kpi = component.startsWith("kpi-");
    if (kpi) return to === "kpis" && location === "center" ? next() : veto("KPIs go in the KPI strip");
    return to === "kpis" ? veto("The KPI strip takes KPIs only") : next();
};

model.use(kpis);

The dashboard builder below applies the same rule to tab.move and tabset.move, so a KPI already in the layout cannot be moved out of its strip either.

Dashboard builder
Gallery

Vetoing or rewriting the add

A drop runs tab.add with the tab you gave and the drop's placement (to, location, index). A middleware may veto it (return veto(…): nothing is added, and onDrop receives undefined) or rewrite it (assign a new ctx.payload, then call next(): the new payload is validated again). onDrop receives the id of the tab that was actually added.

Lower layers

  • useDragSource({ model, tab, onDrop, disabled }) is Dockable.DragSource without the element: spread its props (draggable, onDragStart, onDragEnd, ref) on your own, and read state.dragging (hooks).
  • In the core, DragDropManager.startAddDrag(model, event, tab, onDrop?, dragImage?) starts a new-tab drag from a native dragstart (it returns false when no layout of the model is mounted), and DragDropManager.endDrag() ends it from dragend. An adapter for another framework calls these.