Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/badge/Badge.tsx
iris 88c69f2953 shared badge: drop the disclosure chevron
mara, #3757: remove the chevrons. aria-expanded's own outline/bg
stays as the only affordance.
2026-08-30 13:52:34 +02:00

115 lines
4.2 KiB
TypeScript

// <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.
//
// Formerly two components: swarm-ui had its own presentational-only
// `StatusChip`, a strict subset of this shape (no interactivity, no
// label/value split, no icon). Deleted in favour of this one — a
// non-interactive `Badge` renders identically, and having two components
// for the same visual pill just meant a page had to pick between them
// with no real distinction to justify it. Lives 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).
import type { ComponentChildren } from 'preact';
import './Badge.css';
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
/**
* `'default'` — the filled pill every status/picker badge has always
* been. `'quiet'` — no permanent fill, only a background on hover/
* `expanded`; for an icon-only trigger that should read as chrome (a
* header button) rather than a status chip — mara: "link and settings
* button should not have the badge bg" against both the agent page's
* `MetaNav` and the shared `SettingsMenu` (swarm-ui + agent both use
* it). Same `variant` naming/shape as `Button`'s
* `ButtonVariant` (`../../swarm-ui/src/ui/button/Button.tsx`) — visual
* weight is orthogonal to `tone` (color semantics), so it's its own prop
* rather than a new `tone` value.
*/
export type BadgeVariant = 'default' | 'quiet';
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;
variant?: BadgeVariant;
/** 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),
* `aria-expanded` reflecting `expanded`. Absent → renders a plain
* `<span>` (status display only, e.g. "alive"/"idle 6m"). No visible
* disclosure caret (mara: "remove the chevrons") — `hover` +
* `aria-expanded`'s outline/bg (Badge.css) are the only affordance.
*/
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',
variant = 'default',
icon,
onClick,
expanded,
disabled,
class: extraClass,
title,
}: BadgeProps) {
const classes = [
'ui-badge',
`ui-badge-${tone}`,
variant === 'quiet' && 'ui-badge-quiet',
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>
</>
);
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>
);
}