Cloud|

@k2b/ui

PanelDialog

Contained or floating composition for complex editors.

PanelDialog@k2b/ui

The header icon sits in a soft chip of the app color. Each section is a title above one tinted group; fields and muted fills such as segmented tracks inside it turn white. Scroll the bounded body: header and footer stay in view. Toggle the optional sections: their titles stay in place and edited values stay intact.

Edit project

General settings

Profile

Visible to collaborators

Team members can open the project.

Connection

API settings

TSX

Copy
const [shared, setShared] = createSignal(true);
const [baseUrl, setBaseUrl] = createSignal("");
const [auth, setAuth] = createSignal("token");
const [reference, setReference] = createSignal("");
<PanelDialog surface="contained">
<PanelDialog.Header title="Edit project" subtitle="General settings" icon="ti ti-settings" />
<PanelDialog.Tabs value={tab()} onValueChange={setTab} options={tabOptions} />
<PanelDialog.Body>
<PanelDialog.Section title="Profile" subtitle="Visible to collaborators">
<TextInput label="Name" value="Launch plan" />
<Switch label="Shared with the team" description="Team members can open the project." value={shared()} onValueChange={setShared} />
</PanelDialog.Section>
<PanelDialog.Section hideable defaultOpen title="Connection" subtitle="API settings">
<TextInput label="Base URL" value={baseUrl()} onValueChange={setBaseUrl} placeholder="https://api.example.com/v1" />
<SegmentedControl ariaLabel="Authentication" value={auth} onValueChange={setAuth} options={authOptions} />
</PanelDialog.Section>
<PanelDialog.Section hideable title="Advanced" subtitle="Optional settings">
<TextInput label="Internal reference" value={reference()} onValueChange={setReference} />
</PanelDialog.Section>
</PanelDialog.Body>
<PanelDialog.Footer>
<span />
<div class="flex items-center gap-2">
<Button variant="secondary">Cancel</Button>
<Button>Save</Button>
</div>
</PanelDialog.Footer>
</PanelDialog>

PanelDialog is the layout shell for a complex editor. It keeps the header and footer fixed while the body scrolls.

PanelDialog.Body adds 24 px top/bottom overflow fades only where more content remains. The header and footer stay unmasked. Set scrollFade={false} on the body for an unmasked surface; do not add a second scroll wrapper. Fades update when sections expand, content changes, or the dialog resizes, and are disabled in forced-color mode.

It does not open a dialog, own form state, validate fields, or save data.

For a short dialog that should become a sheet on phones, use bottomSheetAdaptiveOptions.

Use PanelDialog

Use it for editors with several related field groups or a stable action footer.

Use prompts.form for a small form. Use prompts.dialog for a simple picker. Use SettingsModal for tabbed resource settings.

Import

tsx
import {
  Button,
  confirmDiscardIfDirty,
  dialogCore,
  PanelDialog,
  panelDialogFixedOptions,
  panelDialogOptions,
  panelDialogWideOptions,
  panelDialogWorkspaceOptions,
  TextInput,
} from "@k2b/ui";

Choose the dialog frame

Open the shell with dialogCore.open.

  • panelDialogOptions fits one contained editor to its content.
  • panelDialogWideOptions gives a multi-column editor more horizontal room.
  • panelDialogFixedOptions keeps a stable height while tabs or progressive sections change.
  • panelDialogWorkspaceOptions provides a large work area. Below 48rem it fills the screen edge to edge, without frame or radius, and pads its content clear of notches and the home indicator. It takes the width of the viewport without a reserved scrollbar gutter, so it stays centred.

The corresponding panelDialogPanelClass, panelDialogWidePanelClass, panelDialogFixedPanelClass, and panelDialogWorkspacePanelClass exports are the panel-class strings inside those option objects. Prefer the complete option objects with dialogCore. Use a class export only when another compatible host asks for the panel class separately.

surface="contained" is the default modal treatment. surface="floating" is for a standalone placement on a page: the shell draws its own border, the header, body, and footer share a grey surface, and each section is a white card with its icon.

