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 windowThree primitives take a children function instead of children:
| primitive | calls its child function with | for |
|---|---|---|
Row | TabsetNode<T> | RowNode<T> | every child of the row |
TabList | TabOf<T> | every tab of the tabset (or border) |
Panels | TabOf<T> | every tab whose content should render (all layouts) |
Popout | WindowLayout<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.propsis{ node, index }(the row, and the 1-based index of the child the splitter precedes); spread it on aDockable.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.