Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/shared/src/badge/Badge.tsx
iris 3ff61e49b8 shared Badge: disabled without onClick now renders a real disabled button
argus caught this reviewing PR #4110: Badge only applied the disabled
prop inside its onClick-present branch (a real <button disabled>).
When onClick is undefined -- exactly the case every current caller
hits when its gating condition is false, since onClick and disabled
are computed off the same condition -- it fell through to a plain
<span> that never reads disabled and never gets the
.ui-badge-interactive class the dimming CSS depends on. WantedMenu
and the matrix-account trigger both silently lost the disabled
affordance to this.

Fixed at the root: both the button-vs-span branch and the
interactive-styling class now key off disabled || onClick (extracted
to one interactive flag) instead of onClick alone.

Verified: tsc --noEmit clean on both swarm-ui and agent (Badge's two
consumer packages), nix fmt clean. Screenshot against a throwaway
mock roster (deleted before this commit, never tracked) showing a
hiveless row and a destroyed row both visibly dimmed now, next to an
enabled row at full opacity -- previously all three looked identical.
2026-09-08 14:00:07 +02:00

137 lines
5.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.
//
// Replaces swarm-ui's own presentational-only `StatusChip`, a strict
// subset of this shape (no interactivity, no label/value split, no
// icon) — a non-interactive `Badge` renders identically, so having two
// components for the same visual pill 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 (or `disabled` is true — see below) → renders a `<button>`
* (real control, min touch target), `aria-expanded` reflecting
* `expanded`. Both 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 `aria-expanded`. */
expanded?: boolean;
/**
* Renders as a real `<button disabled>` even with no `onClick` —
* every current caller computes both off the same gating condition
* (`onClick={x ? fn : undefined}`, `disabled={!x}`), so `onClick`
* going `undefined` is exactly when this is `true`. A `disabled`
* badge still means "there's an action here, not right now," which
* is button semantics + the dimming CSS below, not a plain
* non-interactive span (argus, filed as its own issue —
* `WantedMenu`'s and `LinkMatrixAccountForm`'s trigger, both of which
* silently lost the dimmed affordance to this exact gap).
*/
disabled?: boolean;
class?: string;
title?: string;
}
export function Badge({
label,
value,
tone = "neutral",
variant = "default",
icon,
onClick,
expanded,
disabled,
class: extraClass,
title,
}: BadgeProps) {
// `disabled` alone still means "button, just not clickable right
// now" — see the prop's own doc above for why this can't just check
// `onClick`. Both the branch below and the interactive-styling class
// key off this one derived flag, not off `onClick` separately, so
// they can no longer drift apart the way they used to.
const interactive = Boolean(onClick) || Boolean(disabled);
const classes = [
"ui-badge",
`ui-badge-${tone}`,
variant === "quiet" && "ui-badge-quiet",
interactive && "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 (interactive) {
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>
);
}