Popouts
Move tabs into native browser windows with live content - the host page, popoutURL, the commands, theming and closing.
A popout moves a tab into a native browser window. The content keeps its state: the tab's
moveable element is re-parented into the new document with appendChild, never cloned, and its
React tree stays where it was.
In the model, a popout is a window layout: an entry of model.get("windows") (a WindowLayout, with
its id, its screen rect and its own root row). The core opens one native window per window
layout while the layout is mounted, and closes a window whose layout is gone.
1. Serve a host page
The new window loads a host page, popout.html by default (relative to the current page). It
can be an empty document; the core adds a data-dockable-popout content root to it:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { height: 100%; }
</style>
</head>
<body></body>
</html>The window layout's id is passed as ?id=. Nothing names or titles the window unless you pass a
title.
2. Point popoutURL at it
<Dockable.Root model={model} popoutURL="/popout.html">Under a base path (GitHub Pages, a sub-directory deploy), include it: the window is opened
with window.open, which knows nothing about your router. The examples (a Vite app)
use `${import.meta.env.BASE_URL}popout.html`.
openWindow on Root replaces the opener, a function (url, name, features) returning the new
Window or null (the default calls the main window's open). Use it to open windows through
another API, or to fake them in tests.
3. Render the windows
Dockable.Popout goes once, directly under the root. For every window layout it opens a window
and, once the window is ready (styles copied), portals your child function's result into it. The
child function receives the WindowLayout; a Row without node inside renders that window's
root row:
<Dockable.Root model={model} popoutURL="/popout.html">
<Dockable.Row<Types>>{renderNode}</Dockable.Row>
<Dockable.Panels<Types>>{(tab) => <Dockable.Panel node={tab}>{content(tab)}</Dockable.Panel>}</Dockable.Panels>
<Dockable.DropIndicator className="drop-indicator" />
<Dockable.Popout<Types> className="popout-root">
{() => <Dockable.Row<Types>>{renderNode}</Dockable.Row>}
</Dockable.Popout>
</Dockable.Root>Dockable.Panels needs no change: it renders the panels of every layout, and each panel is
positioned in its own window.
4. Pop a tab out
Allow it per tab (poppable: true, or for every tab with defaults.tab.poppable: the
built-in default is false), then put a Dockable.PopoutTrigger in the tabset's header. It pops the
selected tab out, and in the window the same button docks it back (data-mode is "popout" or
"dock"). It renders nothing when neither is possible (popouts unsupported, or the model refuses
it):
<Dockable.PopoutTrigger aria-label="Pop out" className="group">
<ExternalLinkIcon aria-hidden className="group-data-[mode=dock]:hidden" />
<DockIcon aria-hidden className="hidden group-data-[mode=dock]:block" />
</Dockable.PopoutTrigger>target="tabset" moves the whole tabset into one window. Tab also exposes
data-poppable, for a button that lives in the tab.
From code, two commands create a window layout:
tab.popout{ tabId, rect? }moves a tab into a new window. It is refused when the tab'spoppableresolves to false, when it is pinned, or when it is already in a window.tabset.popout{ tabsetId, rect? }moves a whole tabset. It is refused when the tabset is empty or already in a window, or when any of its tabs does not allow popouts.
Both return { windowId }, the new window layout's id. rect is the window's screen rect; without
one a default place is used. The engine's engine.run("popout", { nodeId }) runs the right command
with the node's current screen rect, so the window opens where the tab was;
engine.can("popout", { nodeId }) answers with model.can (and whether popouts are supported);
engine.run("dock-back", { nodeId }) moves a tab or tabset of a window back into the main layout.
Any engine does it, a popout's included.
const { model, engine } = useDockable<Types>();
const popOut = () => engine.run("popout", { nodeId: tab.id }); // or model.run("tab.popout", { tabId: tab.id })
const allowed = engine.can("popout", { nodeId: tab.id });supportsPopout on Root decides whether windows open at all; the default is "a desktop
pointer is present". Without support (or when the browser blocks the window), a window layout is
docked back immediately.
Theming the popout
The core copies into the popout document: every <link rel="stylesheet">, every <style>
(including later edits), CSSOM rules inserted at runtime (polled), adoptedStyleSheets, and the
page's lang. The popout's dir is its layout's direction (engine.get("direction") of the main
layout), wherever the page sets it, and follows a flip (Right-to-left layouts).
If your theme keys off :root[data-theme], a class on <html> or on <body>, turn on
popoutMirrorRoot: it copies the attributes of <html> and <body> into every popout and keeps
them in sync, so a theme switch follows into open windows:
<Dockable.Root model={model} popoutMirrorRoot> {/* all but style and id */}
<Dockable.Root model={model} popoutMirrorRoot={["data-theme"]}> {/* only these */}The examples put their theme on <body> (data-example-theme) for this reason: popoutMirrorRoot
carries it into every window, and the menus they portal into <body> take it too. A theme that
lives on another element (a wrapper div) is not on the document root: set it in Root's
onPopoutOpen, which runs when the popout document is ready and before its content renders.
onPopoutOpen and onPopoutClose receive (layout, window, document) (layout is the
WindowLayout), also for set-up that is not about rendering (a CSS-in-JS cache for the new
document, analytics). popoutTitle on Root names the window's document
(popoutTitle={(layout) => "My app"}); with none, the host page's title is kept.
Closing a popout
When the user closes a popout window, the core runs window.close for its layout: its tabs
move back into the main layout's active tabset (its first tabset when none is active:
model.get("default-tabset")), and the window layout is removed. The result lists the tabs that
moved back. It is a command like any other, so a listener sees it and an undo can reverse it.
The same command closes a window from code:
model.run("window.close", { windowId }); // e.g. the id tab.popout returnedA "dock back" button inside popped-out content moves one tab home; the engine does it, closing the window when it was the window's last tab:
const { engine } = useDockable<Types>();
engine.run("dock-back", { nodeId: tab.id });Do not veto window.close in a middleware for a window the user closed: the window is already
gone, and its layout would stay in the state without it.
Other ways a popout goes away:
- the main page unloading (
pagehide) closes every popout without runningwindow.close: the model still has the window layouts, so a layout saved at that moment reopens them; - a
layout.loadwhose layout drops a window closes that window.
The window's screen rect is recorded in the model (a transient window.configure when the window
moves or resizes), so a saved layout reopens it in place.
What works across windows, and what does not
- Works: content state (counters, inputs, scroll) survives popping out and docking back;
splitters, tabs, keyboard and maximize work inside the window; the window layouts are saved by
model.get("layout-json")underwindowsand reopened bycreateModelorlayout.load(the browser may block a window opened without a user gesture). - Drags: tabs drag between the main window and a popout, and between popouts, like between tabsets. See Drag across windows and layouts.
- Libraries that use the global
document: popped-out content lives in the popout's document, butdocumentandwindowin your code (and in your libraries) are still the main window's. A dialog, popover, tooltip or toast that portals intodocument.bodyopens in the main window, behind the popout; a drag library or an outside-click handler that listens onwindowhears nothing from the popout. The layout cannot fix that for them. Give such a library the content's own document: a portal container ofelement.ownerDocument.body, listeners onelement.ownerDocumentor itsdefaultView(most portal-based components take a container prop for this).onPopoutOpenhands you the popout'swindowanddocumentfor set-up that must happen once per window.