The details open under the agent badge without dimming or blocking the page; a click outside or Escape closes it. New ui/popover primitive (anchored, portal, no backdrop) with a sample on the components page. A confirm or link dialog opened from inside it stays modal, and the popover ignores clicks and Escape that belong to that dialog. AgentDetailDialog becomes AgentDetailPopover.
582 lines
17 KiB
TypeScript
582 lines
17 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 { TextAreaField } from "../ui/text-area-field/TextAreaField.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 { Popover } from "../ui/popover/Popover.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" },
|
|
];
|
|
|
|
function TextAreaFieldSample() {
|
|
const [value, setValue] = useState("");
|
|
return (
|
|
<TextAreaField
|
|
id="sample-text-area-field"
|
|
label="message"
|
|
value={value}
|
|
onInput={setValue}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 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, 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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// `Popover` anchors to the trigger it is given and toggles from it, so the
|
|
// sample needs the trigger's ref and its own `open` state.
|
|
function PopoverSample() {
|
|
const [open, setOpen] = useState(false);
|
|
const anchorRef = useRef<HTMLSpanElement>(null);
|
|
return (
|
|
<>
|
|
<span ref={anchorRef}>
|
|
<Button onClick={() => setOpen((o) => !o)}>toggle popover</Button>
|
|
</span>
|
|
<Popover
|
|
open={open}
|
|
onClose={() => setOpen(false)}
|
|
anchorRef={anchorRef}
|
|
label="sample popover"
|
|
>
|
|
<Panel title="a popover" icon="🔗" onClose={() => setOpen(false)}>
|
|
<p>
|
|
Floats under its trigger and leaves the page behind it clickable.
|
|
Outside click or Escape closes it.
|
|
</p>
|
|
</Panel>
|
|
</Popover>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// `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="TextAreaField">
|
|
<Sample label="editable">
|
|
<TextAreaFieldSample />
|
|
</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="Popover">
|
|
<Sample label="anchored, no backdrop, content supplies its own card">
|
|
<PopoverSample />
|
|
</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">
|
|
<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>
|
|
);
|
|
}
|