LayoutEngine
One layout on screen, one engine per window. Apps use its run, get and is for screen actions and view facts; adapters use engine.adapter.
"One layout on screen: the main layout's, or a popout window's (one engine per window). It measures the elements an adapter registers, positions tab panels over their content area (structural style only), owns the moveable elements that host tab content, and runs the drag and drop and the popout windows. It never changes the model except through commands."
In React you never create one: Dockable.Root does, and useDockable() gives you the engine of
the window your component renders in. An engine has the model's verbs, for what only the screen
can do: the model changes and reads the layout, the engine acts on windows, focus and measuring,
and reads view facts. Where to go for what: Model and engine.
const { engine } = useDockable<Types>();
if (engine.can("popout", { nodeId: tab.id })) engine.run("popout", { nodeId: tab.id });
const panelId = engine.get("tab-panel-dom-id-by", { tabId: tab.id }); // for aria-controls| member | does |
|---|---|
layoutId | the layout this engine draws: MAIN_LAYOUT ("main") or a popout window's id |
run(action, payload?) | performs a screen action (below); returns a CommandResult |
can(action, payload?) | answers whether run would succeed now: a boolean |
check(action, payload?) | returns what run would return (its value, or why it is refused), without doing anything |
get(key, payload?) | reads a view fact (below) |
is(key, payload?) | asks a yes/no question about the view |
adapter | what only an adapter calls (For adapter authors): an app never needs it |
The verbs are bound, like the model's. There is one engine per window, and the main layout's owns the popout windows and the drag in progress, but an app never has to pick one: what concerns the whole page (whether popouts are supported, popping out, docking back) works from any engine, which hands it to the main one.
Running screen actions
engine.run(action, payload) performs what only the screen can do. An action's key has no dot,
so it never reads as a command (tab.close): a command changes the layout through
model.run, and engine.run("tab.close", …) does not compile. The
actions that change the layout do it by running commands, so your middleware sees and can refuse
them; can and check dry-run them.
| action | payload | result | does |
|---|---|---|---|
"popout" | { nodeId } | { windowId } | pops a tab, or a whole tabset, out into a window at its place on screen: runs tab.popout or tabset.popout. Refused where popouts are not supported |
"dock-back" | { nodeId } | { tabIds } | docks a tab, or every tab of a tabset, of a window back into the main layout's "default-tabset" (its active tabset, else its first): window.close when that is all the window holds, otherwise tab.moves in a batch (a pinned tab is unpinned for the move and pinned again). Refused for a node of the main layout |
"focus-tabset" | { direction } | { tabsetId } | moves focus to the selected tab button of the "next" or "previous" tabset of this layout (wrapping), from the tabset containing focus or else the active one, and activates it (tabset.activate). Refused when focus is not in the layout, is in a text field or a menu, or a tabset is maximized |
"close-overlay-border" | { borderId } | { borderId } | closes a border's open panel (border.configure with open: false); focus in the panel goes back to its tab button |
"measure-and-position" | none | {} | runs the measure-and-position cycle: measures every registered element, positions the tab panels and discovers the splitter size. The primitives run it after every commit, and the engine runs it when a dir attribute changes on its root or an ancestor. It also reads the root's direction again: run it yourself when something else moved the tabsets without resizing any element (a direction changed by CSS alone) |
Closing a window from the browser runs window.close, which docks its tabs back the same way.
See Popouts and Dockable.PopoutTrigger.
Reading view facts
engine.get(key, payload) reads what the screen knows and the model does not. As on the model, a
key names what it returns, and one that takes an id ends in -by, completed by the payload's field
(engine.get("tab-panel-dom-id-by", { tabId })):
| key | payload | returns | reads |
|---|---|---|---|
"layout-path-by" | { nodeId } | string | the node's data-layout-path in this layout (the root row's is the layout's prefix: "" for the main layout, /sublayout<n> for a window). See Layout paths |
"tab-button-dom-id-by" | { tabId } | string | the DOM id of a tab's button, unique on the page (see idScope): its panel's aria-labelledby |
"tab-panel-dom-id-by" | { tabId } | string | the DOM id of a tab's panel, unique on the page: its button's aria-controls |
"flex-by" | { nodeId } | FlexSizing | how a row or a tabset sizes in its row: grow, its flex-grow (its weight × 1000, at least 1, so a small weight still fills), and its flex min/max (minWidth, minHeight, maxWidth, maxHeight), from its own limits, its tabs' and its tab strip's height |
"overlay-placement-by" | { borderId } | OverlayPlacement | undefined | where an overlay border's panel sits over the layout (position: absolute): its insets in px, top, right, bottom, left, an edge it leaves free absent. A top or bottom overlay spans its edge; a start or end one stops at the open top and bottom overlays (their size plus a splitter). undefined for a border that is not an overlay |
"popout-mode-by" | { nodeId } | "popout" | "dock" | undefined | what a popout button does for a tab or a tabset: "dock" when it is in a window (engine.run("dock-back")), "popout" when it can pop out now (engine.can("popout")), undefined when neither. Dockable.PopoutTrigger reads it |
"direction" | none | "ltr" | "rtl" | the computed direction of this layout's root, read when the root attaches, when a dir attribute on it or above it changes, and on "measure-and-position": start is on the left in "ltr", on the right in "rtl". Splitters, arrow keys, drops and overlay borders follow it (Right-to-left layouts) |
"splitter-size" | none | number | the measured splitter thickness, shared by every layout of the model |
"owner-document" | none | Document | undefined | the document this layout renders in (a popout's own, for a window's layout): attach listeners for a part of this layout here. Tab content renders under the main layout's root, so it reads its own element's ownerDocument to follow the tab into a popout |
"owner-window" | none | Window | undefined | the window this layout renders in |
engine.is(key, payload) answers a yes/no question about the view:
| key | payload | answers |
|---|---|---|
"popout-supported" | none | window layouts open as native popouts on this page (popout.supportsPopout, by default a desktop pointer) |
"tab-panel-visible" | { tabId } | the tab's panel is shown: selected, and not hidden by a maximized tabset or a hidden border |
"main-layout" | none | this engine draws the main layout, not a popout window's |
"splitter-dragging" | none | a splitter of the model is being dragged |
"border-shown" | { borderId } | the border shows in this layout (the main one; a window's engine answers false): its show is on and, when it autoHides, it has tabs or the current drag reveals it (the pointer near its edge). Dockable.Borders renders the borders it answers true for |
"tab-tabbable" | { tabId } | the tab is its tab list's tab stop (tabIndex 0): the selected tab, or the first tab when none is selected |
The keys are typed (EngineActionKey, EngineGetKey, EngineIsKey, with their maps
EngineActionMap, EngineGetMap and EngineIsMap, and EngineActionPayload<K>,
EngineActionResult<K>, EngineGetPayload<K>, EngineGetResult<K>, EngineIsPayload<K>): an
unknown key or a wrong payload does not compile.
Creating one
createLayoutEngine<T>(options: LayoutEngineOptions<T>): LayoutEngine<T> creates the engine of
the main layout of model. Popout windows get their engines from the main one
(createPopoutEngine): an adapter never creates those.
LayoutEngineOptions<T>:
| option | type | description |
|---|---|---|
model | Model<T> | the model |
measure | MeasureFunction | injectable element measurement (tests pass fixed rects); defaults to getBoundingClientRect |
realtimeResize | boolean | true (default) to resize live while a splitter is dragged; false to preview and commit on release |
tabDragSpeed | number | seconds a view may take to animate the drop outline (exposed as data; default 0.3) |
popout | PopoutOptions<T> | popout windows (main engine only): popoutURL, supportsPopout, title, onPopoutOpen, onPopoutClose, mirrorRoot, openWindow. See Popouts |
onExternalDrag | OnExternalDrag<T> | accepts foreign drags (files, links, other libraries) as new tabs (main engine only; popout layouts use the main engine's) |
dragGroup | DragGroup | layouts of other models this layout exchanges tabs with by drag and drop (main engine only). Without one, drags never cross models |
idScope | string | what keeps this layout's DOM ids and window names apart from another layout's on the page (see below). Main engine only |
idScope exists because default node ids (tab-1, window-1) are the same in every model: two
layouts on one page would otherwise emit the same aria-controls and aria-labelledby ids and
the same popout window names. It defaults to a page-unique d<n>-; Dockable.Root passes
React's useId(), so server and client agree. Popout engines share their main engine's scope.
Settings
engine.adapter.setOptions(settings: LayoutEngineSettings<T>) updates the options an adapter may change on
every render. LayoutEngineSettings<T> is the subset of LayoutEngineOptions<T> that can change:
realtimeResize, tabDragSpeed, popout, onExternalDrag and dragGroup. An option left out
goes back to its default. model, measure and idScope are fixed for the engine's life.
For adapter authors: engine.adapter
Everything else is on engine.adapter, a LayoutEngineAdapter<T>: the render cycle, element
registration, the moveable elements, measured geometry, overlay borders, and the drag and popout
machinery. A framework adapter (React today; Angular and Vue next) calls it; an app never needs
it, and the examples never touch it.
| member | description |
|---|---|
model | the model this engine draws (read-only) |
main | the main layout's engine (itself, for the main engine; read-only): it owns the popout windows, the drag and drop state and the view state every engine of the model shares |
idScope | the page-unique scope of this layout's DOM ids and window names (read-only) |
engineOf(id) | the engine of the layout a node is in: a popout's, or the main one |
createPopoutEngine(layoutId) | the engine of a popout window's layout, sharing this main engine's view state (the popout manager calls it) |
setOptions(settings) | updates the options an adapter passes on every render (Settings) |
The render cycle
The adapter's side of the cycle:
- call
prepare()before rendering a layout (paths and size ranges are computed as the render reads them); - render the structure, registering elements with
registerMeasurable,registerTabPanelandregisterSplitter; - run
engine.run("measure-and-position")after every commit (a layout effect in React); - re-render when the revision from
subscribe/getSnapshotchanges.
"The engine never renders geometry through the adapter: panel positions are written directly, so
a resize or splitter drag does not require a re-render." A transient row.resize (a realtime
splitter drag) is applied by writing flex-grow on the registered elements, with no re-render
at all; the lasting command at the release re-renders once.
import { createLayoutEngine, createModel } from "@fragiola/dockable";
const model = createModel<Types>(json);
const engine = createLayoutEngine({ model });
const { adapter } = engine;
adapter.attachRoot(rootElement);
function render() {
adapter.prepare();
// … build the DOM, then register each element by node id:
adapter.registerMeasurable(tabset.id, "tabset", tabsetElement);
adapter.registerMeasurable(tabset.id, "tabsetcontent", contentElement);
adapter.registerTabPanel(tab.id, panelElement);
adapter.attachMoveable(tab.id, panelElement);
engine.run("measure-and-position");
}
adapter.subscribe(render);
render();| method | description |
|---|---|
subscribe(listener) | calls listener when adapters should re-render; returns the unsubscribe function (bound, for useSyncExternalStore) |
getSnapshot() | the render revision: a number that changes whenever adapters should re-render (bound). Every engine of a model shares it |
prepare() | prepares the layout for rendering; the data-layout-path of every node and the size ranges rows and tabsets are rendered with are computed as the render reads them. Call before rendering |
attachRoot(element) | attaches the layout's root element, the containing block the panels are positioned in, and starts observing it, the window it lives in and the model. Idempotent; attaching another element detaches the previous one first |
detachRoot() | detaches the root element and releases every observer and listener |
dispose() | detaches and forgets everything. The engine must not be used afterwards |
Registration by id
Elements are registered against node ids, never node objects: a node is a new object in every state that changes it, its id is not.
| method | description |
|---|---|
registerMeasurable(id, kind, element) | registers (or, with null, unregisters) an element whose geometry the engine measures. kind is a MeasurableKind |
registerTabList(containerId, element, vertical?) | registers (or, with null, unregisters) a tabset's or border's tab list for tab overflow: when its tabs do not fit, the engine hides the ones that do not (keeping the selected one) and reports them through getHiddenTabs. vertical is the direction the tabs run (Dockable.TabList does) |
registerOverflowTrigger(containerId, element) | registers (or, with null, unregisters) the overflow trigger of a tab container: the space it takes is reserved in the strip (Dockable.TabOverflowTrigger does) |
registerTabPanel(tabId, element) | registers (or, with null, unregisters) the element a tab's content panel is positioned with |
registerSplitter(element, isHorizontal) | registers a splitter element and returns its remover. Its thickness (width while isHorizontal(), height otherwise) becomes the splitter size of every layout of the model. The orientation is read at every measure pass, since a row can flip it |
subscribeGeometry(listener) | calls listener after a measure pass changed a measured rect of this layout; returns its remover. A splitter re-reads its ARIA value with it |
getRegistrations() | the registered elements (measurables, tabPanels, splitters), read-only, for tests and debugging |
Derived view data
| method | description |
|---|---|
rowOrientation(rowId) | the Orientation of a row of this layout: "horizontal" or "vertical" |
edgeBands() | the EdgeBand[] of this layout's root row, relative to the layout root: where a drop docks to an edge, and where an edge indicator goes. Empty when edge docking is off |
getHiddenTabs(containerId) | the ids of a container's tabs hidden by tab overflow, in model order (the same array until it changes; bound) |
subscribeOverflow(listener) | calls listener when a container's hidden tabs change; returns the unsubscribe function (bound) |
shouldRender(tabId, renderOnDemand?) | whether a tab's content should be rendered: once rendered it stays rendered (its state is kept); before that, when its container's content area has a size and it is selected (or renderOnDemand is false). Marks it rendered |
rect(kind, id) | a measured rect of a node of this layout, relative to the layout root |
inlineRect(kind, id) | the same rect with its x read from the start side: as is in LTR, mirrored (x negated) in RTL, for math that runs from start to end |
Moveables
The content of a tab lives in a moveable element that the engine owns, keyed by tab id. It is
re-parented with appendChild, never cloned, so moving a tab between tabsets, borders and popout
windows never remounts its content.
| method | description |
|---|---|
getMoveableElement(tabId) | the element that hosts a tab's content: <div data-dockable-moveable> in the main layout's document, created on first use and kept for as long as the tab exists |
attachMoveable(tabId, panel, options?) | moves the tab's moveable element into panel (its current panel) and restores its scroll position. options is a MoveableOptions |
releaseMoveable(tabId, panel?, options?) | parks the moveable element in the hidden moveables home when its panel goes away, so the content's DOM (and the framework state rendered into it) is never destroyed by unmounting a panel. A later attachMoveable moves the same element into a new panel |
takeMoveable(tabId) | hands a tab's moveable element over to another model (a drag group transfer) and forgets it; undefined if there is none |
adoptMoveable(tabId, element) | adopts another model's moveable element for a tab (the other side of a transfer), so its content keeps its state |
MoveableOptions (how a panel hosts its content, a view option rather than layout state; the
scrollable and remountInWindow props of Dockable.Panel):
| field | type | description |
|---|---|---|
scrollable | boolean | the content scrolls inside its panel (default true); false clips it |
remountInWindow | boolean | the content is remounted when it moves to another window, so it is not parked (default false) |
Popouts
The main engine owns the popout windows: one native window per window layout of the state,
opened while the main engine is attached. Apps pop out and dock back with
engine.run; an adapter reaches the windows themselves through the
manager.
| method | description |
|---|---|
getPopoutManager() | the PopoutManager of the model's windows (owned by the main engine) |
Interaction
| method | description |
|---|---|
getDragDropManager() | the drag-and-drop state machine of this layout (see below) |
getDragGroup() | the drag group this layout exchanges tabs in (the main engine's), if any |
getOnExternalDrag() | the onExternalDrag handler (set on the main engine) |
handleOverlayPointerDown(event) | call on every pointerdown of the document (capture phase): a press in the main layout's area (not on a border strip) outside an open overlay border's panel closes it. Presses inside an element marked OVERLAY_ATTRIBUTE keep it open (Dockable.Root does). Returns true when a panel closed |
handleOverlayKeyDown(event, key) | call on every keydown with the close key (keyMap.closeOverlayBorder): with focus in an open overlay panel or on its tab button, closes it and focuses the tab button (Dockable.Root does). Returns true, and prevents the default, when a panel closed |
isRealtimeResize() | the realtimeResize option |
setSplitterDragging(dragging) | marks a splitter drag in progress (used by the splitter controller) |
getTabDragSpeed() | seconds a view may take to animate the drop outline |
Geometry
The core never touches the global document or window: all DOM access goes through the root
element's ownerDocument and defaultView, which is why the same engine runs inside a popout
window.
| method | description |
|---|---|
rectInLayout(element) | an element's rect relative to the layout root (not the viewport) |
getDomRect() | the layout root's rect in viewport coordinates (cached per measure pass) |
getFreshDomRect() | the layout root's rect in viewport coordinates, measured now |
The document and window the layout renders in are view facts any code may need:
engine.get("owner-document") and engine.get("owner-window").
Paths
The data-layout-path scheme, as functions: every adapter emits the same values, so e2e
selectors work across frameworks. See Layout paths.
| export | description |
|---|---|
getTabButtonPath(tabPath) | a tab button's path: its panel path with the last segment renamed (/ts0/t1 gives /ts0/tb1) |
getTabStripPath(tabsetPath) | a tabset's tab strip path (/ts0/tabstrip) |
getSplitterPath(nodePath, index) | the path of the splitter before child index (1-based) of a row or border (/s0 is between the root row's first two children) |
DROP_INDICATOR_PATH | "/outline", the drop indicator's path |
getTabButtonId(tabId, scope?) | the DOM id of a tab's button; engine.get("tab-button-dom-id-by", { tabId }) passes the engine's scope |
getTabPanelId(tabId, scope?) | the DOM id of a tab's panel; engine.get("tab-panel-dom-id-by", { tabId }) passes the engine's scope |
Attributes
| export | value | marks |
|---|---|---|
MOVEABLE_ATTRIBUTE | "data-dockable-moveable" | the element hosting a tab's content |
OVERLAY_ATTRIBUTE | "data-dockable-overlay" | an element that belongs to an open overlay border (its wrapper, splitter, toolbar): a press on it does not close the overlay |
POPOUT_ATTRIBUTE | "data-dockable-popout" | a popout window's content root |
Types
| type | description |
|---|---|
MeasurableKind | the kinds of element whose geometry the engine measures: "row", "tabset", "tabstrip", "tabsetcontent", "tabbutton", "borderheader", "bordercontent" |
MeasureFunction | (element: HTMLElement) => Rect: measures an element in viewport coordinates |
MoveableOptions | how a panel hosts its content (see Moveables) |
SizeRange | the size range of a row or tabset, in px: minWidth, minHeight, maxWidth, maxHeight (read-only) |
FlexSizing | a SizeRange plus grow: what "flex-by" returns |
OverlayPlacement | an overlay border's insets in px (top, right, bottom, left, each optional and read-only): what "overlay-placement-by" returns |
EdgeBand | an edge drop band: location (a BorderLocation: "top", "bottom", "start" or "end") and rect, relative to the layout root |
Drag and drop
The drag-and-drop machine lives in the core: one DragDropManager per engine, working on native
drag events that the engine listens to on its root, so the same code path runs in a popout
document. The drag in progress is page-wide (a static DragState), so a drag crosses the layouts
and windows of a model, and, with a drag group, of other models.
A drop resolves in two steps: the geometric candidates under the pointer (open overlay borders,
the root row's edge bands, the maximized tabset or the tabsets under the pointer, then the border
strips and open border panels), each turned into the command the drop would run (tab.move,
tabset.move, or tab.add for a new tab) and asked of model.can. The first accepted candidate
is the target; the first refused one is reported as refused (data-drop-refused). So a middleware
veto refuses a drop: see Restricting drops. The drop runs that
command through model.run.
Every Dockable drag sets DRAG_TYPE ("application/x-dockable") on its data transfer, and the
manager claims only the events that carry it: another library's drag passes through, or goes to
onExternalDrag. Every drop and dragend in the document ends the page's drag, even one whose
propagation your content stops: the manager listens in the capture phase.
DragDropManager
Static members, for the page:
| member | description |
|---|---|
getDragState() | the drag in progress, a DragState, or undefined |
subscribeDrag(listener) | calls listener when a drag starts or ends; returns the unsubscribe function |
startAddDrag(model, event, tab, onDrop?, dragImage?) | starts dragging a new tab (tab: TabInitOf<T>) into the layout of model from a consumer element anywhere on the page (a sidebar item, a palette entry). Call it from the element's dragstart, and endDrag from its dragend. Returns false, starting nothing, when no layout of model is attached. A drop runs tab.add, then calls onDrop (useDragSource does) |
endDrag() | ends the page's drag, if any (a drag source's dragend; the lost drag fallback calls it too) |
registerDropZone(model, element, options) | makes element a drop zone for drags of model's layouts (useDropZone calls it). Returns the unregister function |
Members of a layout's manager (engine.adapter.getDragDropManager()):
| member | description |
|---|---|
getIndicatorState() | the drop indicator of this layout, a DropIndicatorSnapshot (the same object until it changes; bound) |
subscribe(listener) | calls listener when the indicator changes; returns the unsubscribe function (bound) |
startDrag(event, id, dragImage?) | starts dragging a tab or a whole tabset of this layout, by id. Call from the source's dragstart, and DragDropManager.endDrag() from its dragend; dragImage is the element the browser snapshots (with none, the browser default) |
attach(element) | attaches the native drag listeners to a layout root, and the document listeners that end the page's drag; returns the detach function (adapter.attachRoot does) |
dispose() | releases the indicator's listeners |
Whether a drag is over this layout is getIndicatorState().dragging.
DragState
The drag in progress; there is one for the page, shared by every window of a model.
| member | description |
|---|---|
mainEngine | the main engine of the model the drag belongs to |
source | how the drag started, a DragSourceKind |
subject | what is dragged, a DragSubject of the drag's own model |
onNewTabDropped | for an add or external drag: called after the drop, a NewTabDropped |
dragId | the id of the dragged tab or tabset (undefined for a new tab) |
isNewTab() | true for drags that create a new tab on drop |
subjectOf(model) | what is dragged, typed by the registry of model (DragSubject<T>), when the drag belongs to model; otherwise undefined. Models of a drag group may share ids, so check the model before the id |
Drag types
| type | description |
|---|---|
DragSubject<T> | what is dragged: { kind: "tab", tab: TabOf<T> }, { kind: "tabset", tabset: TabsetNode<T> }, or { kind: "new", tab: TabInitOf<T> } (a drag source, or a foreign drag) |
DragSourceKind | how a drag started: "internal" (a node of a layout), "add" (a consumer element that adds a tab) or "external" (a foreign drag) |
DragEventLike | the subset of a native DragEvent the manager uses: clientX, clientY, dataTransfer, target, preventDefault |
DropKind | what a drop outline represents: "rect" (into or beside a node) or "edge" (at a layout edge) |
DropLocation | where a drop goes relative to its target: "center", "top", "bottom", "start" or "end" |
DropZoneOptions<T> | accepts?(drag: DragSubject<T>), onDrop(drag, event: DragEventLike) (nothing is moved: run the command you want) and onOverChange?(over: boolean) |
ExternalDrag<T> | what onExternalDrag returns to accept a foreign drag: tab (a TabInitOf<T>, the tab the drop creates) and onDrop? (a NewTabDropped) |
NewTabDropped | (tabId: string | undefined, event: DragEventLike) => void: called after an add or external drag was dropped, with the new tab's id, or undefined when refused |
OnExternalDrag<T> | (event: DragEventLike) => ExternalDrag<T> | undefined: decides whether a drag that did not start in a layout (files, links, text, another library's element) can drop into it. It is called when the drag enters a layout, when only event.dataTransfer.types is readable. See External drag |
DropIndicatorSnapshot, what a drop indicator shows (the same object until it changes; the React
Dockable.DropIndicator renders it):
| field | type | description |
|---|---|---|
visible | boolean | a drop target is under the pointer |
rect | Rect | the drop outline, relative to the layout root (1x1 at the pointer when a drag enters) |
location | DropLocation | where the dragged node would dock relative to the target |
kind | DropKind | "edge" for a drop at the outer edge of a layout, "rect" otherwise |
dragging | boolean | a drag is over this layout |
dragNodeId | string | undefined | the id of the dragged tab or tabset (undefined for a new tab) |
showEdges | boolean | edge docking is enabled (and no tabset is maximized): edge affordances may be shown |
tabDragSpeed | number | seconds a view may take to animate the outline between targets (the core never animates) |
targetNodeId | string | undefined | the id of the drop target node (a tabset, a row for edge drops, a border) |
targetTabsetId | string | undefined | the id of the tabset (or border) the drop goes into or beside |
index | number | the insertion index in the target's tab strip, or -1 for a drop on the content area |
refused | boolean | the pointer is over a target that refuses the drop (a rule of the layout, a middleware) |
refusedTabsetId | string | undefined | the id of the tabset (or border) that refused the drop, when it was one |
revealedBorder | "top" | "bottom" | "start" | "end" | undefined | an auto-hide border with no tabs that the drag reveals (main layout only); engine.is("border-shown", { borderId }) answers with it |
Splitters
SplitterController is the headless behaviour of one splitter: the splitter before child
index (1-based) of a row, or a border's splitter. The adapter forwards native pointer and
keyboard events and renders the ARIA values and state. It runs row.resize or border.resize:
transient on every move of a realtime drag, then one lasting command on release. Without
realtime resize, it exposes the bounded preview offset instead, and commits on release.
createSplitterController<T>(engine, nodeId, index) creates one; useSplitter does it for you.
| member | description |
|---|---|
attach(element) | attaches the splitter element (or detaches with null): registers it for splitter-size discovery and installs the non-passive touchstart guard Android needs |
onPointerDown(event) | starts a pointer drag. Call from the splitter's pointerdown (bound) |
onKeyDown(event) | arrow keys move the splitter by 10px. Call from the splitter's keydown (bound) |
getState() | the current SplitterControllerState (the same object until it changes; bound) |
subscribe(listener) | calls listener when the state or the ARIA values change; returns the unsubscribe function (bound) |
getAria() | the SplitterAria values: a row splitter's measured position in its row, a border splitter's size. The same object until they change; subscribe hears when a measure changes them (bound) |
isHorizontal() | true when the splitter sits between side by side children (it moves along x; bound) |
isHidden() | true when the splitter must not render: row splitters hide while a tabset is maximized |
dispose() | stops any drag in progress and releases every listener and timer |
SplitterAria, the ARIA attributes of a splitter (role="separator"):
| field | type | description |
|---|---|---|
orientation | "horizontal" | "vertical" | the separator's orientation: "vertical" for a splitter between side by side children |
valueNow | number | undefined | row splitters: the 0-100 position within the row, to a tenth; border splitters: the border size in px |
valueMin | number | undefined | the smallest value |
valueMax | number | undefined | the largest value |
valueText | string | undefined | the formatted value ("40.5%" or "200px"), announced in preference to the raw number |
SplitterControllerState, the drag state of a splitter:
| field | type | description |
|---|---|---|
dragging | boolean | true while the splitter is being dragged with the pointer |
previewOffset | number | undefined | while an outline (non-realtime) drag is in progress: how far, in px along the splitter's axis on screen (negative is left or up, in RTL too), the splitter would move if released now (bounded). undefined otherwise |
Keyboard
The configurable command shortcuts. Dockable.Root takes a KeyMap as keyMap; see
Keyboard.
KeyMap, bindings as "Ctrl+Shift+Key" strings (modifiers in any order, key compared
case-insensitively against KeyboardEvent.key); an explicit undefined disables a shortcut:
| field | default | description |
|---|---|---|
closeTab | "Ctrl+Delete" | closes the focused tab button's tab (when the tab is closeable) |
renameTab | "F2" | starts renaming the focused tab button's tab (when model.can("tab.rename") allows it, tabset tabs only) |
focusTabToggle | off | toggles focus between the selected tab button and its content |
focusNextTabset | off | moves focus to the selected tab button of the next tabset in the layout (wrapping), from anywhere within the layout, tab content included |
focusPreviousTabset | off | the same, to the previous tabset |
closeOverlayBorder | "Escape" | closes an open overlay border panel when focus is inside it (or on its tab button) and returns focus to the tab button |
| export | description |
|---|---|
defaultKeyMap | the default bindings above, exported so applications can display or re-register them |
resolveKeyMap(keyMap) | merges the configured bindings over the defaults; a binding passed as an explicit undefined disables that shortcut |
matchesKey(event, spec) | true when event is exactly the key combination spec (a disabled binding, or an event with no key, never matches) |
hasModifier(event) | true when any modifier key is held (the fixed ARIA pattern keys, arrows on tabs and splitters, only apply unmodified) |
toAriaKeyShortcuts(...specs) | the aria-keyshortcuts value of bindings: the set ones, space separated, Ctrl spelled Control; undefined when none is set |
KeyEventLike | the fields shared by native and framework keyboard events: key, ctrlKey, shiftKey, altKey, metaKey |
Tab overflow
The arithmetic behind registerTabList: which tabs of a strip fit, and which go to an overflow
menu. Dockable hides the tabs that do not fit (keeping the selected one) instead of scrolling
the strip. See Tab overflow.
computeTabOverflow(input: TabOverflowInput): TabOverflowResult splits a strip's tabs into the
visible and the hidden ones. When every tab fits, all are visible. Otherwise the tabs fit from the
start into available - reserve, stopping at the first that does not fit; when the selected tab
is not among them, tabs are taken off the end until it fits, and it is shown in its place. The
selected tab always shows, even alone in too small a strip; with no selection, the first tab does.
TabOverflowInput (sizes in px along the strip's axis):
| field | type | description |
|---|---|---|
available | number | the space for the tabs: the strip's inner size, plus the trigger's space while it shows |
sizes | readonly number[] | each tab's natural size, in model order |
gap | number | the gap between two tabs |
selectedIndex | number | the selected tab's index, or -1: it stays in the strip when anything is hidden |
reserve | number | the space the overflow trigger takes, reserved only when something is hidden |
TabOverflowResult:
| field | type | description |
|---|---|---|
visible | number[] | the indices of the tabs that stay in the strip, in model order |
hidden | number[] | the indices of the tabs that go to the menu, in model order |