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:
parent
c4a573d91d
commit
62cc0620c8
7 changed files with 365 additions and 1 deletions
58
frontend/packages/shared/src/badge/Badge.css
Normal file
58
frontend/packages/shared/src/badge/Badge.css
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/* <Badge> — pill-shaped status/control chip. Same touch-target floor as
|
||||
the rest of the shared control set (2.75em ≈ 44px, WCAG 2.5.5) when
|
||||
interactive; a plain display badge (no `onClick`) stays compact since
|
||||
it's not a tap target at all. Colours are the shared base16-derived
|
||||
vars (../theme.css) — `--bg-elev` is the same slot dropdowns/popovers
|
||||
use, so an open Badge and the Dropdown it triggers read as one surface. */
|
||||
.ui-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
padding: 0.15em 0.6em;
|
||||
border-radius: 1em;
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
background: var(--purple-dim);
|
||||
color: var(--fg);
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ui-badge-interactive {
|
||||
cursor: pointer;
|
||||
min-height: 2.75em;
|
||||
padding-inline: 0.8em;
|
||||
}
|
||||
.ui-badge-interactive:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.ui-badge-interactive:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.ui-badge-interactive[aria-expanded='true'] {
|
||||
background: var(--bg-elev);
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.ui-badge-label {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-badge-value {
|
||||
color: var(--fg);
|
||||
}
|
||||
.ui-badge-caret {
|
||||
font-size: 0.75em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-badge-positive .ui-badge-value {
|
||||
color: var(--green);
|
||||
}
|
||||
.ui-badge-warning .ui-badge-value {
|
||||
color: var(--amber);
|
||||
}
|
||||
.ui-badge-negative .ui-badge-value {
|
||||
color: var(--red);
|
||||
}
|
||||
.ui-badge-accent .ui-badge-value {
|
||||
color: var(--purple);
|
||||
}
|
||||
95
frontend/packages/shared/src/badge/Badge.tsx
Normal file
95
frontend/packages/shared/src/badge/Badge.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// <Badge> — a labelled status pill, the "next to the thing it affects"
|
||||
// building block for the per-agent terminal redesign.
|
||||
// Design-guide anti-example this exists to fix: the agent page's model/
|
||||
// effort *pickers* used to live in a `⋯` overflow menu while the current
|
||||
// model/effort only showed as a separate, non-interactive chip — control
|
||||
// disconnected from display. `Badge` is that chip PLUS, optionally, the
|
||||
// control: pass `onClick` and it renders as a real `<button>` (so it can
|
||||
// open a `Dropdown` right underneath itself, or toggle in place — e.g.
|
||||
// pause/resume), pass nothing and it stays a plain status `<span>` (e.g.
|
||||
// "alive"). Same component either way so a page reads as one consistent
|
||||
// row of badges regardless of which ones happen to be interactive.
|
||||
//
|
||||
// Distinct from swarm-ui's own `StatusChip` (`swarm-ui/src/ui/status-chip/`):
|
||||
// that one is presentational-only and swarm-ui-local. This one lives here
|
||||
// in `shared` because both swarm-ui *and* the per-agent page need the
|
||||
// interactive shape, and `shared` is the one package both already depend
|
||||
// on (see `docs/web-ui/design-guide.md`'s component-first + junk-drawer
|
||||
// rules — this is the same visual language, applied where it's actually
|
||||
// consumed).
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Badge.css';
|
||||
|
||||
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
|
||||
|
||||
export interface BadgeProps {
|
||||
/** Dim prefix text, e.g. "model". Omit for a single-value badge like "alive". */
|
||||
label?: ComponentChildren;
|
||||
/** The value itself, e.g. "sonnet". Required — a badge always shows something. */
|
||||
value: ComponentChildren;
|
||||
tone?: BadgeTone;
|
||||
/** Decorative glyph before the label; `aria-hidden`, the text is still the real label. */
|
||||
icon?: ComponentChildren;
|
||||
/**
|
||||
* Present → renders a `<button>` (real control, min touch target) and
|
||||
* shows a disclosure caret. Absent → renders a plain `<span>` (status
|
||||
* display only, e.g. "alive"/"idle 6m").
|
||||
*/
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
/** Only meaningful with `onClick` — drives the caret direction + `aria-expanded`. */
|
||||
expanded?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
icon,
|
||||
onClick,
|
||||
expanded,
|
||||
disabled,
|
||||
class: extraClass,
|
||||
title,
|
||||
}: BadgeProps) {
|
||||
const classes = ['ui-badge', `ui-badge-${tone}`, onClick && 'ui-badge-interactive', extraClass]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const content = (
|
||||
<>
|
||||
{icon ? (
|
||||
<span class="ui-badge-icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
{label ? <span class="ui-badge-label">{label}</span> : null}
|
||||
<span class="ui-badge-value">{value}</span>
|
||||
{onClick ? (
|
||||
<span class="ui-badge-caret" aria-hidden="true">
|
||||
{expanded ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={classes}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span class={classes} title={title}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue