swarm-ui: extract Card/MultiselectFilter/SplitView per component-first design

Mara: "result looks like the shape i am looking for, but the code does
not. you did not follow component first principle" - the card's
clickable/selectable mechanics, the card-view filter trigger+popover,
and the list+detail split layout were all one-off page-local JSX in
AgentsPage.tsx instead of docs/web-ui/design-guide.md's "Component-first
design" primitives. Three new ui/ components, each with a same-day
/components demo section per that doc's own rule:

- ui/card/Card.tsx - the role=button/keyboard-activation/selected
  mechanics AgentCard now wraps agent-specific content around, instead
  of owning them itself.
- ui/multiselect-filter/MultiselectFilter.tsx - the checkbox-list
  trigger+popover control. This was also a straight duplicate of
  Table's own inline popover content once AgentsPage's filter toolbar
  needed the identical thing; Table now renders the same
  MultiselectFilterOptions piece too (keeping its own th-anchored
  trigger and fixed+portal positioning, which are genuinely
  table-specific), not a second copy.
- ui/split-view/SplitView.tsx - the list+detail flex-wrap layout, no
  opinion on what's inside either pane.

AgentsPage.tsx's own CSS shrinks to just the agent-specific content
inside these primitives (card line/message layout, detail-panel field
grid, the name-search input) - the container/positioning rules moved
to each component's own colocated CSS.

No behavior change for any other Table caller (HivesPage,
IssueReportPage, the components demo's own Table samples) - the
popover's visual output is identical, just sourced from the shared
component instead of inline JSX.

Verified: typecheck/build clean, real screenshots of both AgentsPage
(pixel-identical to before) and the three new /components sections,
plus a live click confirming MultiselectFilter's popover opens
correctly on the demo page too.
This commit is contained in:
iris 2026-09-11 22:14:21 +02:00
commit 7003560569
11 changed files with 646 additions and 400 deletions

View file

@ -2,7 +2,9 @@
old table body (mara: "more like card per agent"). Full-width rows, old table body (mara: "more like card per agent"). Full-width rows,
not a grid: the second line (the agent's free-form status message) not a grid: the second line (the agent's free-form status message)
reads better wrapping the card's own width than squeezed into a fixed reads better wrapping the card's own width than squeezed into a fixed
grid cell. */ grid cell. Each card's own clickable/selected/border styling is
`Card`'s (`ui/card/`) this file only lays out agent-specific
content inside one. */
.ui-agent-card-list { .ui-agent-card-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -45,29 +47,6 @@
.ui-agents-view-toggle button:not(.active):hover { .ui-agents-view-toggle button:not(.active):hover {
color: var(--fg); color: var(--fg);
} }
.ui-agent-card {
display: flex;
flex-direction: column;
gap: 0.35em;
padding: 0.75em 1em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
cursor: pointer;
}
.ui-agent-card:hover,
.ui-agent-card:focus-visible {
border-color: var(--purple);
outline: none;
}
/* The row currently shown in the detail panel a solid border (not
just the hover tint above, which needs to keep meaning "hovering",
not double as "selected") plus a faint fill so it still reads once
the pointer moves away. */
.ui-agent-card-selected {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
}
.ui-agent-card-line1 { .ui-agent-card-line1 {
display: flex; display: flex;
align-items: center; align-items: center;
@ -110,27 +89,11 @@
gap: 0.75em; gap: 0.75em;
} }
/* List panel + detail panel side by side `flex-wrap`, not a
`@media` breakpoint, so a narrow viewport stacks them the same
content-driven way the shell's own nav already wraps (`Shell.css`),
rather than picking a second, independent magic-number breakpoint. */
.ui-agents-layout {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: flex-start;
}
.ui-agents-list-panel {
flex: 2 1 480px;
min-width: 0;
}
.ui-agents-detail-panel {
flex: 1 1 320px;
min-width: 0;
}
/* Card view's filter toolbar — `Table`'s per-column popovers, minus the /* Card view's filter toolbar — `Table`'s per-column popovers, minus the
table to hang them off of. */ table to hang them off of. Each `MultiselectFilter` (`ui/
multiselect-filter/`) owns its own trigger/popover styling; this file
only lays out the row they sit in plus the name-search input, which
has no shared-component equivalent (a plain text input). */
.ui-agents-filter-bar { .ui-agents-filter-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -147,17 +110,3 @@
font: inherit; font: inherit;
font-size: 0.9em; font-size: 0.9em;
} }
.ui-agents-filter {
position: relative;
}
/* `.ui-table-filter-popover` (Table.css) supplies the panel chrome
(background/border/shadow) this just positions it under the
trigger, `position: absolute` rather than the fixed/portal recipe
`Table`'s own version needs: nothing here clips this popover, so the
simpler positioning is enough (see this component's own comment). */
.ui-agents-filter-popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25em;
}

