Borders
Side bars with tabs that open panels beside or over the layout - docked and overlay borders, auto-hide, resizing and edge docking.
A border is a strip of tabs on one side of the layout, like an IDE's side bars. Its selected tab opens a panel next to the strip; clicking the selected tab again closes it. Borders are part of the model, so they are saved, restored and changed through commands like everything else.
1. Declare them in the model
Borders live in the layout's borders, one per side at most:
import type { LayoutJson } from "@fragiola/dockable-react";
type Types = {
tabs: {
explorer: undefined;
search: undefined;
terminal: undefined;
};
};
const json: LayoutJson<Types> = {
version: 1,
defaults: { border: { size: 220 } }, // every border's panel size
borders: [
{
location: "start",
selected: 0, // open on its first tab; -1 (the default) is closed
children: [
{ id: "explorer", component: "explorer", label: "Explorer" },
{ id: "search", component: "search", label: "Search" },
],
},
{
location: "bottom",
size: 160,
children: [{ id: "terminal", component: "terminal", label: "Terminal" }],
},
],
root: { type: "row", children: [/* … */] },
};A border's tabs are ordinary tabs: Dockable.Panels renders their panels like any other, and
their content keeps its state when they move between a border and a tabset. Its location is
top, bottom, start or end: start is the
side a line of text begins on (the left in LTR, the right in RTL). A border's id is
border_<location> (border_start) unless you give one. The fields are in the
JSON model reference: size, minSize, maxSize, show, autoHide,
mode, droppable, autoSelectTabWhenOpen, autoSelectTabWhenClosed, and data (typed by
the registry's border entry). Each behaviour field falls back to defaults.border.
2. Render the frame, the strips and the panels
Dockable.Borders replaces the root Dockable.Row inside Dockable.Root, and takes the row as
its child. It lays the borders out around the main area, as FlexLayout does: the top and bottom
strips span the full width, the start and end strips sit between them, and each panel area sits
between its strip and the layout.
<Dockable.Root model={model}>
<Dockable.Borders<Types>
renderBar={(border) => (
<Dockable.Border node={border} className="strip">
<Dockable.TabList<Types> aria-label={`${border.location} panels`}>
{(tab) => (
<Dockable.Tab node={tab} className="border-tab">
{tab.label}
</Dockable.Tab>
)}
</Dockable.TabList>
</Dockable.Border>
)}
>
<Dockable.Row<Types>>{renderNode}</Dockable.Row>
</Dockable.Borders>
<Dockable.Panels<Types>>{(tab) => <Dockable.Panel node={tab}>{content(tab)}</Dockable.Panel>}</Dockable.Panels>
</Dockable.Root>-
renderBarrenders a border's strip (it receives theBorderNode): aDockable.Borderwith aDockable.TabList. The tab list runs vertically in a start or end border (aria-orientation, arrow keys). -
renderContent(optional) renders the panel area: by default aDockable.BorderContent, which holds an area sized by the border (the engine positions the selected tab's panel over it) and the border's splitter on the layout's side. Pass your own to style it, or to replace the splitter:renderContent={(border) => ( <Dockable.BorderContent node={border} renderSplitter={(node) => <Dockable.Splitter node={node} aria-label="Resize" className="splitter" />} /> )}
Borders only render in the main layout, never in a popout window.
3. Style the strips
The primitives set only structural flex. Everything else is yours, through data-*:
| on | attribute | meaning |
|---|---|---|
Border, BorderContent | data-location | top, bottom, start or end |
Border, BorderContent | data-orientation | vertical for a start or end border (the direction its tabs run) |
Border, BorderContent | data-open | a tab is selected: the panel is open |
Border, BorderContent | data-overlay / data-docked | an overlay border / a docked border |
Border, BorderContent | data-empty | the border has no tabs |
Border | data-tab-direction | on the start border: up (default) or down, from the tabDirection prop |
Border | data-drop-target | the current drag would drop into the border |
Border | data-drop-refused | the current drag is over the border, and a rule refuses it |
Side labels are the classic question. Turn them with writing-mode (the tabs stay in order from
the top), and turn the start border's half a turn more when it reads "up", FlexLayout's default:
[data-location][data-orientation="vertical"] [role="tab"] {
writing-mode: vertical-rl;
}
[data-tab-direction="up"] [role="tab"] {
transform: rotate(180deg);
}Which way the start border reads is a prop of the strip, not a model field: tabDirection="down" on
Dockable.Border sets data-tab-direction="down". The attribute is what the prop asks for; the
page's direction does not change it. In a right-to-left page the start border is on the right,
where labels usually read down: pass tabDirection="down" there, so the attribute says what is
drawn and the same rule turns the labels in both directions
(Right-to-left layouts).
With Tailwind, as the examples do: in-data-[orientation=vertical]:[writing-mode:vertical-rl] in-data-[tab-direction=up]:rotate-180 on the tab. Drops into the strip are hit-tested against the
tab buttons' measured boxes, so any styling works.
Tab orientation is styling
Nothing in the package turns a side border's labels: Dockable.Border lays its tab list out as a
column and exposes data-orientation and data-tab-direction. Vertical labels, upright labels in
a column, or icon-only tabs as in an IDE's activity bar are all class names, and the icon comes
from your data:
// an activity bar: an upright icon per tab, named for assistive technology
<Dockable.Tab node={tab} aria-label={tab.label} className="grid place-items-center p-2">
<Icon name={tab.component} aria-hidden />
</Dockable.Tab>An icon-only tab has no text: give it an aria-label (and a tooltip for sighted users). The
border tab orientation example switches the same borders
between vertical and upright labels, and the IDE workbench has an
activity bar.
4. Open, close and resize
- Click a tab to open its panel (
tab.select); click the selected tab again to close it (border.configurewithopen: false). Both are commands, so your middleware sees them. - Resize with the border's splitter: drag it, or focus it and use the arrow keys (10px a
step, towards the layout). Its ARIA value is the size in px (
aria-valuenow,aria-valuemin,aria-valuemaxfromsize,minSize,maxSize). The command isborder.resize, run astransientwhile a live drag moves and once more on release. - From code, with the border's id:
model.run("tab.select", { tabId: "search" }); // opens the start border on Search
model.run("border.configure", { borderId: "border_start", open: false }); // closes its panel
model.run("border.configure", { borderId: "border_start", open: true }); // reopens it (first tab if none)
model.run("border.resize", { borderId: "border_start", size: 300 }); // clamped to minSize/maxSize
model.run("border.configure", { borderId: "border_bottom", show: false }); // hides the whole borderborder.configure also changes mode, autoHide, droppable, the sizes and data; a null
value removes the border's own value so defaults.border applies. Opening a border that has no
tabs is refused.
5. Drag tabs in and out
Borders take part in drag and drop:
- a tab dropped on a strip joins the border at that position;
- a tab dropped on an open panel joins the border (appended);
- a border's tab dragged into a tabset (or to a layout edge) leaves the border.
A border takes tabs only (never a whole tabset) and never a pinned tab. droppable: false on a
border (or defaults.border.droppable) refuses drops into it, and a middleware sees border
targets like any other (to is the border's id; see
Restricting drops). Dockable.Border carries
data-drop-target while it is the target, and data-drop-refused while a rule refuses it.
Overlay borders
An overlay border (mode: "overlay") opens over the layout instead of beside it, like a
drawer:
{ location: "start", mode: "overlay", children: [/* … */] }Switch one at runtime with border.configure:
model.run("border.configure", { borderId: "border_start", mode: "overlay" }); // or "docked"model.get("border-settings-by", { borderId })?.mode reads the mode in effect (the border's own, else
the default), and model.is("border-overlay", { borderId }) answers the same as a boolean.
- It closes on a press in the layout outside its panel (a tabset, a splitter), and on
Escape (
keyMap.closeOverlayBorder) with focus in the panel or on its tab button; focus goes back to the tab button. A press on a border strip does not close it. Both close paths runborder.configurewithopen: false. Dockable.BorderContentisposition: absoluteagainst the layout's edge, and lets presses through to the tab panel (pointer-events: none, except on its splitter). A start or end overlay stops above and below the open top and bottom overlays.- Its stacking is yours: give the content a
z-indexso its splitter paints above the tabsets (the examples usedata-overlay:z-30), and a shadow if you like.
Auto-hide borders
With autoHide: true (or defaults.border.autoHide), a border with no tabs is not rendered,
so it takes no space. While a tab is dragged near that border's edge of the layout (within
edgeDockMargin, away from the edge docking band in the middle of the edge), the border appears
so the tab can be dropped into it; it hides again when the drag moves away or ends. Once it has a
tab, it shows like any border. engine.is("border-shown", { borderId }) answers whether a border
shows right now.
Edge indicators
A drop in the band along an edge of the layout docks the tab to that whole edge, as a new
tabset. Dockable.EdgeIndicator marks those bands during a drag:
{(["top", "bottom", "start", "end"] as const).map((edge) => (
<Dockable.EdgeIndicator key={edge} edge={edge} className="edge-indicator">
<ArrowIcon edge={edge} aria-hidden />
</Dockable.EdgeIndicator>
))}Each indicator is positioned over its band (edgeDockMargin deep, edgeDockLength long,
centred on the edge), shown while a drag that can dock to the edges is over the layout, and
carries data-drop-target when the drop would dock there. It is pointer-events: none, and its
stacking is yours. See Dockable.EdgeIndicator. Edge docking is on
unless defaults.layout.edgeDock is false.
The band is 10px deep by default. A thin tab strip at the top of the layout sits inside it, so a
drop aimed at the strip's middle docks to the top edge instead (gap 11 in
Limitations). Lower defaults.layout.edgeDockMargin, or keep tab strips
taller than twice the band.
Borders in the other examples
The IDE workbench keeps its explorer in the start border and the terminal and problems in a bottom border, and the drag and drop example shows the edge indicators.