Watch
0
0
Fork
You've already forked hyperhive
0

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
2 changed files with 646 additions and 400 deletions

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>
);
}