Documentation
API: primitives (@fragiola/dockable-react)

Hooks

The hooks under the primitives. useDockable gives you the model and the engine, useModelState reads the layout, the rest wire parts into your markup.

The primitives are built on hooks, exported as the lower layer. Use useDockable and useModelState all the time (they are how components inside the layout run commands and read the layout); use the others only when a primitive's element does not fit your markup.

All of them must be called inside Dockable.Root, except useDragSource and useDropZone, which take the model as an option and work anywhere on the page.

useDockable

"The model, the engine and the layout of the enclosing Dockable.Root. Pass the model's registry as the type argument (useDockable<Types>()); a child cannot infer it through context."

import { type TabOf, useDockable } from "@fragiola/dockable-react";

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

function CloseButton({ tab }: { tab: TabOf<Types> }) {
    const { model } = useDockable<Types>();
    // a dry run: whether `tab.close` would apply (closable, pinned, middleware)
    if (!model.can("tab.close", { tabId: tab.id })) return null;
    return (
        <button type="button" onClick={() => model.run("tab.close", { tabId: tab.id })}>
            Close {tab.label}
        </button>
    );
}

useDockable<T>(): UseDockableResult<T>

fieldtypedescription
modelModel<T>the model of the enclosing Dockable.Root: change the layout with model.run, read it with model.get and model.is (Model)
engineLayoutEngine<T>the engine of the layout this component renders in (the main layout's, or a popout window's): screen actions with engine.run (pop out, dock back), view facts with engine.get and engine.is (LayoutEngine)
layoutIdstringthe id of the layout this component renders in ("main" or a window's id)

T is not checked against the root's model: the context holds the model with its registry erased, and useDockable<Types>() states which registry you know it has. Tab content renders under the root wherever the tab is shown, so inside it engine is the main layout's engine and layoutId is "main", even in a popout window: what concerns the whole page (whether popouts are supported, popping out, docking back) works from it all the same. The window a tab is in is model.get("layout-id-by", { nodeId: tab.id }), and the document it renders in right now is its own element's ownerDocument. Which object to go to for what: Model and engine.

useModelState

"A value selected from the model's state, re-rendering the component only when the selection changes (isEqual, Object.is by default). The selector runs once per state; keep it pure. Inside a Dockable.Root it reads the root's model; pass model to read one anywhere."

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

function TabCount() {
    const count = useModelState<Types, number>((state, model) => model.get("all-tabs").length);
    return <output>{count} tabs</output>;
}

useModelState<T, S>(selector: (state: LayoutState<T>, model: Model<T>) => S, options?: UseModelStateOptions<T, S>): S

UseModelStateOptions<T, S>:

optiontypedescription
modelModel<T>the model to read; default: the enclosing Dockable.Root's. With it, the hook works anywhere: a toolbar, a sidebar or a status bar beside the root
isEqual(a: S, b: S) => booleanwhether two selections are the same, so the component does not re-render (default Object.is)
function OpenWindows({ model }: { model: Model<Types> }) {
    const count = useModelState((_state, m) => m.get("windows").length, { model });
    return <output>{count} windows</output>;
}

It is useSyncExternalStore over model.subscribe, so:

  • the selector runs again only when model.state is a new object (a committed command that changed something; a vetoed or failed command, or one that changed nothing, keeps the state), when the root gets another model, or when you pass another selector (an inline closure is a new selector on every render, which only costs one more run);
  • the component re-renders only when isEqual(previous, next) is false. The default, Object.is, suits primitives and nodes: a commit shares every untouched subtree with the previous state, so an unchanged node is the same object. A selector that builds a new array or object each time needs an isEqual that compares contents, or it re-renders on every change of the layout;
  • inside a root, it reads the model from its own context, which changes only when the root gets another model, so it does not make the component re-render with every render of the layout. Outside one, it needs model; without either it throws.

A realtime splitter drag commits on every move (transient row.resize runs), so a selector should stay cheap. Use it for anything that shows layout state outside the parts: a status bar, a list of open tabs, a live model.get("layout-json") (the save-restore example of the persistence guide does this).

Part hooks

useTabSet, useBorder, useSplitter, useDragNode, useDragSource and useDropZone wire one part into your own element. Each takes the node first (the last two, which work outside the layout, take an options object with the model) and returns the same shape:

fielddescription
statewhat the part shows: the values the matching primitive turns into data-* attributes and passes to render
propswhat goes on the element: spread it (<div {...props} />). It holds the callback ref, the event handlers, draggable, the ARIA attributes and, for a splitter, the structural style
function DragHandle({ tabset }: { tabset: TabsetNode<Types> }) {
    const { state, props } = useDragNode(tabset);
    return (
        <span {...props} aria-hidden="true" data-dragging={state.dragging ? "" : undefined}>
            ⠿
        </span>
    );
}

To compose a handler of your own with one of props, call both: onPointerDown={(event) => { props.onPointerDown(event); mine(event); }}.

useTabSet

"The lower layer of Dockable.TabSet: its state, and the props for its element (the measurement ref and the activation handler)."

function MyTabSet({ node, children }: { node: TabsetNode<Types>; children: ReactNode }) {
    const { state, props } = useTabSet(node);
    return (
        <section {...props} data-active={state.active ? "" : undefined}>
            {children}
        </section>
    );
}

A custom tabset must still apply the structural flex sizing Dockable.TabSet sets (flex-grow and the min/max sizes from engine.get("flex-by", { nodeId: node.id }), display: none when state.hidden), and provide the tabset to TabList/TabSetContent, which only Dockable.TabSet does. Prefer Dockable.TabSet with render unless you need something it cannot express. Reading state alone is fine too: useTabSet(node).state.dropIndex places an insertion caret between two tabs.

useTabSet<T>(node: TabsetNode<T>): UseTabSetResult

fieldtypedescription
stateTabSetStatesee below
props{ ref, onPointerDown }ref measures the element as the tabset; onPointerDown makes it active (tabset.activate) on a primary pointer press

TabSetState:

fieldtypedescription
activebooleanthe tabset is its layout's active tabset
maximizedbooleanthe tabset is maximized
hiddenbooleananother tabset of the layout is maximized, so this one is hidden
emptybooleanthe tabset has no tabs
dropTargetbooleanthe current drag would drop into or beside this tabset (its content or its strip)
dropLocationDropLocation | undefinedwhile it is the drop target: where the drag would dock relative to it
dropRefusedbooleanthe current drag is over this tabset, but a drop rule refuses it
dropIndexnumber | undefinedwhile the drag aims at this tabset's strip: the index among its tabs the tab would be inserted at (its length for the end)

The drop fields re-render the tabset only when its own answer changes, not on every pointer move.

useBorder

"The lower layer of Dockable.Border: its state, and the props for its strip."

useBorder<T>(node: BorderNode<T>): UseBorderResult

UseBorderResult:

fieldtypedescription
stateBorderContentStatesee Dockable.BorderContent; Dockable.Border adds its tabDirection
props{ ref }ref measures the element as the border's tab header (its strip)

A custom strip must also provide the border to Dockable.TabList, which only Dockable.Border does: prefer Dockable.Border with render.

useTabOverflow

"Tab overflow of a tabset or a border: which of its tabs are hidden because they do not fit in its Dockable.TabList (the engine measures the list, the tabs and the Dockable.TabOverflowTrigger). The selected tab is never hidden."

useTabOverflow<T>(container: TabContainer<T>): UseTabOverflowResult<T> (a TabsetNode<T> or a BorderNode<T>). It wires no element, so it returns its value directly:

fieldtypedescription
overflowingbooleansome of the container's tabs do not fit, so they are hidden
hiddenTabsTabOf<T>[]the hidden tabs, in model order: what an overflow menu lists

It re-renders only when the container's hidden tabs change. See Tab overflow.

useSplitter

"The lower layer of Dockable.Splitter: the splitter before child index (1-based) of node (a row, or a border with no index). Spread props on the separator element."

function MySplitter({ node, index }: RowSplitterProps) {
    const { state, props } = useSplitter(node, index);
    return (
        <div
            {...props}
            aria-label="Resize"
            data-dragging={state.dragging ? "" : undefined}
        />
    );
}

useSplitter<T>(node: RowNode<T> | BorderNode<T>, index = 0): UseSplitterResult

fieldtypedescription
stateSplitterStatesee Dockable.Splitter
props{ ref, role, tabIndex, aria-*, onPointerDown, onKeyDown, style }the ref; role="separator", tabIndex: 0 and the ARIA values (aria-orientation, aria-valuenow, aria-valuemin, aria-valuemax, aria-valuetext); the pointer and arrow-key handlers; and the structural style: display: none while hidden, the outline preview's transform, pointer-events: auto on a border's splitter

A drag runs row.resize (or border.resize); while a realtime drag moves, those runs are transient, and the release runs one lasting command.

useDragNode

"The lower layer of a draggable part: wires a tab or a tabset to the core's drag-and-drop machine. Spread props on the dragged element."

The example above is a tabset drag handle.

useDragNode<T>(node: TabOf<T> | TabsetNode<T>): UseDragNodeResult

fieldtypedescription
state{ dragging }dragging: this node (of this model) is being dragged
propsDragPropssee below; draggable is the node's effective draggable

DragProps, the props of a draggable element:

fieldtypedescription
draggablebooleanwhether the element is draggable
onDragStart(event: DragEvent<HTMLElement>) => voidstarts the drag in the core
onDragEnd(event: DragEvent<HTMLElement>) => voidends it
refRefCallback<HTMLElement>the element the browser snapshots as the drag image: the dragged element itself, unless you attach it to another one

A drop runs tab.move or tabset.move through the model's middleware; a veto refuses the drop.

useDragSource

"The lower layer of Dockable.DragSource: turns any element, inside or outside the layout (a sidebar item, a palette entry), into a source of new tabs. Dropping it on the layout runs tab.add through the model's middleware. Spread props on the element."

function PaletteEntry({ model }: { model: Model<Types> }) {
    const { state, props } = useDragSource({
        model,
        tab: { component: "card", label: "Orders" },
        onDrop: (id) => console.log(id ? `added ${id}` : "refused"),
    });
    return (
        <li {...props} data-dragging={state.dragging ? "" : undefined}>
            Orders
        </li>
    );
}

useDragSource<T>(options: UseDragSourceOptions<T>): UseDragSourceResult. It does not need to run inside Dockable.Root: the drag goes to the layout of model that is mounted.

UseDragSourceOptions<T>:

fieldtypedescription
modelModel<T>the model of the layout the new tab is dropped into (its Dockable.Root must be mounted)
tabTabInitOf<T> | (() => TabInitOf<T>)the tab a drop creates: a tab.add init, checked against the registry. A function is called at each drag start, so every drop can get fresh data
onDropNewTabDropped | undefined(tabId: string | undefined, event) => void: called after the drop with the new tab's id, or undefined when the add was refused
disabledboolean | undefinedno drag starts while true

UseDragSourceResult:

fieldtypedescription
state{ dragging }dragging: a drag started by this source is in progress
propsDragPropsabove; draggable is false while disabled, and onDragStart cancels the drag when disabled or when no layout of the model is mounted

useDropZone

"The lower layer of Dockable.DropZone: makes an element, inside or outside the layout, a place where a drag of the layout can be dropped for the consumer to handle (a trash can, an "open to the right" pad). Spread props on the element."

const zone = useDropZone({
    model,
    accepts: (drag) => drag.kind === "tab",
    onDrop: (drag) => {
        if (drag.kind === "tab") model.run("tab.close", { tabId: drag.tab.id });
    },
});
return <div {...zone.props} data-over={zone.state.over ? "" : undefined}>Trash</div>;

useDropZone<T>(options: UseDropZoneOptions<T>): UseDropZoneResult. It does not need to run inside Dockable.Root.

What is dragged is a DragSubject<T>: { kind: "tab", tab } (a TabOf<T>), { kind: "tabset", tabset } (a TabsetNode<T>) or, for a new tab (a Dockable.DragSource or a foreign drag), { kind: "new", tab } (a TabInitOf<T>).

UseDropZoneOptions<T>:

fieldtypedescription
modelModel<T>the model whose drags the zone takes
accepts((drag: DragSubject<T>) => boolean) | undefinedwhether the zone takes this drag (default: every drag of the model)
onDrop(drag: DragSubject<T>, event: DragEventLike) => voidcalled when the drag is dropped on the zone. Nothing is moved: run the command you want (tab.close)

UseDropZoneResult:

fieldtypedescription
stateDropZoneStateover: a drag the zone takes is over it; active: a drag the zone would take is in progress (see Dockable.DropZone)
props{ ref }the zone's callback ref

While a drag the zone takes is over it, the layouts hide their outline.

useDragGroup

useDragGroup(): DragGroup: the drag group of the enclosing Dockable.DragGroup, for code that moves tabs between its layouts (group.transfer({ tabId, from, to, target, location, index }), an undo) or listens to transfers. It must be called inside Dockable.DragGroup. See Dockable.DragGroup.

Types

Also exported: TabSetState, DragProps, UseDockableResult, UseTabSetResult, UseBorderResult, UseTabOverflowResult, UseSplitterResult, UseDragNodeResult, UseDragSourceOptions, UseDragSourceResult, UseDropZoneOptions and UseDropZoneResult.