fix(design): a toggle is pressed, never a bare aria-pressed; static labels are flat Badges with a tone, edged label spans lint-refused (E28)
This commit is contained in:
@@ -4,13 +4,23 @@ import type { ReactNode } from "react";
|
||||
// A static label (a topic, a keyword, a genre, a status): flat and tonal, with NO edge (owner, E28 S3d).
|
||||
// Every chip that does something has an edge, so "edged = pressable, flat = just says" holds at a
|
||||
// glance; a static tag painted like a chip is clicked and does nothing. The tint is the text colour
|
||||
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's.
|
||||
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's. A
|
||||
// `tone` says which kind of label it is (a role, a state to notice): its own colour at 6%, the most
|
||||
// that keeps its text at 4.5:1 on light (theme.contract.test.ts measures all three in every scheme).
|
||||
const TONES = {
|
||||
neutral: "bg-fg/8 text-muted",
|
||||
accent: "bg-accent/6 text-accent",
|
||||
warn: "bg-warn/6 text-warn",
|
||||
} as const;
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
onDark = false,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: keyof typeof TONES;
|
||||
onDark?: boolean;
|
||||
title?: string;
|
||||
/** Layout only (margin); the paint is the primitive's. */
|
||||
@@ -22,7 +32,7 @@ export function Badge({
|
||||
title={title}
|
||||
className={clsx(
|
||||
"inline-flex h-5 max-w-full shrink-0 items-center gap-1 truncate rounded-full px-2 text-xs whitespace-nowrap [&>svg]:size-3 [&>svg]:shrink-0",
|
||||
onDark ? "bg-white/10 text-white/90" : "bg-fg/8 text-muted",
|
||||
onDark ? "bg-white/10 text-white/90" : TONES[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -261,6 +261,9 @@ describe("Button accessible-name contract (type level)", () => {
|
||||
<Button icon={<span />} aria-label="by aria-label" />
|
||||
<Button icon={<span />} title="by title" />
|
||||
<Button>plain text needs none</Button>
|
||||
{/* @ts-expect-error a toggle states itself through `pressed`, which also paints it (B3) */}
|
||||
<Button aria-pressed>raw toggle</Button>
|
||||
<Button pressed>toggle</Button>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "by label" })).toBeTruthy();
|
||||
|
||||
@@ -15,7 +15,9 @@ import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from
|
||||
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
||||
// users. Adding one would double the ring.
|
||||
|
||||
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
// A toggle says so through `pressed`, never a bare `aria-pressed`: the prop also paints the pressed
|
||||
// state (B3), and a raw attribute left the Server log's Live toggle looking like any outline button.
|
||||
interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-pressed"> {
|
||||
variant?: ButtonVariant;
|
||||
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
|
||||
size?: ButtonSize;
|
||||
@@ -28,6 +30,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
loading?: boolean;
|
||||
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
||||
pressed?: boolean;
|
||||
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||
"aria-pressed"?: never;
|
||||
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
|
||||
* `media` variant. */
|
||||
round?: boolean;
|
||||
|
||||
@@ -96,6 +96,10 @@ describe("IconButton", () => {
|
||||
<IconButton label="named">
|
||||
<svg />
|
||||
</IconButton>
|
||||
{/* @ts-expect-error a toggle states itself through `pressed`, which also tints it (B3) */}
|
||||
<IconButton label="raw toggle" aria-pressed>
|
||||
<svg />
|
||||
</IconButton>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "named" })).toBeTruthy();
|
||||
|
||||
@@ -11,7 +11,9 @@ import { iconButtonClass, type IconButtonSize, type IconButtonVariant } from "./
|
||||
// pill field it sits inside — `round`), and nothing else. The glyph
|
||||
// is sized here, so a row of them never mixes 14 and 16px icons.
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
// `pressed`, never a bare `aria-pressed`: the prop is what tints a toggled glyph (see Button).
|
||||
export interface IconButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "aria-pressed"> {
|
||||
/** The accessible name and the tooltip. Required: the button renders no text. */
|
||||
label: string;
|
||||
/** The glyph (a lucide element); the primitive sizes it. */
|
||||
@@ -20,6 +22,8 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
||||
size?: IconButtonSize;
|
||||
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
|
||||
pressed?: boolean;
|
||||
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
|
||||
"aria-pressed"?: never;
|
||||
/** Sits on a picture or inside a pill field: round (A2). Implied by the `media` variant. */
|
||||
round?: boolean;
|
||||
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
|
||||
|
||||
Reference in New Issue
Block a user