dock-libr
v0.1.13
Published
Custom dockable panel library for React
Readme
DockLibr
This project was built with the assistance of AI / large language models.
A customizable dockable panel library for React. Provides an IDE-like layout with draggable tab sets, splitters, collapsible panels, and drag-to-edge docking.
Repository: gitlab.com/dumsoft/docklibr
Install
npm install dock-librRequires react and react-dom as peer dependencies (v18 or v19).
Usage
import { DockLayout, DockModel } from 'dock-libr'
import 'dock-libr/styles/dock.css'
const model = DockModel.fromJson({
layout: {
type: 'row',
children: [
{
type: 'tabset', id: 'left', weight: 30,
children: [
{ type: 'tab', id: 'tab1', name: 'Explorer', component: 'explorer' },
{ type: 'tab', id: 'tab2', name: 'Search', component: 'search' },
],
},
{
type: 'tabset', id: 'right', weight: 70,
children: [
{ type: 'tab', id: 'tab3', name: 'Editor', component: 'editor' },
],
},
],
},
})
function factory(component: string) {
switch (component) {
case 'explorer': return <FileExplorer />
case 'search': return <SearchPanel />
case 'editor': return <CodeEditor />
}
}
export function App() {
return <DockLayout model={model} factory={factory} />
}Examples
Nested layout (row inside column)
const model = DockModel.fromJson({
layout: {
type: 'column',
children: [
{
type: 'tabset', id: 'top', weight: 30,
children: [{ type: 'tab', id: 't1', name: 'Top', component: 'panel' }],
},
{
type: 'row', weight: 70,
children: [
{
type: 'tabset', id: 'left', weight: 50,
children: [{ type: 'tab', id: 't2', name: 'Left', component: 'panel' }],
},
{
type: 'tabset', id: 'right', weight: 50,
children: [{ type: 'tab', id: 't3', name: 'Right', component: 'panel' }],
},
],
},
],
},
})Collapsed panel
{
type: 'tabset', id: 'toolbox', weight: 15, collapsed: true,
children: [{ type: 'tab', id: 'tools', name: 'Toolbox', component: 'toolbox' }],
}A collapsed tabset shows only its header bar with a ▶ expand button. Clicking it expands the panel; clicking ▼ collapses it back.
Adding / removing tabs dynamically
const model = DockModel.fromJson({ ... })
const editorTabset = model.getTabSetById('editor-tabset')
if (editorTabset) {
const tab = new TabNode('editor:vars', 'Variables', 'editor')
editorTabset.addTab(tab)
model.notifyListeners()
}
// Remove a tab
editorTabset.removeTab('editor:vars')
model.notifyListeners()Listening for model changes
const unsubscribe = model.addListener(() => {
// called whenever the layout changes (tab move, resize, collapse, etc.)
})
// later: unsubscribe()Keeping an empty tabset visible
Set preventDelete: true on a tabset so it stays visible even when it has no tabs:
{
type: 'tabset', id: 'editor', weight: 70, preventDelete: true,
children: [],
}Preventing resize / collapse / drag per tabset
{
type: 'tabset', id: 'locked', weight: 30,
disableResize: true, // splitters adjacent to this tabset are disabled
disableCollapse: true, // collapse button hidden
disableTabDrag: true, // tabs cannot be dragged out
disablePanelDrag: true, // panel header cannot be dragged
disableClose: true, // close button hidden on all tabs
children: [
{ type: 'tab', id: 't1', name: 'Locked', component: 'panel' },
],
}Disable plain text drop
By default, dragging a tab sets only a custom MIME type (application/x-dock-tab-id) on the data transfer, so browsers won't auto-insert the tab name into text editors. Pass enableTextDrop to also set text/plain:
<DockLayout model={model} factory={factory} enableTextDrop />Middle-click to close
Middle-clicking a tab closes it (respects enableClose and disableClose).
API
DockModel
Main model class. Holds the root layout node, manages actions, and notifies listeners.
| Method | Description |
|--------|-------------|
| DockModel.fromJson(json) | Create a model from a JSON layout definition |
| model.getRoot() | Get the root RowNode or TabSetNode |
| model.getTabSetById(id) | Find a tabset node by its string ID |
| model.doAction(action) | Execute a layout action (move tab, toggle collapse, dock panel, etc.) |
| model.addListener(fn) | Subscribe to layout changes. Returns an unsubscribe function |
| model.notifyListeners() | Force a re-render by triggering all listeners |
RowNode / ColumnNode
Container nodes that lay out their children horizontally (row) or vertically (column).
| Method | Description |
|--------|-------------|
| node.getChildren() | Get child ContainerNode[] (tabsets, rows, or columns) |
| node.getWeight() | Get the proportional size weight |
| node.setWeight(n) | Resize the node's proportional weight |
| node.getOrientation() | Returns 'horizontal' for rows, 'vertical' for columns |
TabSetNode
A tabbed container that holds multiple TabNode children.
| Method | Description |
|--------|-------------|
| tabset.getId() | Get the tabset's string ID |
| tabset.getWeight() | Get the proportional size weight (returns 0 when collapsed) |
| tabset.getCollapsed() / setCollapsed(bool) | Get or set collapsed state |
| tabset.getChildren() | Get TabNode[] |
| tabset.getActiveTabId() / setActiveTabId(id) | Get or set the active tab |
| tabset.getActiveTab() | Get the active TabNode or null |
| tabset.getTabById(id) | Find a tab by ID |
| tabset.addTab(tab) | Append a tab |
| tabset.removeTab(tabId) | Remove and return a tab |
| tabset.moveTab(tabId, targetIndex) | Reorder a tab to a new index |
| tabset.isEmpty() | Check if the tabset has no tabs |
Per-tabset flags (set via JSON or constructor, all default to false):
| Flag | Effect |
|------|--------|
| preventDelete | Tabset is not removed when its last tab is closed |
| disableResize | Splitters adjacent to this tabset are disabled |
| disableCollapse | Collapse button is hidden |
| disableTabDrag | Tabs cannot be dragged individually |
| disablePanelDrag | Panel header cannot be dragged |
| disableClose | Close button hidden on all tabs in this tabset |
TabNode
A single tab inside a tabset.
| Method | Description |
|--------|-------------|
| tab.getId() | Get the tab's string ID |
| tab.getName() / setName(name) | Get or set the display name |
| tab.getComponent() | Get the component string (passed to the factory) |
| tab.isEnableClose() / setEnableClose(bool) | Whether the tab can be closed by the user |
DockLayout
The React component that renders the layout.
| Prop | Type | Description |
|------|------|-------------|
| model | DockModel | The model instance |
| factory | (component: string, nodeId: string) => ReactNode | Function that returns a React element for each component name |
| onModelChange? | () => void | Optional callback fired on every model change |
| enableTextDrop? | boolean | When true, also sets text/plain on drag data transfer (default false) |
useDockContext()
React hook (must be used inside a DockLayout) that returns:
model,factorydragInfo,setDragInfo— drag state for panel drag-and-dropdropZone,setDropZone— drop target zone during dragrootDropZone,setRootDropZone— root edge zone during draghoveredTabSetId,setHoveredTabSetId— tracks which tabset the cursor is hovering during dragenableTextDrop— whethertext/plainis set on drag data transfer
Layout Action Types
Passed to model.doAction():
| Action | Data | Description |
|--------|------|-------------|
| MOVE_TAB | { tabId, fromTabSetId, toTabSetId } | Move a tab between tabsets |
| TOGGLE_COLLAPSE | { tabSetId } | Toggle collapsed state of a tabset |
| DOCK_PANEL | { tabId, sourceTabSetId, targetTabSetId, location, isPanelDrag } | Dock a tab to the edge of another tabset |
| DOCK_PANEL_ROOT | { tabId, sourceTabSetId, location, isPanelDrag } | Dock a tab to the edge of the root layout |
| DELETE_TABSET | { tabSetId } | Remove an empty tabset |
| SET_ACTIVE_TAB | { tabSetId, tabId } | Set which tab is active in a tabset |
| CLOSE_TAB | { tabId, tabSetId } | Close a tab (removes it and cleans up empty tabsets) |
| LOAD_MODEL | { layout: IJsonModel } | Load a new layout |
JSON Model Interface
interface IJsonModel {
layout: IJsonRow | IJsonTabSet
}
interface IJsonRow {
type: 'row' | 'column'
weight?: number
children: (IJsonRow | IJsonTabSet)[]
}
interface IJsonTabSet {
type: 'tabset'
id: string
weight?: number
collapsed?: boolean
preventDelete?: boolean
disableResize?: boolean
disableCollapse?: boolean
disableTabDrag?: boolean
disablePanelDrag?: boolean
disableClose?: boolean
activeTabId?: string
children: IJsonTab[]
}
interface IJsonTab {
type: 'tab'
id: string
name: string
component: string
enableClose?: boolean // default true
}Event Details
Drop zone locations (used in DOCK_PANEL and DOCK_PANEL_ROOT actions):
| Location | Description |
|----------|-------------|
| 'center' | Drop into the center (move tab into target tabset) |
| 'top' | Dock above |
| 'bottom' | Dock below |
| 'left' | Dock to the left |
| 'right' | Dock to the right |
Drag during drop: While dragging a tab, hovering over a tabset shows a 75×75px plus-shaped overlay at the center with 5 zones (center + 4 edges). At the root layout edges, 50×25px square indicators appear. All highlights turn blue when active.
Tab reorder: Dragging a tab within the same tabset shows a thin 2px blue vertical line indicating the insertion position.
Styling
Import the default stylesheet:
import 'dock-libr/styles/dock.css'The CSS defines the following classes that you can override:
| Class | Purpose |
|-------|---------|
| .dock-layout | Root layout container (fills parent height) |
| .dock-container-horizontal | Horizontal flex container for row children |
| .dock-container-vertical | Vertical flex container for column children |
| .dock-tabset | A tab set panel |
| .dock-tabset-collapsed | Applied when a tabset is collapsed |
| .dock-panel-header | The header bar of a tabset (tabs + collapse button) |
| .dock-tab-bar | Row of tab buttons inside the header |
| .dock-tab-button | Individual tab button |
| .dock-tab-button-active | Active tab button |
| .dock-tab-button-content | Tab button label text |
| .dock-tab-close-btn | Close button on a tab |
| .dock-tabset-content | Content area below the header |
| .dock-tabset-content-empty | Empty content area (no active tab) |
| .dock-splitter | Draggable splitter between panels |
| .dock-splitter-horizontal | Horizontal splitter (between rows in a column) |
| .dock-splitter-vertical | Vertical splitter (between columns in a row) |
| .dock-drop-indicator | Drop zone indicator overlay |
| .dock-root-edge-indicator | Root edge docking indicator |
| .dock-root-edge-indicator-active | Active root edge indicator |
| .dock-collapse-btn | Collapse/expand button |
| .dock-collapse-btn-hidden | Hidden collapse button (when disableCollapse is true) |
Customizing theme
Override the default colors by targeting the classes in your own CSS:
/* Dark theme (default) */
.dock-panel-header { background: #1e1e1e; color: #ccc; }
.dock-tab-button-active { background: #2a4a7a; }
.dock-splitter { background: #3a3a3a; }
/* Light theme example */
.dock-panel-header { background: #f0f0f0; color: #222; }
.dock-tab-button-active { background: #e0e8f0; }
.dock-splitter { background: #ccc; }Development & CI/CD
Branching
main: stable, published. Only accepts merges fromdevvia merge requests.dev: active development. Push all changes here.
Release Pipeline
The repository includes a .gitlab-ci.yml pipeline that:
- On push to
dev: builds the package to verify it compiles. - On push to
main(from a merged MR): builds, creates a GitLab Release with av<version>tag, and publishes to npm.
No tokens or keys are stored in the repository. Publishing uses npm Trusted Publisher (OIDC) — no CI/CD variables are required.
See AGENTS.md for the full development workflow.
License
AGPL-3.0-only — see the LICENSE file for details.
