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:
parent
60d7e8ce84
commit
7003560569
11 changed files with 646 additions and 400 deletions
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
26
frontend/packages/swarm-ui/src/ui/card/Card.css
Normal file
26
frontend/packages/swarm-ui/src/ui/card/Card.css
Normal 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));
|
||||||
|
}
|
||||||
64
frontend/packages/swarm-ui/src/ui/card/Card.tsx
Normal file
64
frontend/packages/swarm-ui/src/ui/card/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/packages/swarm-ui/src/ui/split-view/SplitView.css
Normal file
14
frontend/packages/swarm-ui/src/ui/split-view/SplitView.css
Normal 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;
|
||||||
|
}
|
||||||
33
frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx
Normal file
33
frontend/packages/swarm-ui/src/ui/split-view/SplitView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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") —
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue