Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
iris 8a4c613e4e swarm-ui: drop the redundant outer dialog card, move close into the panel header
Dialog and Panel both drew their own bordered/backgrounded card in the
same --bg-elev, so a Panel-based dialog (create-agent, link-matrix-account)
rendered as two concentric cards with a floating close button on the
outer one and no purpose to it.

Give Dialog a "plain" mode that drops its own card chrome (border,
background, padding) and floating close button, and give Panel an
optional onClose that renders a close button at the end of its own
header row instead. AgentsPage's two Panel-backed dialogs now use
plain + Panel's onClose, so the Panel is the dialog's only visible
card. ConfirmDialog (no Panel of its own) is unaffected — plain
defaults to false, unchanged card + floating close button.

Added a ComponentsPage sample demonstrating the plain + onClose
pairing. Verified both dialog modes via a real headless-chromium
screenshot (plain dialog: single card, close button in the header
bar; default dialog: unchanged floating close button).
2026-09-13 00:58:53 +02:00

530 lines
16 KiB
TypeScript

// <ComponentsPage> — living style guide for every ui primitive this
// package ships. One section per component, each mode it supports
// rendered side by side with a caption naming the mode — so a visual
// regression or a missing variant is obvious at a glance, and a new
// primitive gets a section here the same day it's added. Sample data
// only, no network calls — this page must render the same whether
// swarm-controller's API is up or not.
import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Card } from "../ui/card/Card.js";
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
import {
MultiselectFilter,
type MultiselectFilterState,
} from "../ui/multiselect-filter/MultiselectFilter.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { SplitView } from "../ui/split-view/SplitView.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { TextField } from "../ui/text-field/TextField.js";
import {
SelectField,
type SelectOption,
} from "../ui/select-field/SelectField.js";
import { Button, type ButtonVariant } from "../ui/button/Button.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { GearIcon } from "@hive/shared/icons.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import "./ComponentsPage.css";
function Section({
title,
children,
}: {
title: string;
children: ComponentChildren;
}) {
return (
<section class="components-section">
<h2 class="components-section-title">{title}</h2>
<div class="components-section-body">{children}</div>
</section>
);
}
function Sample({
label,
children,
}: {
label: string;
children: ComponentChildren;
}) {
return (
<div class="components-sample">
<div class="components-sample-label">{label}</div>
<div class="components-sample-content">{children}</div>
</div>
);
}
interface Row {
name: string;
detail: string;
}
const TABLE_COLUMNS: TableColumn<Row>[] = [
{ key: "name", header: "name", render: (r) => r.name },
{
key: "detail",
header: "detail",
render: (r) => r.detail,
// One filterable column, multiselect mode — the checkbox-list popover
// + reset-filters button are both real `Table` behavior an operator
// hits daily (`AgentsPage`/`HivesPage`), so the demo shows them rather
// than a plain unfilterable grid.
filterValues: (r) => [r.detail],
filterMode: "multiselect",
},
{
key: "status",
header: "status",
render: () => <Badge tone="positive" value="ok" />,
},
];
const TABLE_ROWS: Row[] = [
{ name: "alpha", detail: "sample row one" },
{ name: "beta", detail: "sample row two" },
];
const SELECT_OPTIONS: SelectOption[] = [
{ value: "alpha", label: "alpha" },
{ value: "beta", label: "beta" },
];
const BUTTON_VARIANTS: ButtonVariant[] = ["primary", "default"];
const BADGE_TONES: BadgeTone[] = [
"neutral",
"positive",
"warning",
"negative",
"accent",
];
const MODEL_OPTIONS: DropdownOption[] = [
{ value: "haiku", label: "haiku", description: "fast" },
{ value: "sonnet", label: "sonnet", description: "balanced" },
{ value: "opus", label: "opus", description: "powerful" },
];
// Controlled samples need their own state to actually type/select into —
// module-level consts can't do that, hence these two small wrappers
// rather than inline JSX in the page body below.
function TextFieldSample() {
const [value, setValue] = useState("");
return (
<TextField
id="sample-text-field"
label="agent name"
value={value}
onInput={setValue}
/>
);
}
function SelectFieldSample() {
const [value, setValue] = useState("alpha");
return (
<SelectField
id="sample-select-field"
label="hive"
value={value}
onChange={setValue}
options={SELECT_OPTIONS}
/>
);
}
function RefreshIntervalPickerSample() {
const [value, setValue] = useState<RefreshIntervalMs>(30_000);
return (
<RefreshIntervalPicker
id="sample-refresh-interval"
value={value}
onChange={setValue}
/>
);
}
// The badge-triggers-a-dropdown-right-underneath-itself pattern this is
// meant to land — the model/effort picker shape from the per-agent
// terminal, demoed here with sample data. The wrapper
// needs `position: relative` (`.components-badge-anchor`) so `Dropdown`
// anchors under this badge specifically, not the page.
function BadgePickerSample() {
const [value, setValue] = useState("sonnet");
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
return (
<div class="components-badge-anchor" ref={anchorRef}>
<Badge
label="model"
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
<Dropdown
open={open}
options={MODEL_OPTIONS}
activeValue={value}
label="select model"
onSelect={(v) => {
setValue(v);
setOpen(false);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
/>
</div>
);
}
// The other interactive shape: a badge that's itself the control (no
// dropdown) — e.g. the agent page's pause/resume action folded into the
// status row instead of living in the `⋯` overflow menu.
function BadgeToggleSample() {
const [paused, setPaused] = useState(false);
return (
<Badge
value={paused ? "▶ resume" : "⏸ pause"}
tone={paused ? "warning" : "neutral"}
onClick={() => setPaused((p) => !p)}
/>
);
}
// `Dialog` and `ConfirmDialog` both need `open` state to actually toggle —
// same "controlled sample needs its own wrapper" reasoning as
// `TextFieldSample` above.
function DialogSample() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>open dialog</Button>
<Dialog open={open} onClose={() => setOpen(false)} label="sample dialog">
<p>
Plain content — <code>Dialog</code> supplies the modal wiring (focus
trap, Escape, backdrop-click) and the close button; the caller owns
everything else, including its own heading if it wants one.
</p>
</Dialog>
</>
);
}
// `plain`: a `Panel`'s own header bar/border is the dialog's
// only card, not a second one nested inside `Dialog`'s own — pair
// `plain` with `Panel`'s `onClose` so the close button still exists,
// just moved into that header instead of floating over the whole thing.
function PlainDialogSample() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>open plain dialog</Button>
<Dialog
open={open}
onClose={() => setOpen(false)}
label="sample plain dialog"
plain
>
<Panel
title="a form-shaped dialog"
icon="🔗"
onClose={() => setOpen(false)}
>
<p>
One card, one header bar — the close button lives here instead of
floating over a second, purposeless outer one.
</p>
</Panel>
</Dialog>
</>
);
}
// Own state for the same reason `TextFieldSample` needs it — a
// controlled sample has to actually respond to interaction.
function MultiselectFilterSample() {
const [filter, setFilter] = useState<MultiselectFilterState>({
values: [],
negate: false,
});
return (
<MultiselectFilter
label="hive"
options={["pr1ma", "sec0nd"]}
filter={filter}
onToggle={(v) =>
setFilter((f) => ({
...f,
values: f.values.includes(v)
? f.values.filter((x) => x !== v)
: [...f.values, v],
}))
}
onNegateChange={(negate) => setFilter((f) => ({ ...f, negate }))}
/>
);
}
// A `view` toggle here is caller-owned (same contract `AgentsPage` uses)
// — `FilterableView` itself only renders whichever one this sample
// says to. Reuses `TABLE_COLUMNS`/`TABLE_ROWS` from the `Table` section
// above so the filter behavior is directly comparable between the two.
function FilterableViewSample() {
const [view, setView] = useState<"cards" | "table">("cards");
return (
<div>
<div class="components-chip-row" style={{ marginBottom: "0.5em" }}>
<Button
variant={view === "cards" ? "primary" : "default"}
onClick={() => setView("cards")}
>
cards
</Button>
<Button
variant={view === "table" ? "primary" : "default"}
onClick={() => setView("table")}
>
table
</Button>
</div>
<FilterableView
columns={TABLE_COLUMNS}
rows={TABLE_ROWS}
rowKey={(r) => r.name}
storageKey="swarm-ui:components-demo:filterable-view"
view={view}
emptyMessage="no rows"
renderCard={(r) => (
<Card onClick={() => {}}>
<strong>{r.name}</strong>
<div>{r.detail}</div>
</Card>
)}
/>
</div>
);
}
function ConfirmDialogSample() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>open confirm dialog</Button>
<ConfirmDialog
open={open}
label="sample confirm dialog"
onCancel={() => setOpen(false)}
onConfirm={() => setOpen(false)}
confirmLabel="confirm"
>
<p>
The message is caller-owned <code>children</code>, same as{" "}
<code>Dialog</code> — only the button row and spacing are shared.
</p>
</ConfirmDialog>
</>
);
}
export function ComponentsPage() {
return (
<Panel title="components" icon="🧰">
<p class="components-intro">
Every primitive in <code>src/ui/</code>, shown in each mode it supports.
Sample data only — nothing here calls the API.
</p>
<Section title="Panel">
<Sample label="with title">
<Panel title="example title">panel body content</Panel>
</Sample>
<Sample label="without title">
<Panel>panel body content, no title</Panel>
</Sample>
<Sample label="with icon (per panel, no default — see the doc comment)">
<Panel title="example title" icon="✨">
panel body content
</Panel>
</Sample>
<Sample label="with actions (e.g. HivesPage's refresh picker)">
<Panel
title="example title"
actions={
<RefreshIntervalPicker
id="sample-panel-actions"
value={30_000}
onChange={() => {}}
/>
}
>
panel body content
</Panel>
</Sample>
</Section>
<Section title="SplitView">
<Sample label="primary + secondary, side by side (wraps to stacked on a narrow viewport)">
<SplitView
primary={
<Panel title="list">
primary content — e.g. AgentsPage's roster
</Panel>
}
secondary={
<Panel title="detail">
secondary content — e.g. the selected row
</Panel>
}
/>
</Sample>
</Section>
<Section title="Table">
<Sample label="populated">
<Table
columns={TABLE_COLUMNS}
rows={TABLE_ROWS}
rowKey={(r) => r.name}
storageKey="swarm-ui:components-demo:table-filters"
/>
</Sample>
<Sample label="empty">
<Table
columns={TABLE_COLUMNS}
rows={[]}
rowKey={(r) => r.name}
storageKey="swarm-ui:components-demo:table-filters-empty"
/>
</Sample>
</Section>
<Section title="Card">
<div class="components-chip-row">
<Sample label="static (no onClick)">
<Card>
<strong>alpha</strong>
<div>plain content, not clickable</div>
</Card>
</Sample>
<Sample label="interactive">
<Card onClick={() => {}}>
<strong>beta</strong>
<div>click, or Enter/Space when focused</div>
</Card>
</Sample>
<Sample label="selected (e.g. the row shown in a detail panel)">
<Card selected onClick={() => {}}>
<strong>gamma</strong>
<div>same card, with selected set</div>
</Card>
</Sample>
</div>
</Section>
<Section title="MultiselectFilter">
<Sample label="trigger + checkbox popover">
<MultiselectFilterSample />
</Sample>
</Section>
<Section title="FilterableView">
<Sample label="filter bar (built from every filterable column) + cards/table, same filter state either way">
<FilterableViewSample />
</Sample>
</Section>
<Section title="RelativeTime">
<div class="components-chip-row">
<Sample label="5s ago, ticking">
<RelativeTime epochMs={Date.now() - 5_000} />
</Sample>
<Sample label="2h ago, ticking">
<RelativeTime epochMs={Date.now() - 2 * 3_600_000} />
</Sample>
</div>
</Section>
<Section title="RefreshIntervalPicker">
<Sample label="editable">
<RefreshIntervalPickerSample />
</Sample>
</Section>
<Section title="TextField">
<Sample label="editable">
<TextFieldSample />
</Sample>
</Section>
<Section title="SelectField">
<Sample label="editable">
<SelectFieldSample />
</Sample>
</Section>
<Section title="Button">
<div class="components-chip-row">
{BUTTON_VARIANTS.map((variant) => (
<Sample key={variant} label={variant}>
<Button variant={variant}>{variant}</Button>
</Sample>
))}
<Sample label="disabled">
<Button disabled>disabled</Button>
</Sample>
</div>
</Section>
<Section title="Dialog">
<Sample label="modal, caller-owned content">
<DialogSample />
</Sample>
<Sample label="plain — content supplies its own card + close">
<PlainDialogSample />
</Sample>
</Section>
<Section title="ConfirmDialog">
<Sample label="modal, cancel/confirm button row">
<ConfirmDialogSample />
</Sample>
</Section>
<Section title="Badge (@hive/shared — used by swarm-ui and the per-agent page)">
<div class="components-chip-row">
{BADGE_TONES.map((tone) => (
<Sample key={tone} label={`display, ${tone}`}>
<Badge value={tone} tone={tone} />
</Sample>
))}
<Sample label="with a label prefix">
<Badge label="ctx" value="534k" />
</Sample>
<Sample label="interactive, opens a Dropdown (model/effort picker shape)">
<BadgePickerSample />
</Sample>
<Sample label="interactive, toggles itself (pause/resume shape)">
<BadgeToggleSample />
</Sample>
<Sample label="interactive, quiet variant (icon-only header trigger, e.g. settings)">
<Badge
value={<GearIcon />}
title="settings"
variant="quiet"
onClick={() => {}}
/>
</Sample>
</div>
</Section>
</Panel>
);
}