Use PanelDialog.Section for meaningful field groups. Keep the primary save action in PanelDialog.Footer. In a contained dialog a section is its 14 px title and optional 12 px subtitle above one softly tinted, borderless group that holds its content. There is no card, border, or divider. The section icon shows only in floating placement. Fields inside the group (text, textarea, number, select, multi-select, combobox, date, tags, color, and input-style buttons and checkbox cards) turn to the base surface so they stand out from the tint, disabled ones included. The other muted fills do the same: subtle buttons, segmented control tracks, filter chips, and neutral status chips. A hovered field stays on the base surface and shows its border; focus, invalid, and autofill states stay as they are. Switches, checkboxes, dropzones, notices, and code keep their own look. About 24 px separates a section from its neighbours. A section placed first in a grid row keeps its top aligned with the next column. Only the section's own subtitle is muted: fields, notices, and paragraphs in the section keep their own description, error, and text styles. Secondary text must keep 4.5:1 against the tint; --k2b-text-muted and Cloud's text-dimmed do in both themes.

Content in a group should not paint a white card of its own: the group already groups it, and a white card hides the white fields inside. Drop the fill, as for list rows, or give blocks that need an edge, such as a scrolling pre, the base surface. A surface that does paint its own background starts over, and its fields return to the muted well: detail panels, disclosures, choice, date, and dropdown popovers, and Cloud's paper. Set --k2b-field-surface: initial on any other such surface.

Prefer sections for every field in a body that has groups. Loose fields keep the muted well on the dialog surface, so loose fields above a group show two well colours side by side.

A section that is alone in the body, directly or inside a form that is the body's only child, has nothing to be grouped against. It drops the tint and its fields sit on the dialog surface. InlineGuidance and NoticeCard beside it do not count, so an error that appears after a failed save does not turn the section into a group and move its fields. Sections wrapped one by one, for example in grid columns, keep their groups.

A section inside another section's group adds a heading, not a second group. Its title and content line up with the fields around it, and its fields stay white.

Pass hideable to a section for optional settings. A closed section shows only its title row with an eye icon at the trailing edge. Opening it keeps the title at exactly the same place, swaps the eye for an eye-off button in the same slot, and shows the group below. The title gets the same width in both states and wraps the same way, so nothing above the group moves in either direction. Section actions show only while the section is open; on a hideable section they narrow the open title, which can then wrap differently, so leave them off hideable sections. This changes section visibility, not the dialog's surface.

tsx
<PanelDialog.Section hideable title="Connection" subtitle="API settings" icon="ti ti-plug">
  <TextInput label="Base URL" value={baseUrl()} onValueChange={setBaseUrl} />
</PanelDialog.Section>

Hideable sections start closed. Use defaultOpen to start expanded, or control them with open and onOpenChange. The callback requests a change; the owner can refuse it or open a section after a validation error. disabled blocks the toggle, not the fields. Children stay mounted so unsaved edits survive closing. Keyboard focus moves to the visible toggle when the focused control is hidden. Ordinary sections keep their behavior.

Footer children can move onto separate rows when space is limited. Put a hint first and the action group last: the group keeps its width and moves below the hint before its buttons are squeezed. A trailing flex group containing direct buttons can also wrap whole buttons when they cannot fit across the footer on their own.

Use PanelDialog.Tabs only for local views within the editor. Its value may be direct or an accessor; the application updates it through onValueChange. Between header and body the tabs share the dialog's horizontal inset; inside the body or a section they start at the content edge.

Close ownership

Header and footer sit flat on the dialog surface: no grey band and no divider line. Both stay outside the scrolling body, so they remain visible while only the body scrolls; the body's fade is the only cue that content continues. This shares the flat surface and close control of prompts dialogs; PanelDialog uses the larger title for its larger editors. Its header icon sits in a soft chip of the accent ink --k2b-app-workspace-active.

The header uses a 20 px semibold title and a 13 px secondary subtitle. Without a subtitle, it centers the title beside its icon and actions. Headers with a subtitle keep their two-line, top-aligned layout. Action touch targets keep their size. Header, tabs, body, and footer share a 24 px leading inset, 16 px below 48rem; body and footer use it at the trailing edge too. The header's trailing edge is tighter, 20 px and 12 px below 48rem, so the close control sits closer to the corner. Keep filenames and other long identifiers in the subtitle or body; both heading lines wrap. Use whitespace to separate content when additional section frames add no meaning.

