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.onExternalDragonDockable.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.
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.
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.
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 })isDockable.DragSourcewithout the element: spread itsprops(draggable,onDragStart,onDragEnd,ref) on your own, and readstate.dragging(hooks).- In the core,
DragDropManager.startAddDrag(model, event, tab, onDrop?, dragImage?)starts a new-tab drag from a nativedragstart(it returns false when no layout of the model is mounted), andDragDropManager.endDrag()ends it fromdragend. An adapter for another framework calls these.