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>
| field | type | description |
|---|---|---|
model | Model<T> | the model of the enclosing Dockable.Root: change the layout with model.run, read it with model.get and model.is (Model) |
engine | LayoutEngine<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) |
layoutId | string | the 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>:
| option | type | description |
|---|---|---|
model | Model<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) => boolean | whether 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.stateis 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 anisEqualthat 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:
| field | description |
|---|---|
state | what the part shows: the values the matching primitive turns into data-* attributes and passes to render |
props | what 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
| field | type | description |
|---|---|---|
state | TabSetState | see below |
props | { ref, onPointerDown } | ref measures the element as the tabset; onPointerDown makes it active (tabset.activate) on a primary pointer press |
TabSetState:
| field | type | description |
|---|---|---|
active | boolean | the tabset is its layout's active tabset |
maximized | boolean | the tabset is maximized |
hidden | boolean | another tabset of the layout is maximized, so this one is hidden |
empty | boolean | the tabset has no tabs |
dropTarget | boolean | the current drag would drop into or beside this tabset (its content or its strip) |
dropLocation | DropLocation | undefined | while it is the drop target: where the drag would dock relative to it |
dropRefused | boolean | the current drag is over this tabset, but a drop rule refuses it |
dropIndex | number | undefined | while 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:
| field | type | description |
|---|---|---|
state | BorderContentState | see 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:
| field | type | description |
|---|---|---|
overflowing | boolean | some of the container's tabs do not fit, so they are hidden |
hiddenTabs | TabOf<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
| field | type | description |
|---|---|---|
state | SplitterState | see 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
| field | type | description |
|---|---|---|
state | { dragging } | dragging: this node (of this model) is being dragged |
props | DragProps | see below; draggable is the node's effective draggable |
DragProps, the props of a draggable element:
| field | type | description |
|---|---|---|
draggable | boolean | whether the element is draggable |
onDragStart | (event: DragEvent<HTMLElement>) => void | starts the drag in the core |
onDragEnd | (event: DragEvent<HTMLElement>) => void | ends it |
ref | RefCallback<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>:
| field | type | description |
|---|---|---|
model | Model<T> | the model of the layout the new tab is dropped into (its Dockable.Root must be mounted) |
tab | TabInitOf<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 |
onDrop | NewTabDropped | undefined | (tabId: string | undefined, event) => void: called after the drop with the new tab's id, or undefined when the add was refused |
disabled | boolean | undefined | no drag starts while true |
UseDragSourceResult:
| field | type | description |
|---|---|---|
state | { dragging } | dragging: a drag started by this source is in progress |
props | DragProps | above; 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>:
| field | type | description |
|---|---|---|
model | Model<T> | the model whose drags the zone takes |
accepts | ((drag: DragSubject<T>) => boolean) | undefined | whether the zone takes this drag (default: every drag of the model) |
onDrop | (drag: DragSubject<T>, event: DragEventLike) => void | called when the drag is dropped on the zone. Nothing is moved: run the command you want (tab.close) |
UseDropZoneResult:
| field | type | description |
|---|---|---|
state | DropZoneState | over: 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.