PanelDock

Panels docked at the bottom of a region, for work beside the page.

AlphaSince 1.0.0
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 children is 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: onModeChange with the mode a panel asks for, onClose, onActivate when people press or focus inside a panel that isn't active, and onHeightChange with a new height in pixels.
  • Open panels sit side by side at the bottom inline-end, the newest at the end.
DocumentDrafts.tsx
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>
  );
}

PanelDock.Header is each panel's sticky header.

  • Its title is a heading at headingLevel, 2 by default, with meta below it.
  • The application's actions take the ghost variant where they set none.
  • The window controls, Minimize, Maximize or Restore, and Close, follow them; controls={false} leaves them out.
  • Its children stay with the header below the title row, such as Tabs.

Tabs

A minimized panel is a tab.

  • label gives the tab a shorter text than the title, and icon an icon.
  • tooltip gives it the full title.
  • attention gives 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 asks onModeChange to 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 height sets it, and people resize it between minHeight, 15 rem, and maxHeight, the dock's room; onHeightChange reports 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-stacked and onStackedChange, and to make the panels it leaves out inert.
  • The dock is an inline-size container, so an unnamed @container query in panel content resolves against it.
  • An em set on the dock itself scales with stackBelow, as the dock's font size is stackBelow / 37.5.

Composition

Put the dock in a positioned container with a height, or directly in a Scaffold's body.

  • Set inert on the content beneath while a panel is maximized.
  • minimizedLabel, openLabel, closeLabel and resizeLabel name 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 inert on the content beneath while a panel fills the region.
  • A PanelDock.Header with a title in each panel.
  • attention text for a tab's dot, and a tooltip where 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 separator with its height and its share of the room.
  • Tabs in a list named Hidden panels, each a Show button described by its attention text.
  • 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.

PropTypeDescription
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
(panel: Panel) => ReactNode
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
(id: string, mode: PanelDockMode) => void
Called with the mode a panel asks for, from its window controls and its tab, and from the dock itself with overflow="minimize".
onCloserequired
(id: string) => void
Called from a panel's close control and its tab's close button.
onActivate
(id: string) => void
Called when people press or focus inside a panel that is not active, and when its tab brings it back.
onHeightChange
(id: string, height: number) => void
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
(stacked: boolean) => void
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
(label: string) => string
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
(label: string) => string
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
(title: string) => string
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.

PropTypeDescription
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-mode
maximized · minimized · open on env-panel-dock-panel, env-panel-dock-tab
data-overflowing
Present on env-panel-dock-row
data-panel-id
Any value, or present on env-panel-dock-panel, env-panel-dock-tab
data-placement
fill · hidden · row on env-panel-dock-panel
data-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 }) => string A minimized panel's button, such as Show Notes.
messages.panelDock.close
({ label }) => string A minimized panel's close button, such as Close Notes.
messages.panelDock.resize
({ label }) => string A 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.Root and PanelDock.Header; PanelDockControls is internal, controls is a boolean and the controls' names come from EnvUIProvider's messages.panelDock. Minimized panels are tabs, env-panel-dock-tab and data-tabs, where they were chips, and a header's application buttons are ghost by default. showLabel, which names a tab's button, is openLabel. A panel's resize handle, env-panel-dock-resize, is env-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, minHeight and maxHeight, and the height onHeightChange reports, are in rem, where they were pixels: divide them by 16. A number for stackBelow is rem too.