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
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;
|
||||
}
|
||||
|
||||
/* `filterMode: "multiselect"` — a scrollable checkbox list rather than
|
||||
a `<select multiple>`, which needs a modifier key to pick more than
|
||||
one option and reads as unfamiliar to most operators; a plain
|
||||
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;
|
||||
}
|
||||
/* `filterMode: "multiselect"`'s actual checkbox-list content is
|
||||
`MultiselectFilterOptions` (`ui/multiselect-filter/`) now — its own
|
||||
colocated CSS, not here. */
|
||||
|
||||
/* 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") —
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createPortal } from "preact/compat";
|
|||
import type { ComponentChildren } from "preact";
|
||||
import { FilterIcon } from "@hive/shared/icons.js";
|
||||
import { useLocalSetting } from "@hive/shared/settings-storage.js";
|
||||
import { MultiselectFilterOptions } from "../multiselect-filter/MultiselectFilter.js";
|
||||
import "./Table.css";
|
||||
|
||||
export interface TableColumn<T> {
|
||||
|
|
@ -426,21 +427,16 @@ export function Table<T>({
|
|||
|
||||
function renderFilterControl(c: TableColumn<T>) {
|
||||
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 (
|
||||
<>
|
||||
<div class="ui-table-filter-multiselect">
|
||||
{multiselectOptionsFor(c).map((v) => (
|
||||
<label key={v} class="ui-table-filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.values.includes(v)}
|
||||
onChange={() => toggleFilterValue(c.key, v)}
|
||||
/>
|
||||
{v}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<MultiselectFilterOptions
|
||||
options={multiselectOptionsFor(c)}
|
||||
filter={getFilter(c.key)}
|
||||
onToggle={(v) => toggleFilterValue(c.key, v)}
|
||||
/>
|
||||
{renderNegateToggle(c)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue