Documentation
Concepts

Composition

Children functions, owning the recursion, and Row inserting splitters.

Dockable does not render a layout for you. It gives you primitives and calls your functions to render each part. You write the recursion once, and you own every element in it.

The tree you render

Dockable.Root                      the engine, the context, the containing block
├─ Dockable.Row                    the root row: calls (child) => … per child
│  ├─ Dockable.TabSet              a tabset
│  │  ├─ Dockable.TabList          the strip: calls (tab) => … per tab
│  │  │  └─ Dockable.Tab           a tab button
│  │  └─ Dockable.TabSetContent    the measured content area (empty)
│  ├─ Dockable.Splitter            inserted by Row between children
│  └─ Dockable.Row node={row}      a nested row: the same function again
├─ Dockable.Panels                 calls (tab) => … per rendered tab
│  └─ Dockable.Panel               a tab's content panel
├─ Dockable.DropIndicator          the drop outline of a drag
└─ Dockable.Popout                 calls (layout) => … per popout window

Three primitives take a children function instead of children:

primitivecalls its child function withfor
RowTabsetNode<T> | RowNode<T>every child of the row
TabListTabOf<T>every tab of the tabset (or border)
PanelsTabOf<T>every tab whose content should render (all layouts)
PopoutWindowLayout<T>every popout window layout

The nodes are plain, immutable objects of the model's state (The model and commands), typed by your registry T. A child cannot infer the root's registry through context, so pass it as a type argument (<Dockable.Row<Types>>, <Dockable.Panels<Types>>); without it, tab.data is unknown. See Typed data.

Owning the recursion

A row's children are tabsets or rows, so the child function is recursive. Write it once as a plain function and pass it to every Row:

import { Dockable, type RowNode, type TabsetNode } from "@fragiola/dockable-react";
import type { ReactNode } from "react";

function renderNode(child: TabsetNode<Types> | RowNode<Types>): ReactNode {
    if (child.type === "tabset") {
        return <MyTabSet node={child} />;
    }
    return <Dockable.Row node={child}>{renderNode}</Dockable.Row>;
}

// the root row has no `node`: it renders the enclosing layout's root row
<Dockable.Row<Types>>{renderNode}</Dockable.Row>

A node is told apart by its type field ("tabset" or "row" here), which also narrows it.

Because the function is yours, anything can go in it: a header with buttons around the TabList, a different tabset component per tabset.data, a toolbar only on the active tabset, the strip below the content (render TabSetContent before the TabList: TabSet is a column, so markup order is visual order).

function MyTabSet({ node }: { node: TabsetNode<Types> }) {
    const { model } = useDockable<Types>();
    return (
        <Dockable.TabSet node={node} className="tabset">
            <div className="header">
                <Dockable.TabList<Types> aria-label={node.data?.name ?? "Tabs"} className="tabs">
                    {(tab) => (
                        <Dockable.Tab node={tab} className="tab">
                            {tab.label}
                            {model.can("tab.close", { tabId: tab.id }) ? <CloseButton tab={tab} /> : null}
                        </Dockable.Tab>
                    )}
                </Dockable.TabList>
                <MaximizeButton tabset={node} />
            </div>
            <Dockable.TabSetContent />
        </Dockable.TabSet>
    );
}

The popout windows render the same structure: pass the same function to the Row inside Dockable.Popout (a Row with no node there renders the window layout's root row).

<Dockable.Popout<Types>>{() => <Dockable.Row<Types>>{renderNode}</Dockable.Row>}</Dockable.Popout>

Splitters

Row inserts a Dockable.Splitter between every two children by itself. Two props change it:

  • renderSplitter={(props) => …} renders your own. props is { node, index } (the row, and the 1-based index of the child the splitter precedes); spread it on a Dockable.Splitter:

    const renderSplitter = (props: RowSplitterProps<Types>) => (
        <Dockable.Splitter {...props} aria-label="Resize" className="splitter" />
    );
    
    <Dockable.Row<Types> renderSplitter={renderSplitter}>{renderNode}</Dockable.Row>
  • splitter={false} renders none (the row cannot be resized by the user).

renderSplitter is per row: pass it to every Row, the nested ones in your recursion and the one in Popout (Limitations, gap 6). The Splitters guide styles them.

The panel layer

Dockable.Panels goes once, directly under Dockable.Root, not inside the recursion. It renders the content of every layout (the main one and the popouts), and the engine moves each panel where its tab is:

<Dockable.Panels<Types>>
    {(tab) => (
        <Dockable.Panel node={tab} className="panel">
            {renderContent(tab)}
        </Dockable.Panel>
    )}
</Dockable.Panels>

renderContent switches on tab.component, which narrows tab.data to that component's data (Typed data).

Tabs are iterated in stable id order, so React never reorders DOM the engine has re-parented. See Geometry for why content lives here.

Anything else inside the root

Any element can be a child of Dockable.Root: a toolbar overlay, a status bar, a component that calls useDockable() to run commands (model.run) or useModelState() to follow the layout. Keep in mind that the root row covers the root (inset: 0), so an overlay needs to be positioned and stacked.

The lower layer

Every primitive is built on a hook you can use directly: useTabSet, useSplitter, useDragNode, useDockable, useModelState. The part hooks return { state, props }: the state, and the props (ref and handlers) to spread on your element, for the rare case where a primitive's element does not fit. See Hooks.