dashfoo

Build your own layout

Compose the Layout and Tabset primitives into a custom layout from the same parts DashfooLayout is built from, with your own chrome, labels, and toolbars.

DashfooLayout is a thin assembly of exported primitives. When its chrome doesn't fit (custom tab labels, a different toolbar order, extra controls in the strip), compose the same parts yourself. The parts keep selection, close-with-focus-restore, inline rename, keyboard navigation, overflow, maximize, and drag-dock outside DashfooLayout.

The three layers

A hand-built layout wires three things:

  1. A store. useDashfooStore({ defaultModel }) owns the model, dispatch, and undo/redo, the same state DashfooLayout uses internally.
  2. The Layout frame. Layout.Root takes model, dispatch, and renderers, and provides the layout store every part below reads through selectors. Layout.DragLayer opts the tree into drag-dock (omit it and the layout still works without dragging). Layout.Rows renders the split tree on the resize adapter.
  3. A tabset composition. Pass renderTabset to Layout.Rows to substitute your own arrangement of Tabset.* parts at every leaf, or use Layout.Tabset, the stock composition, where custom chrome isn't needed.
import { Layout, Tabset, useDashfooStore } from "@dashfoo/react";

const App = () => {
  const store = useDashfooStore({ defaultModel });

  return (
    <Layout.Root dispatch={store.dispatch} model={store.model} renderers={{ tab: renderTab }}>
      <Layout.DragLayer>
        <Layout.Rows node={store.model.layout} renderTabset={(node) => <MyTabset node={node} />} />
      </Layout.DragLayer>
    </Layout.Root>
  );
};

Composing a tabset

Tabset.Root creates a per-tabset store; the parts inside coordinate through it, so they can be reordered, wrapped, or omitted without changing behavior:

const MyTabset = ({ node }: { node: TabsetNode }) => (
  <Tabset.Root node={node}>
    <Tabset.TabStrip>
      <Tabset.Tablist>
        {node.children.map((tab) => (
          <Tabset.Tab key={tab.id} tab={tab}>
            <Tabset.Trigger>
              <MyLabel />
            </Tabset.Trigger>
            <Tabset.RenameInput />
            <Tabset.CloseButton />
          </Tabset.Tab>
        ))}
      </Tabset.Tablist>
      <Tabset.Toolbar>
        <TabCounter />
        <Tabset.OverflowMenu />
        <Tabset.MaximizeButton />
        <Tabset.Grip />
      </Tabset.Toolbar>
    </Tabset.TabStrip>
    <Tabset.Content />
  </Tabset.Root>
);

Custom parts read the same stores the built-in parts do:

import { useTab, useTabset } from "@dashfoo/react";

// A label that knows which tab it is and whether it's selected.
const MyLabel = () => {
  const { index, tab } = useTab();
  const selected = useTabset((state) => state.visualSelected === index);
  return <span className={selected ? "font-bold" : undefined}>{tab.name}</span>;
};

// A toolbar add-on the stock composition doesn't have.
const TabCounter = () => {
  const count = useTabset((state) => state.node.children.length);
  return <span>{count} tabs</span>;
};

The hooks are selector-based: a part re-renders only when its selected slice changes, so a drag pulse or a rename in one tab doesn't wake the rest of the strip.

What each part owns

PartOwns
Tabset.RootThe per-tabset store, drop registration, overflow measurement, the drag-out selection preview, and focus restore after a close.
Tabset.TablistThe scrolling viewport and the role="tablist" marker that owns the triggers by id.
Tabset.TabTab identity for the parts inside, and focus return after a rename.
Tabset.TriggerThe role="tab" button: ids and aria wiring, the roving-tabindex keyboard model, select on click, rename on double-click, tab dragging. Children override the label.
Tabset.RenameInputThe inline editor (Enter commits, Escape cancels, blur commits once); renders only while its tab is being renamed.
Tabset.CloseButtonClose without selecting; hides while renaming or when the tab isn't closable.
Tabset.ContentThe role="tabpanel" pane(s), keepMounted included. A children render-prop overrides the layout's renderTab for this tabset.
Tabset.OverflowMenu / Grip / MaximizeButtonSelf-hide when their feature is off, so compositions never need the visibility conditions by hand.

A11y wiring (aria-controls, aria-labelledby, aria-selected, roving tabIndex) flows through the stores, so it stays correct in any part order. The tablist marker owns the triggers through aria-owns, so Tabset.CloseButton and Tabset.RenameInput stay siblings in the accessibility tree instead of invalid tablist children. Every part forwards its ref to the element it renders.

Maximize

In a hand-built layout, maximize is the host's branch. DashfooLayout uses the same branch internally:

import { findTabset } from "@dashfoo/core";

const maximized = store.model.maximizedTabsetId
  ? findTabset(store.model, store.model.maximizedTabsetId)
  : undefined;

{maximized ? <MyTabset node={maximized} /> : <Layout.Rows node={store.model.layout} … />}

Responsive

DashfooLayout's responsive prop isn't available here, but the mechanism behind it is two exported pieces. Measure the container with useContainerWidth, render a stackModel projection below the breakpoint, and pass the lock flags to Layout.Root. The projection is view-only: dispatch still targets store.model, so nothing maps back and widening up is lossless. The store, history, and mounted panels survive the breakpoint cross.

import { stackModel } from "@dashfoo/core";
import { Layout, useContainerWidth, useDashfooStore } from "@dashfoo/react";
import { useMemo, useRef } from "react";

const App = () => {
  const store = useDashfooStore({ defaultModel });
  const [containerRef, width] = useContainerWidth();
  const isCompact = width <= 720;
  const view = useMemo(
    () => (isCompact ? stackModel(store.model) : store.model),
    [isCompact, store.model],
  );

  // View-only: drop the adjustSplit rrp auto-fires while it re-measures the
  // stacked structure, so it never writes to the canonical model. The ref
  // (written during render so it is current before rrp's onLayoutChanged) gates
  // even the closure rrp captured before the swap.
  const isCompactRef = useRef(isCompact);
  isCompactRef.current = isCompact;
  const dispatch = (action) =>
    isCompactRef.current && action.type === "adjustSplit" ? undefined : store.dispatch(action);

  return (
    <Layout.Root
      dispatch={dispatch}
      draggableTabs={!isCompact}
      draggableTabsets={!isCompact}
      model={view}
      renderers={{ tab: renderTab }}
      resizableSplits={!isCompact}
      rootRef={containerRef}
    >
      <Layout.DragLayer>
        <Layout.Rows node={view.layout} renderTabset={(node) => <MyTabset node={node} />} />
      </Layout.DragLayer>
    </Layout.Root>
  );
};

rootRef is forwarded onto the layout's root element so useContainerWidth can measure it. Maximize and the projection compose: branch on view, not store.model, so a maximized tabset resolves against the stacked tree too.

Guard rails

Misuse is never silent. A part rendered outside its provider throws (Tabset.Trigger outside Tabset.Tab, any tabset part outside Tabset.Root). Soft mistakes (two Tablists under one Root, a Tab whose node isn't in the tabset, double-click rename with no RenameInput composed) log a [dashfoo] warning and keep rendering.

Working reference

The demo's Raw primitives page (apps/demo-vite/src/pages/raw.tsx) builds a complete layout this way (custom labels, a tab counter, reordered toolbar, undo/redo buttons wired to the store) with no DashfooLayout anywhere, and its Playwright suite drags, renames, and maximizes against it.