On this page
Import
import { PanelDock } from '@envsemble/envui';
Usage
PanelDock.Root takes the application's panels, in order, each a PanelDockPanel with its id, its title, which names its region and its tab, and its mode, open, minimized or maximized.
- Its
childrenis a function that renders a panel's content from its panel, which mounts once and stays mounted until the panel closes. - The application owns the panels, their order, modes, the active one and their heights, and the dock asks for changes:
onModeChangewith the mode a panel asks for,onClose,onActivatewhen people press or focus inside a panel that isn'tactive, andonHeightChangewith a new height in pixels. - Open panels sit side by side at the bottom inline-end, the newest at the end.
import { useEffect, useState } from 'react';
import {
Button,
IconButton,
Menu,
PanelDock,
Column,
Row,
Surface,
Text,
Textarea,
type PanelDockMode,
type PanelDockPanel,
} from '@envsemble/envui';
import { IconContentCopy } from '@envsemble/envui/icons/ContentCopy';
import { IconDescription } from '@envsemble/envui/icons/Description';
import { IconMoreVert } from '@envsemble/envui/icons/MoreVert';
import { IconShare } from '@envsemble/envui/icons/Share';
interface Document {
id: string;
title: string;
/** A shorter chip label for a long title. */
label?: string;
editor: string;
summary: string;
draft: string;
}
const documents: Document[] = [
{
id: 'report',
title: 'Quarterly report',
editor: 'Alex Morgan',
summary: 'Revenue grew in every region this quarter.',
draft: '',
},
{
id: 'notes',
title: 'Meeting notes',
editor: 'Sam Lee',
summary: 'The team agreed on the dates for the next release.',
draft: '',
},
{
id: 'budget',
title: 'Budget for the second half',
label: 'Budget',
editor: 'Priya Shah',
summary: 'Support hiring trails the plan by two people.',
draft: 'Move the design review to the first week.',
},
{
id: 'roadmap',
title: 'Roadmap',
editor: 'Sam Lee',
summary: 'Search and sharing come first next quarter.',
draft: '',
},
];
interface DocumentPanel extends PanelDockPanel {
document: Document;
}
function panelFor(document: Document, mode: PanelDockMode, active = false): DocumentPanel {
return {
id: document.id,
title: document.title,
label: document.label,
mode,
active,
// The application keeps each panel's height, in pixels; this one leaves the buttons in view.
height: 20,
icon: <IconDescription />,
tooltip: `${document.title} · ${document.editor}`,
document,
};
}
/** A document's window. Its draft lives in the panel's own state, which every mode keeps. */
function DocumentWindow({
document,
onChangedChange,
}: {
document: Document;
onChangedChange: (changed: boolean) => void;
}) {
const [draft, setDraft] = useState(document.draft);
const changed = draft !== '';
// Reports only when the draft starts or stops differing; the callback is new on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => onChangedChange(changed), [changed]);
return (
<>
<PanelDock.Header
title={document.title}
meta={`Edited by ${document.editor}`}
actions={
<Menu.Root
aria-label={`${document.title} actions`}
trigger={<IconButton icon={<IconMoreVert />} aria-label="More actions" />}
>
<Menu.Item icon={<IconShare />}>Share</Menu.Item>
<Menu.Item icon={<IconContentCopy />}>Make a copy</Menu.Item>
</Menu.Root>
}
/>
{/* The body's inset lines up with the header's title. */}
<Column gap={4} style={{ padding: '0 1.5rem 1.5rem' }}>
<Text emphasis="muted">{document.summary}</Text>
<Textarea
label="Draft"
rows={4}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
</Column>
</>
);
}
export default function DocumentDrafts() {
const [panels, setPanels] = useState(() => [
panelFor(documents[0]!, 'open'),
panelFor(documents[1]!, 'open', true),
panelFor(documents[2]!, 'minimized'),
]);
const [stacked, setStacked] = useState(false);
const update = (change: (panel: DocumentPanel) => DocumentPanel) =>
setPanels((current) => current.map(change));
const open = (document: Document) =>
setPanels((current) =>
current.some((panel) => panel.id === document.id)
? current.map((panel) => ({
...panel,
active: panel.id === document.id,
mode: panel.id === document.id && panel.mode === 'minimized' ? 'open' : panel.mode,
}))
: [
...current.map((panel) => ({ ...panel, active: false })),
panelFor(document, 'open', true),
],
);
const markChanged = (id: string, changed: boolean) =>
update((panel) =>
panel.id === id ? { ...panel, attention: changed ? 'Unsaved changes' : undefined } : panel,
);
// The page beneath is out of reach while a panel covers it.
const covered = panels.some((panel) =>
stacked ? panel.mode !== 'minimized' : panel.mode === 'maximized',
);
return (
// The dock fills a positioned region with a height, such as the area below a topbar.
<Surface
background="muted"
padding={0}
style={{ position: 'relative', blockSize: '34rem', overflow: 'hidden' }}
>
<Column gap={4} inert={covered} style={{ padding: '2rem' }}>
<Text render={<h3 />} variant="title">
Documents
</Text>
<Text emphasis="muted">Open documents to work on them beside the page.</Text>
<Row wrap align="center" gap={4}>
{documents.map((document) => (
<Button
key={document.id}
variant="secondary"
startIcon={<IconDescription />}
onClick={() => open(document)}
>
{document.label ?? document.title}
</Button>
))}
</Row>
</Column>
<PanelDock.Root
panels={panels}
onModeChange={(id, mode) =>
// One panel at a time is maximized; maximizing another returns the earlier one.
update((panel) =>
panel.id === id
? { ...panel, mode }
: mode === 'maximized' && panel.mode === 'maximized'
? { ...panel, mode: 'open' }
: panel,
)
}
onClose={(id) => setPanels((current) => current.filter((panel) => panel.id !== id))}
onActivate={(id) => update((panel) => ({ ...panel, active: panel.id === id }))}
onHeightChange={(id, height) =>
update((panel) => (panel.id === id ? { ...panel, height } : panel))
}
onStackedChange={setStacked}
>
{(panel) => (
<DocumentWindow
document={panel.document}
onChangedChange={(changed) => markChanged(panel.id, changed)}
/>
)}
</PanelDock.Root>
</Surface>
);
}Header
PanelDock.Header is each panel's sticky header.
- Its
titleis a heading atheadingLevel, 2 by default, withmetabelow it. - The application's
actionstake the ghost variant where they set none. - The window controls, Minimize, Maximize or Restore, and Close, follow them;
controls={false}leaves them out. - Its
childrenstay with the header below the title row, such as Tabs.
Tabs
A minimized panel is a tab.
labelgives the tab a shorter text than the title, andiconan icon.tooltipgives it the full title.attentiongives it a dot, described by its text, such as Unsaved changes.
Make room
A panel's sizes are numbers of rem, as Sheet's and Panes' are. It takes preferredWidth, 32 rem, where the row has room, and shrinks to minWidth, 20 rem, up to maxWidth, 48 rem; then the row scrolls sideways.
- With
overflow="minimize", the dock instead asksonModeChangeto minimize the open panel active longest ago, one at a time, until the rest fit, and to open it again once there is room. It never asks for the active panel, the newest one or the one that holds focus. - A panel starts at 85% of the dock's room, up to 40 rem, unless
heightsets it, and people resize it betweenminHeight, 15 rem, andmaxHeight, the dock's room;onHeightChangereports the new height in rem. - The tabs float over the bottom of the open panels, so a panel minimized or brought back never moves the others; their content keeps the tabs' height clear at its end, so it scrolls out from beneath them.
- The arrow keys on a resize handle change the height by 16 px, and by 64 px with Shift.
<PanelDock.Root
panels={panels}
overflow="minimize"
onModeChange={changeMode}
onClose={(id) => setPanels((current) => current.filter((panel) => panel.id !== id))}
onActivate={(id) => update((panel) => ({ ...panel, active: panel.id === id }))}
onHeightChange={(id, height) =>
update((panel) => (panel.id === id ? { ...panel, height } : panel))
}
>
{(panel) => <PanelDock.Header title={panel.title} />}
</PanelDock.Root>
Stacking
Below stackBelow, 40 rem of the dock's own width, a CSS length or a number of rem, one panel at a time fills the region and the others wait as tabs.
- A container query on the dock decides it, so the server's markup and the first paint already stack.
- The dock then reads the layout to report it with
data-stackedandonStackedChange, and to make the panels it leaves out inert. - The dock is an inline-size container, so an unnamed
@containerquery in panel content resolves against it. - An
emset on the dock itself scales withstackBelow, as the dock's font size isstackBelow/ 37.5.
Composition
Put the dock in a positioned container with a height, or directly in a Scaffold's body.
- Set
inerton the content beneath while a panel is maximized. minimizedLabel,openLabel,closeLabelandresizeLabelname the list of tabs, a tab's buttons and the resize handles for one dock.
The parts are the dock, env-panel-dock; each panel, env-panel-dock-panel, with its resize handle, env-panel-dock-resize-handle, its header, env-panel-dock-header, which holds the title, env-panel-dock-title, the meta, env-panel-dock-meta, the actions, env-panel-dock-actions, and the window controls, env-panel-dock-controls, and its content, env-panel-dock-content; and the tabs, env-panel-dock-tab, in the strip, env-panel-dock-strip. The dock has data-tabs while it shows tabs.
Accessibility
You provide
- A positioned container with a height, and
inerton the content beneath while a panel fills the region. - A PanelDock.Header with a title in each panel.
attentiontext for a tab's dot, and atooltipwhere labels truncate.
envUI provides
- Each panel as a region named by its title, never a dialog, so focus is never trapped.
- The resize handle as a focusable
separatorwith its height and its share of the room. - Tabs in a list named Hidden panels, each a Show button described by its
attentiontext. - Hidden panels inert, and focus that follows panels as they hide, come back and close.
- Scroll padding that keeps a focused control clear of the sticky header.
Props
PanelDock.Root
Panels docked to the bottom inline-end edge of a region, beside the page, for items people work on alongside it, such as drafts and conversations: open panels side by side, one maximized panel filling the region, and minimized panels as tabs. A panel's content stays mounted through every change of mode and order.
| Prop | Type | Description |
|---|---|---|
panelsrequired | readonly Panel[ | The panels in order, each a PanelDockPanel: open panels sit side by side, the newest at the end, and tabs follow in the same order. The application owns them. |
childrenrequired | ( | Renders a panel's content from its panel, such as a PanelDock.Header above the panel's body. The content mounts once and stays mounted until the panel closes. |
onModeChangerequired | ( | Called with the mode a panel asks for, from its window controls and its tab, and from the dock itself with overflow="minimize". |
onCloserequired | ( | Called from a panel's close control and its tab's close button. |
onActivate | ( | Called when people press or focus inside a panel that is not active, and when its tab brings it back. |
onHeightChange | ( | Called with a panel's new height in rem when a drag on its top edge ends, and on each key press there. |
stackBelowDefault '40rem' | stringnumber | A width, as a CSS length, such as "40rem", or a number of rem, such as 40: while the dock itself is narrower, one panel at a time fills it and the others wait as tabs. The dock then has data-stacked. |
onStackedChange | ( | Called with true when the dock stacks and with false when its panels sit side by side. |
overflowDefault 'scroll' | 'scroll''minimize' | What the dock does when its open panels cannot all sit side by side at their minimum widths: scroll (default) scrolls the row sideways; minimize asks onModeChange to minimize the least recently active open panel, one at a time, until the rest fit, and to open it again once the region widens and it fits. It never asks for the active panel, the newest one or the one that holds focus, so never for a panel alone in the row, and where those cannot fit by themselves the row scrolls instead. |
minimizedLabelDefault 'Hidden panels', or EnvUIProvider's messages.panelDock.minimized | string | Accessible name of the list of tabs, which holds minimized panels and the panels a stacked dock leaves out, for this dock only. |
openLabelDefault Show and the text, such as Show Budget, or EnvUIProvider's messages.panelDock.open | ( | Accessible name of a tab's button that opens its panel, from the tab's text, for this dock only. A function, as each tab names its own panel. |
closeLabelDefault Close and the text, such as Close Budget, or EnvUIProvider's messages.panelDock.close | ( | Accessible name of a tab's close button, from the tab's text, for this dock only. |
resizeLabelDefault Resize and the title, such as Resize Quarterly report, or EnvUIProvider's messages.panelDock.resize | ( | Accessible name of a panel's resize handle, from its title, for this dock only. |
Also accepts the native props of <div>, such as ref, className and aria-* attributes.
PanelDock.Header
The header of a docked panel. It sticks to the top of the panel while its content scrolls: the title and its metadata at the start, the application's actions and the window controls at the end, small like a title bar's.
| Prop | Type | Description |
|---|---|---|
titlerequired | ReactNode | The panel's title, as a heading in 1.125 rem medium-weight text; it wraps between words rather than truncating. |
meta | ReactNode | Supporting text below the title, in 0.875 rem muted text, such as who changed it last. |
actions | ReactNode | The application's own actions, before the window controls, such as an Edit IconButton and a Menu of more; buttons that set no variant take the window controls' ghost one. |
controlsDefault true | boolean | Shows the window controls, Minimize, Maximize or Restore, and Close, inside a PanelDock; false leaves them out. EnvUIProvider's messages.panelDock names them. |
headingLevelDefault 2 | 123456 | The title's heading level in the page outline; the look stays the same. |
children | ReactNode | Content below the title row that stays with the sticky header, such as Tabs. |
Also accepts the native props of <header>, such as ref, className and aria-* attributes.
Part names
Each part's class and state attributes are public and versioned, for an application's CSS to style.
env-panel-dock- The dock of panels.
env-panel-dock-actions- A panel's actions in its header.
env-panel-dock-content- A panel's content.
env-panel-dock-controls- A panel's window controls: minimize, maximize and close.
env-panel-dock-header- A panel's header.
env-panel-dock-header-content- The header's title and meta.
env-panel-dock-heading- A panel's heading.
env-panel-dock-meta- A fact beside a panel's title.
env-panel-dock-panel- One panel.
env-panel-dock-probe- An invisible element that measures the dock's room.
env-panel-dock-resize-handle- The handle that resizes the dock.
env-panel-dock-row- The row of open panels.
env-panel-dock-strip- The strip of minimized panels' tabs.
env-panel-dock-tab- A minimized panel's tab.
env-panel-dock-tab-attention- The mark of a tab that asks for attention.
env-panel-dock-tab-close- A tab's close button.
env-panel-dock-tab-label- A tab's title.
env-panel-dock-tab-restore- The button that restores a minimized panel.
env-panel-dock-title- A panel's title.
data-active- Present on env-panel-dock-panel
data-attention- Present on env-panel-dock-tab
data-entering- Any value, or present on env-panel-dock-panel, env-panel-dock-tab
data-filled- Present
data-modemaximized·minimized·openon env-panel-dock-panel, env-panel-dock-tabdata-overflowing- Present on env-panel-dock-row
data-panel-id- Any value, or present on env-panel-dock-panel, env-panel-dock-tab
data-placementfill·hidden·rowon env-panel-dock-paneldata-resizing- Present on env-panel-dock-panel
data-scrolled- Present on env-panel-dock-header
data-stacked- Present
data-tabs- Present
Strings
EnvUIProvider's messages overrides each, and a component's own label prop wins over it.
messages.panelDock.open({ label }) => stringA minimized panel's button, such as Show Notes.messages.panelDock.close({ label }) => stringA minimized panel's close button, such as Close Notes.messages.panelDock.resize({ label }) => stringA panel's resize handle, such as Resize Notes.messages.panelDock.minimized"Hidden panels"Names the minimized panels.messages.panelDock.minimize"Minimize"A window control.messages.panelDock.maximize"Maximize"A window control.messages.panelDock.restore"Restore"A window control.messages.panelDock.closeWindow"Close"A window control: closes the panel.
Changelog
- 1.0.0
One namespace,
PanelDock.RootandPanelDock.Header; PanelDockControls is internal,controlsis a boolean and the controls' names come from EnvUIProvider'smessages.panelDock. Minimized panels are tabs,env-panel-dock-tabanddata-tabs, where they were chips, and a header's application buttons are ghost by default.showLabel, which names a tab's button, isopenLabel. A panel's resize handle,env-panel-dock-resize, isenv-panel-dock-resize-handle, as every resize handle is named.- 1.0.0-prerelease.6
Colors under the pointer and while pressed change with
--env-motion-ease-enter, rather than easing in slowly, so hover and press show from the first frame.- 1.0.0-prerelease.5
A panel's
height,preferredWidth,minWidth,maxWidth,minHeightandmaxHeight, and the heightonHeightChangereports, are in rem, where they were pixels: divide them by 16. A number forstackBelowis rem too.