diff --git a/packages/propel/src/components/badge/badge.stories.tsx b/packages/propel/src/components/badge/badge.stories.tsx index aaf470aa..e9b666bd 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"; @@ -9,7 +10,6 @@ const TONES: BadgeTone[] = [ "neutral", "grey", "brand", - "info", "indigo", "success", "emerald", @@ -47,6 +47,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 +75,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..0308dfd9 100644 --- a/packages/propel/src/components/badge/badge.tsx +++ b/packages/propel/src/components/badge/badge.tsx @@ -7,7 +7,11 @@ import { } from "../../elements/badge"; export type BadgeProps = Omit & { - /** The badge label text. */ + /** + * The badge label text. Omit for an icon-only badge (a compact status indicator) — the icon + * itself is decorative (`aria-hidden`), and a plain `` carries no accessible name, so the + * pill gets `role="img"` in that case; pass an `aria-label` too, or it's still unlabeled. + */ label?: string; /** Element rendered before the label (inline-start), e.g. ``. */ startIcon?: React.ReactNode; @@ -17,13 +21,16 @@ export type BadgeProps = Omit & { /** * 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..07c945d1 100644 --- a/packages/propel/src/elements/badge/badge.stories.tsx +++ b/packages/propel/src/elements/badge/badge.stories.tsx @@ -8,7 +8,6 @@ const TONES: BadgeTone[] = [ "neutral", "grey", "brand", - "info", "indigo", "success", "emerald", @@ -59,6 +58,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 +91,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 +109,9 @@ export const Magnitudes: Story = { */ export const WithIcon: Story = { parameters: { controls: { disable: true } }, + decorators: rowLayout, render: (args) => ( -
+ <> {MAGNITUDES.map((magnitude) => ( @@ -108,7 +120,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..ceb133bd 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,14 +29,14 @@ 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 — exactly the 11 tones the spec's Color property lists + // (`danger` = Figma's "Error"; no `info`, the spec defines none). Label-hue tones map to + // `bg-label-*`/`text-label-*` utilities; semantic tones (brand/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", brand: "bg-accent-subtle text-accent-primary", - info: "bg-info-subtle text-info-primary", indigo: "bg-label-indigo-bg text-label-indigo-text", success: "bg-success-subtle text-success-primary", emerald: "bg-label-emerald-bg text-label-emerald-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;