Pass the dialog's close callback to PanelDialog.Header. closeDisabled temporarily disables that control, and closeLabel overrides its accessible name.

If the editor can be dirty, call confirmDiscardIfDirty before closing. The application decides what counts as dirty.

Register that same handler with the render context's setDismissHandler so Escape and backdrop clicks cannot bypass it. While saving, the handler should leave the dialog open. Call close directly after a successful save; it is the completion callback, not a discard request. Dismissal handlers may be async and may open a confirmation subdialog. Repeated native dismiss requests are ignored while that handler is pending. cancelBehavior: "ignore" still disables native dismissal entirely.

dialogCore owns the backdrop, focus handling, and Escape behavior. Do not create a second dialog frame around PanelDialog.

API reference

ts
type PanelDialogSurface = "contained" | "floating";

type PanelDialogProps = {
  children: JSX.Element; surface?: PanelDialogSurface;
};

type PanelDialogHeaderProps = {
  title: JSX.Element; subtitle?: JSX.Element; icon?: string; actions?: JSX.Element; close?: () => void;
  closeDisabled?: boolean; closeLabel?: string;
};

type PanelDialogBodyProps = {
  children: JSX.Element; scrollPreserveKey?: string; scrollFade?: boolean;
};

type PanelDialogFooterProps = {
  children: JSX.Element;
};

type PanelDialogSectionProps = {
  title: JSX.Element; subtitle?: JSX.Element; icon?: string; actions?: JSX.Element; children: JSX.Element;
} & (
  | { hideable?: false; open?: never; defaultOpen?: never; onOpenChange?: never; disabled?: never }
  | { hideable: true; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; disabled?: boolean }
);

type PanelDialogTabOption<T extends string = string> = {
  value: T; label: JSX.Element; icon?: string; disabled?: boolean;
};

type PanelDialogTabsProps<T extends string = string> = {
  options: readonly PanelDialogTabOption<T>[]; value: T | (() => T); onValueChange: (value: T) => void;
  ariaLabel?: string; label?: string;
};

Types map to the correspondingly named compound members. PanelDialog supplies geometry, not modal state. For dialogCore.open and its cancellation options see Prompts. Tabs accepts a string value or accessor; the parent applies onValueChange.

Accessibility

Give the header and every section a clear title. Each section is a section labelled by its title. A hideable section toggles through a button with aria-expanded and aria-controls; focus order stays the same open and closed. Header actions need their own accessible names.

Tabs use tab triggers inside a labelled tab list. Pass ariaLabel when the default Dialog tabs does not describe the choices. Disabled options remain visible but cannot be selected.

Runtime

PanelDialog can render layout on the server, but the dialog host, tabs, close controls, hideable sections, and form mutations require browser code.

confirmDiscardIfDirty returns immediately when its boolean or accessor is false. Otherwise it opens the package confirmation prompt and resolves to the user's decision.

Example

tsx
await dialogCore.open<void>(
  (close, context) => {
    const requestClose = async () => {
      if (saving()) return;
      if (await confirmDiscardIfDirty(dirty)) close();
    };
    context.setDismissHandler(requestClose);
    return (
    <PanelDialog>
      <PanelDialog.Header
        title="Edit item"
        icon="ti ti-pencil"
        close={requestClose}
      />
      <PanelDialog.Body scrollPreserveKey="item-editor">
        <PanelDialog.Section title="Basics">
          <TextInput
            label="Title"
            value={title()}
            onValueChange={setTitle}
          />
        </PanelDialog.Section>
      </PanelDialog.Body>
      <PanelDialog.Footer>
        <Button variant="secondary" size="sm" onClick={requestClose}>Cancel</Button>
        <Button size="sm" onClick={save}>Save</Button>
      </PanelDialog.Footer>
    </PanelDialog>
    );
  },
  panelDialogOptions,
);

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.