View file

@ -28,7 +28,7 @@
// //
// The "wanted" column is one `WantedMenu` badge+dropdown per row — see // The "wanted" column is one `WantedMenu` badge+dropdown per row — see
// that component's own comment above `AgentsPage` for why. // that component's own comment above `AgentsPage` for why.
import { useEffect, useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
@ -36,8 +36,10 @@ import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { LinkIcon } from "@hive/shared/icons.js"; import { LinkIcon } from "@hive/shared/icons.js";
import { Button } from "../ui/button/Button.js"; import { Button } from "../ui/button/Button.js";
import { Card } from "../ui/card/Card.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../ui/dialog/Dialog.js"; import { Dialog } from "../ui/dialog/Dialog.js";
import { MultiselectFilter } from "../ui/multiselect-filter/MultiselectFilter.js";
import { Panel } from "../ui/panel/Panel.js"; import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import { import {
@ -45,6 +47,7 @@ import {
useRefreshInterval, useRefreshInterval,
type RefreshIntervalMs, type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js"; } from "../ui/refresh-interval/RefreshInterval.js";
import { SplitView } from "../ui/split-view/SplitView.js";
import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js"; import { Table, useTableFilters, type TableColumn } from "../ui/table/Table.js";
import { CreateAgentForm } from "./CreateAgentForm.js"; import { CreateAgentForm } from "./CreateAgentForm.js";
import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js"; import { LinkMatrixAccountForm } from "./LinkMatrixAccountForm.js";
@ -279,12 +282,11 @@ function WantedMenu({
// esbuild config can't consume those yet (same gap `Dialog.tsx`'s own // esbuild config can't consume those yet (same gap `Dialog.tsx`'s own
// comment already flags for `hive-dialog`). Revisit once that closes. // comment already flags for `hive-dialog`). Revisit once that closes.
// //
// A `role="button"` div, not a real `<button>`: the card also hosts the // Built on the shared `Card` primitive (`ui/card/`) for the clickable/
// real `<button>`s inside `WantedMenu`, and nested buttons are invalid // selectable-container mechanics — the `stopPropagation` wrapper around
// HTML. `onOpenDetail` fires for a click/Enter/Space landing on the card // `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// itself; the `stopPropagation` wrapper around `WantedMenu` keeps its own // `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// clicks (mouse or keyboard-synthesized) from also opening the panel, and // `onClick` and opening the detail panel.
// the `e.target !== e.currentTarget` guard below does the same for keys.
function AgentCard({ function AgentCard({
row, row,
pending, pending,
@ -310,21 +312,7 @@ function AgentCard({
}) { }) {
const { tone, label } = FRESHNESS[row.freshness]; const { tone, label } = FRESHNESS[row.freshness];
return ( return (
<div <Card selected={selected} onClick={() => onOpenDetail(row)}>
class={
selected ? "ui-agent-card ui-agent-card-selected" : "ui-agent-card"
}
role="button"
tabIndex={0}
onClick={() => onOpenDetail(row)}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetail(row);
}
}}
>
<div class="ui-agent-card-line1"> <div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span> <span class="ui-agent-card-name">{row.name}</span>
<Badge <Badge
@ -367,18 +355,19 @@ function AgentCard({
<div class="ui-agent-card-message"> <div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"} {row.snapshot?.status_text ?? "—"}
</div> </div>
</div> </Card>
); );
} }
// One `<input>` (the "name" column's plain-text filter) plus one // One `<input>` (the "name" column's plain-text filter) plus one shared
// `FilterMultiselect` per multiselect-mode filterable column — the card // `MultiselectFilter` (`ui/multiselect-filter/`) per multiselect-mode
// view's answer to `Table`'s own per-column popover icons, driving the // filterable column — the card view's answer to `Table`'s own
// exact same `useTableFilters` state `AgentsPage` passes down (mara: // per-column popover icons, driving the exact same `useTableFilters`
// "same filters for the cards tho"). Deliberately not a generic // state `AgentsPage` passes down (mara: "same filters for the cards
// "render every filterable column automatically" loop: the name column's // tho"). Deliberately not a generic "render every filterable column
// free-text filter needs its own always-visible `<input>`, not a // automatically" loop: the name column's free-text filter needs its own
// dropdown, so there's no single shape that covers every column anyway. // always-visible `<input>`, not a dropdown, so there's no single shape
// that covers every column anyway.
function AgentFilterBar({ function AgentFilterBar({
columns, columns,
getFilter, getFilter,
@ -421,7 +410,7 @@ function AgentFilterBar({
} }
/> />
{hiveCol ? ( {hiveCol ? (
<FilterMultiselect <MultiselectFilter
label="hive" label="hive"
options={multiselectOptionsFor(hiveCol)} options={multiselectOptionsFor(hiveCol)}
filter={getFilter("hive")} filter={getFilter("hive")}
@ -430,7 +419,7 @@ function AgentFilterBar({
/> />
) : null} ) : null}
{statusCol ? ( {statusCol ? (
<FilterMultiselect <MultiselectFilter
label="status" label="status"
options={multiselectOptionsFor(statusCol)} options={multiselectOptionsFor(statusCol)}
filter={getFilter("status")} filter={getFilter("status")}
@ -439,7 +428,7 @@ function AgentFilterBar({
/> />
) : null} ) : null}
{wantedCol ? ( {wantedCol ? (
<FilterMultiselect <MultiselectFilter
label="wanted" label="wanted"
options={multiselectOptionsFor(wantedCol)} options={multiselectOptionsFor(wantedCol)}
filter={getFilter("wanted")} filter={getFilter("wanted")}
@ -460,91 +449,6 @@ function AgentFilterBar({
); );
} }
// One multiselect filter's trigger + popover — same checkbox-list markup
// `Table`'s own popover renders (`.ui-table-filter-checkbox`/
// `-negate`, reused rather than duplicated), just `position: absolute`
// under a `Badge` trigger instead of a portal: this never sits inside a
// clipping scroll container or a `<dialog>` the way `WantedMenu`'s
// portal mode has to route around, so the simpler positioning is enough.
function FilterMultiselect({
label,
options,
filter,
onToggle,
onNegateChange,
}: {
label: string;
options: string[];
filter: { values: string[]; negate: boolean };
onToggle: (value: string) => void;
onNegateChange: (negate: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(e: PointerEvent) {
if (e.target instanceof Node && ref.current?.contains(e.target)) return;
setOpen(false);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div class="ui-agents-filter" ref={ref}>
<Badge
variant="quiet"
value={
filter.values.length > 0
? `${label} (${filter.values.length})`
: label
}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
{open ? (
<div
class="ui-agents-filter-popover ui-table-filter-popover"
role="dialog"
aria-label={`filter by ${label}`}
>
<div class="ui-table-filter-multiselect">
{options.map((v) => (
<label key={v} class="ui-table-filter-checkbox">
<input
type="checkbox"
checked={filter.values.includes(v)}
onChange={() => onToggle(v)}
/>
{v}
</label>
))}
</div>
<label class="ui-table-filter-negate">
<input
type="checkbox"
checked={filter.negate}
onChange={(e) =>
onNegateChange((e.target as HTMLInputElement).checked)
}
/>
exclude
</label>
</div>
) : null}
</div>
);
}
export function AgentsPage() { export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null); const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
@ -865,209 +769,217 @@ export function AgentsPage() {
return ( return (
<> <>
<div class="ui-agents-layout"> <SplitView
<Panel primary={
title="agents" <Panel
icon="👥" title="agents"
class="ui-agents-list-panel" icon="👥"
actions={ actions={
<> <>
<Button variant="primary" onClick={() => setCreateOpen(true)}> <Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent + agent
</Button> </Button>
<div class="ui-agents-view-toggle" role="group" aria-label="view"> <div
<button class="ui-agents-view-toggle"
type="button" role="group"
aria-pressed={viewMode === "cards"} aria-label="view"
class={viewMode === "cards" ? "active" : undefined}
onClick={() => setViewMode("cards")}
> >
cards <button
</button> type="button"
<button aria-pressed={viewMode === "cards"}
type="button" class={viewMode === "cards" ? "active" : undefined}
aria-pressed={viewMode === "table"} onClick={() => setViewMode("cards")}
class={viewMode === "table" ? "active" : undefined} >
onClick={() => setViewMode("table")} cards
> </button>
table <button
</button> type="button"
</div> aria-pressed={viewMode === "table"}
<RefreshIntervalPicker class={viewMode === "table" ? "active" : undefined}
id="agents-refresh" onClick={() => setViewMode("table")}
value={intervalMs} >
onChange={setIntervalMs} table
</button>
</div>
<RefreshIntervalPicker
id="agents-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</>
}
>
{error ? (
<ApiErrorPanel
context="failed to load the agent roster"
problem={error}
/> />
</> ) : null}
} {!error && rows === null ? <p>loading</p> : null}
> {rows && rows.length === 0 ? (
{error ? ( <p class="ui-agents-empty">
<ApiErrorPanel no agents yet the swarm-wide identity store has no agents
context="failed to load the agent roster" registered on any hive
problem={error} </p>
/> ) : null}
) : null} {/* Card view's own filter toolbar table view keeps its
{!error && rows === null ? <p>loading</p> : null}
{rows && rows.length === 0 ? (
<p class="ui-agents-empty">
no agents yet the swarm-wide identity store has no agents
registered on any hive
</p>
) : null}
{/* Card view's own filter toolbar table view keeps its
existing per-column popovers instead (see `columns` above); existing per-column popovers instead (see `columns` above);
both read/write the same `useTableFilters` state (same both read/write the same `useTableFilters` state (same
`storageKey`), so switching the view toggle doesn't reset or `storageKey`), so switching the view toggle doesn't reset or
hide whatever's filtered. */} hide whatever's filtered. */}
{rows && rows.length > 0 && viewMode === "cards" ? ( {rows && rows.length > 0 && viewMode === "cards" ? (
<AgentFilterBar <AgentFilterBar
columns={columns} columns={columns}
getFilter={getFilter} getFilter={getFilter}
updateFilter={updateFilter} updateFilter={updateFilter}
toggleFilterValue={toggleFilterValue} toggleFilterValue={toggleFilterValue}
resetFilters={resetFilters} resetFilters={resetFilters}
activeFilters={activeFilters} activeFilters={activeFilters}
multiselectOptionsFor={multiselectOptionsFor} multiselectOptionsFor={multiselectOptionsFor}
/> />
) : null} ) : null}
{rows && rows.length > 0 && viewMode === "cards" ? ( {rows && rows.length > 0 && viewMode === "cards" ? (
<div class="ui-agent-card-list"> <div class="ui-agent-card-list">
{visibleRows.length === 0 ? ( {visibleRows.length === 0 ? (
<p class="ui-agents-empty">no rows match the current filter</p> <p class="ui-agents-empty">
) : ( no rows match the current filter
visibleRows.map((a) => ( </p>
<AgentCard ) : (
key={a.name} visibleRows.map((a) => (
row={a} <AgentCard
pending={pendingAgents.has(a.name)} key={a.name}
error={actionErrors.get(a.name)} row={a}
selected={detailTarget?.name === a.name} pending={pendingAgents.has(a.name)}
onSelectUp={selectUp} error={actionErrors.get(a.name)}
onSelectOffline={selectOffline} selected={detailTarget?.name === a.name}
onSelectPaused={selectPaused} onSelectUp={selectUp}
onOpenDetail={setDetailTarget} onSelectOffline={selectOffline}
/> onSelectPaused={selectPaused}
)) onOpenDetail={setDetailTarget}
)} />
</div> ))
) : null} )}
{rows && rows.length > 0 && viewMode === "table" ? ( </div>
<Table ) : null}
columns={columns} {rows && rows.length > 0 && viewMode === "table" ? (
rows={rows} <Table
rowKey={(a) => a.name} columns={columns}
storageKey="swarm-ui:agents:table-filters" rows={rows}
/> rowKey={(a) => a.name}
) : null} storageKey="swarm-ui:agents:table-filters"
</Panel> />
{/* A second on-page `Panel`, not a modal mara: "why no separate ) : null}
panel? ... i mean a second panel on agent page." Always </Panel>
mounted (an empty state when nothing's selected) rather than }
conditionally rendered, so picking an agent never causes the // A second on-page `Panel`, not a modal — mara: "why no separate
page's own layout to jump. `.ui-agents-layout`'s `flex-wrap` // panel? ... i mean a second panel on agent page." Always mounted
stacks this below the list on a narrow viewport, same // (an empty state when nothing's selected) rather than
content-driven-not-a-fixed-breakpoint approach the shell's own // conditionally rendered, so picking an agent never causes the
nav already uses (`Shell.css`), rather than a new media query. */} // page's own layout to jump. `SplitView`'s `flex-wrap` stacks
<Panel // this below the list on a narrow viewport, same content-driven-
title={detailTarget ? detailTarget.name : "agent details"} // not-a-fixed-breakpoint approach the shell's own nav already
icon="🔎" // uses (`Shell.css`), rather than a new media query.
class="ui-agents-detail-panel" secondary={
> <Panel
{detailTarget ? ( title={detailTarget ? detailTarget.name : "agent details"}
<div class="ui-agent-detail"> icon="🔎"
<dl class="ui-agent-detail-fields"> >
<dt>status</dt> {detailTarget ? (
<dd> <div class="ui-agent-detail">
{(() => { <dl class="ui-agent-detail-fields">
const { tone, label } = FRESHNESS[detailTarget.freshness]; <dt>status</dt>
return ( <dd>
<Badge {(() => {
tone={tone} const { tone, label } = FRESHNESS[detailTarget.freshness];
value={ return (
<> <Badge
{label} tone={tone}
{detailTarget.last_seen_unix !== null ? ( value={
<> <>
{" "} {label}
( {detailTarget.last_seen_unix !== null ? (
<RelativeTime <>
epochMs={detailTarget.last_seen_unix * 1000} {" "}
/> (
) <RelativeTime
</> epochMs={detailTarget.last_seen_unix * 1000}
) : null} />
</> )
} </>
/> ) : null}
); </>
})()} }
</dd> />
<dt>message</dt> );
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd> })()}
<dt>wanted</dt> </dd>
<dd> <dt>message</dt>
{/* Full menu (destroy included) mara: "destroy is <dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) mara: "destroy is
already available via wanted state", no separate already available via wanted state", no separate
button needed. */} button needed. */}
<WantedMenu <WantedMenu
row={detailTarget} row={detailTarget}
pending={pendingAgents.has(detailTarget.name)} pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp} onSelectUp={selectUp}
onSelectOffline={selectOffline} onSelectOffline={selectOffline}
onSelectPaused={selectPaused} onSelectPaused={selectPaused}
onDestroy={setDestroyTarget} onDestroy={setDestroyTarget}
/>
</dd>
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{detailTarget.config_pr ? (
<Badge
tone="warning"
value={
detailTarget.config_pr.html_url ? (
<a
href={detailTarget.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{detailTarget.config_pr.pr_number}
</a>
) : (
`#${detailTarget.config_pr.pr_number}`
)
}
/> />
) : ( </dd>
"—" <dt>hive</dt>
)} <dd>{detailTarget.hive ?? "—"}</dd>
</dd> <dt>config PR</dt>
</dl> <dd>
<div class="ui-agent-detail-actions"> {detailTarget.config_pr ? (
<Badge <Badge
variant="quiet" tone="warning"
icon={<LinkIcon />} value={
value="link matrix account" detailTarget.config_pr.html_url ? (
onClick={ <a
detailTarget.hive href={detailTarget.config_pr.html_url}
? () => setMatrixTarget(detailTarget) target="_blank"
: undefined rel="noreferrer"
} >
disabled={!detailTarget.hive} #{detailTarget.config_pr.pr_number}
title={ </a>
detailTarget.hive ) : (
? `link a matrix account to ${detailTarget.name}` `#${detailTarget.config_pr.pr_number}`
: "no hive on record for this agent — nothing to link against" )
} }
/> />
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link matrix account"
onClick={
detailTarget.hive
? () => setMatrixTarget(detailTarget)
: undefined
}
disabled={!detailTarget.hive}
title={
detailTarget.hive
? `link a matrix account to ${detailTarget.name}`
: "no hive on record for this agent — nothing to link against"
}
/>
</div>
</div> </div>
</div> ) : (
) : ( <p class="ui-agents-empty">select an agent to see its details</p>
<p class="ui-agents-empty">select an agent to see its details</p> )}
)} </Panel>
</Panel> }
</div> />
<Dialog <Dialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}

View file

@ -7,12 +7,18 @@
// swarm-controller's API is up or not. // swarm-controller's API is up or not.
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
import { Card } from "../ui/card/Card.js";
import {
MultiselectFilter,
type MultiselectFilterState,
} from "../ui/multiselect-filter/MultiselectFilter.js";
import { Panel } from "../ui/panel/Panel.js"; import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
type RefreshIntervalMs, type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js"; } from "../ui/refresh-interval/RefreshInterval.js";
import { SplitView } from "../ui/split-view/SplitView.js";
import { Table, type TableColumn } from "../ui/table/Table.js"; import { Table, type TableColumn } from "../ui/table/Table.js";
import { TextField } from "../ui/text-field/TextField.js"; import { TextField } from "../ui/text-field/TextField.js";
import { import {
@ -212,6 +218,31 @@ function DialogSample() {
); );
} }
// 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 }))}
/>
);
}
function ConfirmDialogSample() { function ConfirmDialogSample() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@ -269,6 +300,23 @@ export function ComponentsPage() {
</Sample> </Sample>
</Section> </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"> <Section title="Table">
<Sample label="populated"> <Sample label="populated">
<Table <Table
@ -288,6 +336,35 @@ export function ComponentsPage() {
</Sample> </Sample>
</Section> </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="RelativeTime"> <Section title="RelativeTime">
<div class="components-chip-row"> <div class="components-chip-row">
<Sample label="5s ago, ticking"> <Sample label="5s ago, ticking">

View file

@ -0,0 +1,26 @@
.ui-card {
display: flex;
flex-direction: column;
gap: 0.35em;
padding: 0.75em 1em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
}
/* Only an interactive `Card` (has `onClick`) gets pointer/hover
affordance a plain static one shouldn't look clickable. */
[role="button"].ui-card {
cursor: pointer;
}
[role="button"].ui-card:hover,
[role="button"].ui-card:focus-visible {
border-color: var(--purple);
outline: none;
}
/* A solid border (not just the hover tint above, which needs to keep
meaning "hovering", not double as "selected") plus a faint fill so a
selected card still reads once the pointer moves away. */
.ui-card-selected {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 10%, var(--bg-elev));
}

View file

@ -0,0 +1,64 @@
// <Card> — the clickable/selectable-container mechanics (role="button",
// keyboard activation, hover + selected styling) any card-shaped list
// item needs, with zero opinion on its content — same "primitive owns
// the mechanics, caller owns the content" split as `Panel`/`Dialog`.
// Extracted out of `AgentsPage`'s `AgentCard` (design-guide's
// "Component-first design": a page had hand-rolled this rather than
// reaching for a `src/ui/` primitive) — `AgentCard` now wraps agent-
// specific content (name/badges/message) around this instead of owning
// the click/keyboard/selected logic itself.
//
// A `role="button"` `<div>`, not a real `<button>`: a card's content
// commonly hosts its own real `<button>`s/interactive controls (a
// status badge that opens a menu, say), and nested buttons are invalid
// HTML. `onClick` fires for a click/Enter/Space landing on the card
// itself; a caller whose content has its own interactive controls needs
// to `stopPropagation` on those (see `AgentsPage`'s `WantedMenu` wrapper
// for the pattern) and should guard its own `onKeyDown`, if any, with an
// `e.target !== e.currentTarget` check the same way this component does
// internally — nothing here can do that guarding on a caller's behalf.
import type { ComponentChildren } from "preact";
import "./Card.css";
export function Card({
selected,
onClick,
children,
class: extraClass,
}: {
/** Highlight styling the caller decides what "selected" means (the
* row shown in a detail panel, say); this component just renders it. */
selected?: boolean;
/** Present a real interactive card (role="button", keyboard-
* activatable). Absent a plain static container, same border/
* padding/radius, no click/keyboard wiring at all. */
onClick?: () => void;
children: ComponentChildren;
class?: string;
}) {
const classes = ["ui-card", selected && "ui-card-selected", extraClass]
.filter(Boolean)
.join(" ");
if (!onClick) {
return <div class={classes}>{children}</div>;
}
return (
<div
class={classes}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
>
{children}
</div>
);
}

View file

@ -0,0 +1,51 @@
/* Checkbox-list content same visual treatment as `Table`'s own
popover content (`Table.css`'s `.ui-table-filter-multiselect`/
`-checkbox`), colocated here instead of reached-across-files since
`Table` no longer renders this markup itself (see this component's
file-top comment). */
.ui-multiselect-filter-options {
display: flex;
flex-direction: column;
gap: 0.3em;
max-height: 12em;
overflow-y: auto;
}
.ui-multiselect-filter-checkbox {
display: flex;
align-items: center;
gap: 0.35em;
cursor: pointer;
font-size: 0.9em;
}
.ui-multiselect-filter-negate {
display: flex;
align-items: center;
gap: 0.35em;
margin-top: 0.5em;
padding-top: 0.5em;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
}
/* The standalone control (trigger + popover) `position: relative`/
`absolute` under the trigger, not `Table`'s fixed+portal recipe:
nothing here sits inside a clipping ancestor (see file-top comment),
so the simpler positioning is enough. */
.ui-multiselect-filter {
position: relative;
}
.ui-multiselect-filter-popover {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25em;
z-index: 10;
min-width: 12em;
padding: 0.5em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}

View file

@ -0,0 +1,140 @@
// <MultiselectFilter> / <MultiselectFilterOptions> — the checkbox-list
// "pick any of these values" control every multiselect-mode filter in
// this package needs, split into a content-only piece and a full
// standalone control around it.
//
// Extracted out of `Table`'s own per-column filter popover (design-
// guide's "Component-first design") once `AgentsPage`'s card-view filter
// toolbar needed the identical checkbox list and nearly duplicated it —
// exactly the "next contributor duplicates ad-hoc styling, someone has
// to hunt it down and consolidate" scenario that section warns about.
// `Table` keeps its own trigger (an icon button inside a `<th>`) and its
// own positioning (`position: fixed` + portal, to escape
// `.ui-table-scroll`'s clip — see that file's own comment), genuinely
// different concerns from a plain toolbar's; only the checkbox-list
// *content* moves here, as `MultiselectFilterOptions`. A caller with no
// clipping context and no header row to anchor an icon to — a plain
// toolbar, `AgentsPage`'s first use — reaches for `MultiselectFilter`
// instead: the same content, wrapped in its own `Badge` trigger and a
// much simpler `position: absolute` popover.
import { useEffect, useRef, useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import "./MultiselectFilter.css";
export interface MultiselectFilterState {
values: string[];
negate: boolean;
}
export function MultiselectFilterOptions({
options,
filter,
onToggle,
}: {
options: string[];
filter: MultiselectFilterState;
onToggle: (value: string) => void;
}) {
return (
<div class="ui-multiselect-filter-options">
{options.map((v) => (
<label key={v} class="ui-multiselect-filter-checkbox">
<input
type="checkbox"
checked={filter.values.includes(v)}
onChange={() => onToggle(v)}
/>
{v}
</label>
))}
</div>
);
}
// The negate toggle — mara: "you should be able to change if the filter
// is negated or not (eg search for this text vs exclude it)", same
// reasoning as `Table`'s own copy. Small enough (one checkbox+label)
// that keeping it inline here rather than a third exported piece isn't
// worth the indirection; `Table` keeps rendering its own copy for text-
// mode filters, which don't otherwise touch this file at all.
function NegateToggle({
negate,
onChange,
}: {
negate: boolean;
onChange: (negate: boolean) => void;
}) {
return (
<label class="ui-multiselect-filter-negate">
<input
type="checkbox"
checked={negate}
onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
/>
exclude
</label>
);
}
export function MultiselectFilter({
label,
options,
filter,
onToggle,
onNegateChange,
}: {
label: string;
options: string[];
filter: MultiselectFilterState;
onToggle: (value: string) => void;
onNegateChange: (negate: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(e: PointerEvent) {
if (e.target instanceof Node && ref.current?.contains(e.target)) return;
setOpen(false);
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
return (
<div class="ui-multiselect-filter" ref={ref}>
<Badge
variant="quiet"
value={
filter.values.length > 0
? `${label} (${filter.values.length})`
: label
}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
{open ? (
<div
class="ui-multiselect-filter-popover"
role="dialog"
aria-label={`filter by ${label}`}
>
<MultiselectFilterOptions
options={options}
filter={filter}
onToggle={onToggle}
/>
<NegateToggle negate={filter.negate} onChange={onNegateChange} />
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,14 @@
.ui-split-view {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: flex-start;
}
.ui-split-view-primary {
flex: 2 1 480px;
min-width: 0;
}
.ui-split-view-secondary {
flex: 1 1 320px;
min-width: 0;
}

View file

@ -0,0 +1,33 @@
// <SplitView> — a primary/secondary pane laid out side by side, wrapping
// to stacked on a narrow viewport. Extracted out of `AgentsPage`'s own
// list+detail row (design-guide's "Component-first design": a page had
// built this inline for its own list/detail split, `AgentsPage`'s
// `Panel`s reaching for it is the first real use, per the doc's "build
// the primitive before or alongside the first real page that needs it").
//
// `flex-wrap`, not a `@media` breakpoint — content-driven stacking, same
// approach the shell's own nav row already uses (`shell/Shell.css`)
// rather than a second, independent magic-number breakpoint. No opinion
// on what's inside either pane (a `Panel`, anything else) — same "own
// the mechanics, not the content" split as `Card`.
import type { ComponentChildren } from "preact";
import "./SplitView.css";
export function SplitView({
primary,
secondary,
}: {
/** The wider pane a list, a table, whatever the page's main content
* is. `flex: 2` against `secondary`'s `flex: 1`. */
primary: ComponentChildren;
/** The narrower pane a detail view, a summary, anything that reads
* as "more about the thing selected in `primary`." */
secondary: ComponentChildren;
}) {
return (
<div class="ui-split-view">
<div class="ui-split-view-primary">{primary}</div>
<div class="ui-split-view-secondary">{secondary}</div>
</div>
);
}

View file

@ -141,25 +141,9 @@
font-size: 0.9em; font-size: 0.9em;
} }
/* `filterMode: "multiselect"` a scrollable checkbox list rather than /* `filterMode: "multiselect"`'s actual checkbox-list content is
a `<select multiple>`, which needs a modifier key to pick more than `MultiselectFilterOptions` (`ui/multiselect-filter/`) now its own
one option and reads as unfamiliar to most operators; a plain colocated CSS, not here. */
checkbox list needs neither. `max-height` + scroll keeps a
long option set (many labels) from pushing the popover off-screen. */
.ui-table-filter-multiselect {
display: flex;
flex-direction: column;
gap: 0.3em;
max-height: 12em;
overflow-y: auto;
}
.ui-table-filter-checkbox {
display: flex;
align-items: center;
gap: 0.35em;
cursor: pointer;
font-size: 0.9em;
}
/* The negate toggle sits under every filter mode's own control (mara: /* The negate toggle sits under every filter mode's own control (mara:
"you should be able to change if the filter is negated or not") "you should be able to change if the filter is negated or not")

View file

@ -14,6 +14,7 @@ import { createPortal } from "preact/compat";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
import { FilterIcon } from "@hive/shared/icons.js"; import { FilterIcon } from "@hive/shared/icons.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js"; import { useLocalSetting } from "@hive/shared/settings-storage.js";
import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js";
import "./Table.css"; import "./Table.css";
export interface TableColumn<T> { export interface TableColumn<T> {
@ -426,21 +427,16 @@ export function Table<T>({
function renderFilterControl(c: TableColumn<T>) { function renderFilterControl(c: TableColumn<T>) {
if (c.filterMode === "multiselect") { if (c.filterMode === "multiselect") {
const f = getFilter(c.key); // Content shared with `AgentsPage`'s card-view filter toolbar —
// see `MultiselectFilter.tsx`'s own file-top comment for why this
// moved out of here.
return ( return (
<> <>
<div class="ui-table-filter-multiselect"> <MultiselectFilterOptions
{multiselectOptionsFor(c).map((v) => ( options={multiselectOptionsFor(c)}
<label key={v} class="ui-table-filter-checkbox"> filter={getFilter(c.key)}
<input onToggle={(v) => toggleFilterValue(c.key, v)}
type="checkbox" />
checked={f.values.includes(v)}
onChange={() => toggleFilterValue(c.key, v)}
/>
{v}
</label>
))}
</div>
{renderNegateToggle(c)} {renderNegateToggle(c)}
</> </>
); );