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).
530 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|