dashfoo

Floating panels

Float a panel out of the docked layout into a draggable, resizable overlay. Floats are first-class, serializable nodes that stay in the same app.

A panel can lift out of the tiled layout into a floating panel: a draggable, resizable overlay that sits above the docked rows but stays in the same app (the "float" move from FlexLayout). A floating panel is not a separate browser window; it renders in the same React tree, so context, events, and styling stay connected. It is also a first-class node in the model (Dashfoo.floats), so it serializes, validates, and self-heals exactly like a docked panel. The anatomy figure shows where floats and their minimized chips sit relative to the tiled grid.

The floatable prop

Pass floatable to add a float control to each tabset toolbar and render any floating panels:

import type { Dashfoo, TabNode } from "@dashfoo/core";
import { model, row, tab, tabset } from "@dashfoo/core";
import { DashfooLayout } from "@dashfoo/react";
import type { ReactNode } from "react";
import { useMemo } from "react";

const dashboard = (): Dashfoo =>
  model(
    row([
      tabset([tab("chart", "Chart"), tab("trades", "Trades")], { id: "left", weight: 2 }),
      tabset([tab("book", "Order Book")], { id: "right", weight: 1 }),
    ]),
  );

const renderPanel = (node: TabNode): ReactNode => <div>{node.name}</div>;

const Dashboard = (): ReactNode => {
  const defaultModel = useMemo(() => dashboard(), []);
  return <DashfooLayout defaultModel={defaultModel} factory={renderPanel} floatable />;
};

export { Dashboard };

Clicking the [data-dashfoo="tabset-float"] control floats the whole tabset into an overlay positioned over where it was docked. Drag the title bar to move it anywhere on the page, drag the edge/corner handles to resize, minimize it to a small chip (and click the chip to restore), and Dock back to return it to the layout. You can also drag tabs into and out of a float: it is a full participant in drag-dock.

Floats are model nodes

Floating dispatches an action; the result is a normalized model with a floats entry. Everything you can do to the main tree works on a float's tree:

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

const floated = reducer(model, { type: "floatTabset", tabsetId: "left" });
floated.floats;
// [{ type: "float", id, layout: RowNode, geometry: { left, top, width, height } }]

// Dock it back into the active main tabset and drop the float.
reducer(floated, { type: "dockFloat", floatId: floated.floats[0].id });

The reducer actions are floatTab, floatTabset, dockFloat, moveFloat (persists a float's rect as you drag or resize it, one undo step per gesture), setFloatMinimized, and renameFloat. geometry is in viewport CSS pixels (the overlay is fixed), so a float can be dragged anywhere on the page. A float carries its own name ("Panel", "Panel 1", … assigned when it is floated), shown as the window title, never the active tab's name. normalize heals each float's own layout and drops a float the moment its last panel leaves, the same way it removes an empty tabset. Because a float owns a full RowNode, a floated panel is a real container: it can hold one tab or several.

Saved float bounds are projected into the current viewport, so restoring a desktop layout on a narrow screen keeps title-bar controls reachable. The projection creates no history entry and never overwrites the saved rect; widening restores the saved geometry. A later move or resize commits the geometry visible at that time.

What's in scope

  • Same React tree, full drag-dock. Floats render through the normal Layout.Rows in the same tree, sharing one drag manager with the docked layout, so app context (a theme provider, a query client), event handling, your stylesheet, and dragging tabs into and out of a float all stay connected.
  • Drag anywhere + resize. The title bar moves the float across the viewport; eight handles resize it. Both update the DOM imperatively during the gesture and commit one moveFloat on release, so a drag is a single undo step.
  • Named, renamable title. Each float is titled with its own unique name ("Panel", "Panel 1", …); double-click the title to rename it (renameFloat), which dedupes the new name against the other floats so two never collide.
  • Minimize. A title-bar control collapses the float to a small chip (setFloatMinimized); tapping the chip (draggable itself) restores it to its saved rect.
  • Dock back as a panel. The "Dock back" control returns the float as its own panel (all its tabs, grouped) rather than flattening them into another tabset.
  • Bring to front. Clicking anywhere in a float (its body, a tab, or its chrome) raises it above the others.
  • Honors editable. Under editable={false} a float is static: its content stays selectable and it still raises to the front, but move, resize, rename, minimize, and dock-back are all switched off: none of moveFloat, renameFloat, setFloatMinimized, or dockFloat can be dispatched from a static layout.

The overlay is pointer-events: none, so empty space stays click-through to the docked layout underneath; each float opts pointer events back in.

Hand-built layouts

If you compose the Layout.* primitives yourself, wrap the layout's content with Layout.FloatLayer (passing floats + global), which renders each floated panel as an overlay over the rows, and add Tabset.FloatButton to your tabset toolbar.

<Layout.Root
  dispatch={store.dispatch}
  floatable
  model={store.model}
  renderers={{ tab: renderPanel }}
>
  <Layout.FloatLayer floats={store.model.floats} global={store.model.global}>
    <Layout.DragLayer>
      <Layout.Rows node={store.model.layout} />
    </Layout.DragLayer>
  </Layout.FloatLayer>
</Layout.Root>

Tabset.FloatButton hides itself (and warns) if no Layout.FloatLayer is present, so a floated panel can never end up with nowhere to render.

See also: The layout model · Persisting layouts.