Add Badge + Dropdown Preact components, demo on swarm-ui /components

New shared Preact primitives for the per-agent terminal redesign:

- Badge (@hive/shared/badge.js): a labelled status pill. Plain <span>
  when static (e.g. "alive"), a real <button> with a disclosure caret
  when given onClick (e.g. opens a Dropdown, or toggles itself in
  place). Same component either way so a status row reads as one
  consistent set of badges regardless of which are interactive.
- Dropdown (@hive/shared/dropdown.js): a small option list anchored
  directly under whatever opened it (no portal, no native <dialog> —
  see the file comment for why). Closes on outside click or Escape.

This is the fix for the design guide's own named anti-example: the
agent page's model/effort pickers live in the overflow menu while the
current model/effort only show as a disconnected chip. Badge+Dropdown
composed together is that control moved inline, next to what it shows.

Demoed on swarm-ui's /components page: all Badge tones, a label-prefixed
badge, an interactive badge that opens a Dropdown (model-picker shape),
and an interactive badge that toggles itself (pause/resume shape).

Both packages build + tsc --noEmit clean.
This commit is contained in:
iris 2026-08-28 01:39:32 +02:00 committed by mara
commit 62cc0620c8
7 changed files with 365 additions and 1 deletions

View file

@ -42,3 +42,10 @@
font-size: 0.8em;
color: var(--muted);
}
/* Anchor for a Badge + the Dropdown it opens (@hive/shared) Dropdown
positions `absolute` against this box, see Dropdown.css's file-top
comment. */
.components-badge-anchor {
position: relative;
display: inline-block;
}

View file

@ -15,6 +15,8 @@ import { Table, type TableColumn } from '../ui/table/Table.js';
import { TextField } from '../ui/text-field/TextField.js';
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
import { Button, type ButtonVariant } from '../ui/button/Button.js';
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import './ComponentsPage.css';
function Section({ title, children }: { title: string; children: ComponentChildren }) {
@ -59,6 +61,13 @@ const SELECT_OPTIONS: SelectOption[] = [
];
const BUTTON_VARIANTS: ButtonVariant[] = ['primary', 'default'];
const BADGE_TONES: BadgeTone[] = ['neutral', 'positive', 'warning', 'negative', 'accent'];
const MODEL_OPTIONS: DropdownOption[] = [
{ value: 'haiku', label: 'haiku', description: 'fast' },
{ value: 'sonnet', label: 'sonnet', description: 'balanced' },
{ value: 'opus', label: 'opus', description: 'powerful' },
];
// Controlled samples need their own state to actually type/select into —
// module-level consts can't do that, hence these two small wrappers
@ -86,6 +95,51 @@ function RefreshIntervalPickerSample() {
return <RefreshIntervalPicker id="sample-refresh-interval" value={value} onChange={setValue} />;
}
// The badge-triggers-a-dropdown-right-underneath-itself pattern this is
// meant to land — the model/effort picker shape from the per-agent
// terminal, demoed here with sample data. The wrapper
// needs `position: relative` (`.components-badge-anchor`) so `Dropdown`
// anchors under this badge specifically, not the page.
function BadgePickerSample() {
const [value, setValue] = useState('sonnet');
const [open, setOpen] = useState(false);
return (
<div class="components-badge-anchor">
<Badge
label="model"
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
<Dropdown
open={open}
options={MODEL_OPTIONS}
activeValue={value}
label="select model"
onSelect={(v) => {
setValue(v);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</div>
);
}
// The other interactive shape: a badge that's itself the control (no
// dropdown) — e.g. the agent page's pause/resume action folded into the
// status row instead of living in the `⋯` overflow menu.
function BadgeToggleSample() {
const [paused, setPaused] = useState(false);
return (
<Badge
value={paused ? '▶ resume' : '⏸ pause'}
tone={paused ? 'warning' : 'neutral'}
onClick={() => setPaused((p) => !p)}
/>
);
}
export function ComponentsPage() {
return (
<Panel title="components" icon="🧰">
@ -176,6 +230,25 @@ export function ComponentsPage() {
</Sample>
</div>
</Section>
<Section title="Badge (@hive/shared — used by swarm-ui and the per-agent page)">
<div class="components-chip-row">
{BADGE_TONES.map((tone) => (
<Sample key={tone} label={`display, ${tone}`}>
<Badge value={tone} tone={tone} />
</Sample>
))}
<Sample label="with a label prefix">
<Badge label="ctx" value="534k" />
</Sample>
<Sample label="interactive, opens a Dropdown (model/effort picker shape)">
<BadgePickerSample />
</Sample>
<Sample label="interactive, toggles itself (pause/resume shape)">
<BadgeToggleSample />
</Sample>
</div>
</Section>
</Panel>
);
}