diff --git a/packages/propel/src/components/badge/badge.stories.tsx b/packages/propel/src/components/badge/badge.stories.tsx index aaf470aa..36d3be0a 100644 --- a/packages/propel/src/components/badge/badge.stories.tsx +++ b/packages/propel/src/components/badge/badge.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Check, Sparkles } from "lucide-react"; +import { expect } from "storybook/test"; import { iconControl } from "../../storybook/icon-control"; import { Icon } from "../icon"; @@ -47,6 +48,17 @@ const meta = { export default meta; type Story = StoryObj; +// A single-row swatch layout, shared by every story below that just lines Badges up side by side +// (`Magnitudes`, `WithLeadingIcon`, `WithTrailingIcon`, `IconOnly`). `Tones` needs `flex-wrap` and +// `PlanBadges` needs a two-row layout, so those keep their own wrapper instead of this decorator. +const rowLayout: Story["decorators"] = [ + (Story) => ( +
+ +
+ ), +]; + export const Default: Story = {}; /** Every color/intent the badge supports, side by side. */ @@ -64,20 +76,22 @@ export const Tones: Story = { /** The three sizes (Figma S / Base / Large). */ export const Magnitudes: Story = { argTypes: { magnitude: { control: false }, label: { control: false } }, + decorators: rowLayout, render: (args) => ( -
+ <> {MAGNITUDES.map((magnitude) => ( ))} -
+ ), }; /** A leading icon node (`startIcon`), sized to the magnitude and tinted to the tone. */ export const WithLeadingIcon: Story = { parameters: { controls: { disable: true } }, + decorators: rowLayout, render: (args) => ( -
+ <> {MAGNITUDES.map((magnitude) => ( ))} -
+ ), }; /** A trailing icon node (`endIcon`), sized + tinted the same as the leading slot. */ export const WithTrailingIcon: Story = { parameters: { controls: { disable: true } }, + decorators: rowLayout, render: (args) => ( -
+ <> {MAGNITUDES.map((magnitude) => ( ))} -
+ + ), +}; + +/** + * Icon-only (no `label`) — the Figma anatomy's "compact status indicator / when space is limited" + * case. The label part is skipped entirely, so the icon sits centered with symmetric padding. The + * icon is decorative (`aria-hidden`), so the pill carries its own `aria-label` — otherwise this + * state has no accessible name at all. + */ +export const IconOnly: Story = { + parameters: { controls: { disable: true } }, + decorators: rowLayout, + render: (args) => ( + <> + {MAGNITUDES.map((magnitude) => ( + } + aria-label="Completed" + /> + ))} + ), }; +/** + * Behavior twin of `IconOnly`: with no label there is no empty label span left in the pill (an + * empty flex child would eat the `gap` and render the icon off-center), the icon sits symmetrically + * — equal space on both sides — and the pill still carries an accessible name via `aria-label`. + * Tagged out of the sidebar/docs/manifest while still running under the default `test` tag. + */ +export const IconOnlyInteraction: Story = { + ...IconOnly, + tags: ["!dev", "!autodocs", "!manifest"], + play: async ({ canvasElement }) => { + const pills = canvasElement.querySelectorAll("div > span"); + // Guards the assertions below from silently no-op'ing if this selector ever stops matching. + await expect(pills.length).toBe(MAGNITUDES.length); + for (const pill of pills) { + // Exactly one child: the icon slot. No empty BadgeLabel span. + await expect(pill.children.length).toBe(1); + const icon = pill.children[0] as HTMLElement; + const pillRect = pill.getBoundingClientRect(); + const iconRect = icon.getBoundingClientRect(); + // Symmetric: leading space == trailing space (within a rounding pixel). + const before = iconRect.left - pillRect.left; + const after = pillRect.right - iconRect.right; + await expect(Math.abs(before - after)).toBeLessThanOrEqual(1); + // The icon is aria-hidden, so the pill's own `aria-label` is the only accessible name. + await expect(pill.getAttribute("aria-label")).toBe("Completed"); + } + }, +}; + // The Figma "Plan Badges" frame's two fills (`plans/brand/*`, `plans/neutral/*`) already // exist on Badge's `tone` axis, so a plan badge is just a tone choice: // paid -> `brand` free -> `grey` diff --git a/packages/propel/src/components/badge/badge.tsx b/packages/propel/src/components/badge/badge.tsx index 6a7a021a..1447438d 100644 --- a/packages/propel/src/components/badge/badge.tsx +++ b/packages/propel/src/components/badge/badge.tsx @@ -6,24 +6,34 @@ import { BadgeLabel, } from "../../elements/badge"; -export type BadgeProps = Omit & { - /** The badge label text. */ - label?: string; +type BadgeOwnProps = Omit & { /** Element rendered before the label (inline-start), e.g. ``. */ startIcon?: React.ReactNode; /** Element rendered after the label (inline-end), e.g. ``. */ endIcon?: React.ReactNode; }; +/** + * Either a text badge or an icon-only one — and the accessible name is required either way. A + * labeled badge is named by its visible text (`aria-label` optional); an icon-only badge (no + * `label`) renders a `role="img"` pill whose name MUST come from `aria-label`, so the union makes + * omitting both a type error rather than a silently unnamed badge. + */ +export type BadgeProps = BadgeOwnProps & + ({ label: string; "aria-label"?: string } | { label?: undefined; "aria-label": string }); + /** * The ready-made badge: composes the atomic `Badge` pill with the `BadgeLabel` and optional leading - * (`startIcon`) and trailing (`endIcon`) icon slots. + * (`startIcon`) and trailing (`endIcon`) icon slots. With no `label` the pill is icon-only — the + * label part is skipped entirely (an empty flex child would still consume the pill's `gap` and + * render it lopsided), and the pill defaults to `role="img"` so an author-supplied `aria-label` is + * valid ARIA (a bare ``'s `generic` role doesn't support naming). */ export function Badge({ label, startIcon, endIcon, ...props }: BadgeProps) { return ( - + {startIcon} - {label} + {label != null ? {label} : null} {endIcon} ); diff --git a/packages/propel/src/elements/badge/badge.stories.tsx b/packages/propel/src/elements/badge/badge.stories.tsx index f159850d..ed2fa294 100644 --- a/packages/propel/src/elements/badge/badge.stories.tsx +++ b/packages/propel/src/elements/badge/badge.stories.tsx @@ -59,6 +59,17 @@ const meta = { export default meta; type Story = StoryObj; +// A single-row swatch layout, shared by every story below that just lines Badges up side by side +// (`Magnitudes`, `WithIcon`, `IconOnly`). `Tones` needs `flex-wrap` and `PlanBadges` needs a +// two-row layout, so those keep their own wrapper instead of this decorator. +const rowLayout: Story["decorators"] = [ + (Story) => ( +
+ +
+ ), +]; + export const Default: Story = {}; /** Every color/intent the badge supports, side by side. */ @@ -81,14 +92,15 @@ export const Tones: Story = { export const Magnitudes: Story = { // Iterates `magnitude` (and labels with it); `tone` stays live. argTypes: { magnitude: { control: false } }, + decorators: rowLayout, render: (args) => ( -
+ <> {MAGNITUDES.map((magnitude) => ( {magnitude} ))} -
+ ), }; @@ -98,8 +110,9 @@ export const Magnitudes: Story = { */ export const WithIcon: Story = { parameters: { controls: { disable: true } }, + decorators: rowLayout, render: (args) => ( -
+ <> {MAGNITUDES.map((magnitude) => ( @@ -108,7 +121,37 @@ export const WithIcon: Story = { Done ))} -
+ + ), +}; + +/** + * Icon-only (no `BadgeLabel`) — the Figma anatomy's "compact status indicator / when space is + * limited" case: the pill holds just the `Icon` slot, centered with symmetric padding. The icon is + * decorative (`aria-hidden`) and a bare ``'s `generic` role doesn't support naming, so this + * state needs both `role="img"` and an `aria-label` — otherwise it has no accessible name at all + * (the ready-made `Components/Badge` applies `role="img"` for you when `label` is omitted). + */ +export const IconOnly: Story = { + parameters: { controls: { disable: true } }, + decorators: rowLayout, + render: (args) => ( + <> + {MAGNITUDES.map((magnitude) => ( + + + + + + ))} + ), }; diff --git a/packages/propel/src/elements/badge/variants.ts b/packages/propel/src/elements/badge/variants.ts index bfab9f1e..4a5f5e0c 100644 --- a/packages/propel/src/elements/badge/variants.ts +++ b/packages/propel/src/elements/badge/variants.ts @@ -14,7 +14,7 @@ import { type StrictVariantProps } from "../../internal/variant-props"; // // "Depends (adjustable)" — exposed as required cva variants: // - magnitude: S / Base / Large (height, horizontal padding, text size, node size) -// - tone: color/sentiment (neutral, grey, brand, info, …) +// - tone: color/sentiment (neutral, grey, brand, …) export const badgeVariants = cva( "inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-sm leading-none font-medium whitespace-nowrap", { @@ -29,9 +29,9 @@ export const badgeVariants = cva( // Large: 24px tall, 8px x-padding, text/14, 16px nodes. lg: "h-6 px-2 text-14 [--node-size:1rem]", }, - // Figma Color/sentiment axis. Label-hue tones map to `bg-label-*`/`text-label-*` - // utilities; semantic tones (brand/info/success/warning/danger) map to the matching - // subtle background + primary text tokens — no arbitrary hex. + // Figma Color/sentiment axis (`danger` = Figma's "Error"). Label-hue tones map to + // `bg-label-*`/`text-label-*` utilities; semantic tones (brand/info/success/warning/danger) + // map to the matching subtle background + primary text tokens — no arbitrary hex. tone: { neutral: "bg-layer-3 text-label-grey-text", grey: "bg-label-grey-bg text-label-grey-text", @@ -50,10 +50,6 @@ export const badgeVariants = cva( }, ); -// The decorative leading icon at the badge's inline-start (the Figma badge icon). Sizes -// its single child to the badge's `--node-size` (shared node-slot class) and inherits -// the tone's text color so the icon tints to match — per the spec, "icon follows the -// badge's size and color". type BadgeVariantConfig = VariantProps; export type BadgeMagnitude = NonNullable; export type BadgeTone = NonNullable;