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.
137 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|