From 0c00eede3256147f655beb6bc7f6cf8cdebfea5a Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Fri, 10 Jul 2026 12:14:46 +0530 Subject: [PATCH 1/4] refactor(badge): enhance icon-only badge functionality and update stories - Updated `Badge` component to support icon-only rendering by conditionally omitting the label. - Introduced new `IconOnly` and `IconOnlyInteraction` stories to demonstrate the icon-only badge behavior and ensure proper rendering. - Refactored existing badge stories to utilize a shared row layout for improved presentation. - Removed the `info` tone from badge variants to align with design specifications. - Enhanced documentation to clarify the behavior of icon-only badges and their layout. --- .../src/components/badge/badge.stories.tsx | 75 +++++++++++++++++-- .../propel/src/components/badge/badge.tsx | 8 +- .../src/elements/badge/badge.stories.tsx | 42 +++++++++-- .../propel/src/elements/badge/variants.ts | 10 +-- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/packages/propel/src/components/badge/badge.stories.tsx b/packages/propel/src/components/badge/badge.stories.tsx index aaf470aa..58f8c925 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. + */ +export const IconOnly: Story = { + parameters: { controls: { disable: true } }, + decorators: rowLayout, + render: (args) => ( + <> + {MAGNITUDES.map((magnitude) => ( + } + /> + ))} + ), }; +/** + * 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), and the icon sits + * symmetrically — equal space on both sides. 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 }) => { + for (const pill of canvasElement.querySelectorAll("div > span")) { + // 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 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..558bb68d 100644 --- a/packages/propel/src/components/badge/badge.tsx +++ b/packages/propel/src/components/badge/badge.tsx @@ -7,7 +7,7 @@ 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). */ label?: string; /** Element rendered before the label (inline-start), e.g. ``. */ startIcon?: React.ReactNode; @@ -17,13 +17,15 @@ 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). */ 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..2d8fb6b9 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,27 @@ 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. + */ +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..8652e763 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", From 12a77ad34289a313d97f95e8bf4d1978d2de2205 Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Fri, 10 Jul 2026 12:27:37 +0530 Subject: [PATCH 2/4] fix(badge): update success and warning variant text colors for consistency - Changed the text color for the `success` variant from `text-success-primary` to `text-success-secondary` to align with design specifications. - Updated the text color for the `warning` variant from `text-warning-primary` to `text-warning-secondary` for improved consistency across badge variants. --- packages/propel/src/elements/badge/variants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/propel/src/elements/badge/variants.ts b/packages/propel/src/elements/badge/variants.ts index 8652e763..34553286 100644 --- a/packages/propel/src/elements/badge/variants.ts +++ b/packages/propel/src/elements/badge/variants.ts @@ -38,9 +38,9 @@ export const badgeVariants = cva( grey: "bg-label-grey-bg text-label-grey-text", brand: "bg-accent-subtle text-accent-primary", indigo: "bg-label-indigo-bg text-label-indigo-text", - success: "bg-success-subtle text-success-primary", + success: "bg-success-subtle text-success-secondary", emerald: "bg-label-emerald-bg text-label-emerald-text", - warning: "bg-warning-subtle text-warning-primary", + warning: "bg-warning-subtle text-warning-secondary", yellow: "bg-label-yellow-bg text-label-yellow-text", danger: "bg-danger-subtle text-danger-primary", crimson: "bg-label-crimson-bg text-label-crimson-text", From 83bee32d2d9a9610765c21c566e13b313ea4f8bc Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Fri, 10 Jul 2026 14:03:26 +0530 Subject: [PATCH 3/4] fix(badge): enhance accessibility for icon-only badges - Updated the `Badge` component to require an `aria-label` when using icon-only rendering, ensuring proper accessibility. - Enhanced documentation to clarify the need for an accessible name in icon-only badges. - Adjusted stories to reflect the new accessibility requirements and validate the presence of `aria-label` in tests. --- .../src/components/badge/badge.stories.tsx | 18 +++++++++++++----- packages/propel/src/components/badge/badge.tsx | 6 +++++- .../src/elements/badge/badge.stories.tsx | 12 ++++++++++-- packages/propel/src/elements/badge/variants.ts | 4 ---- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/propel/src/components/badge/badge.stories.tsx b/packages/propel/src/components/badge/badge.stories.tsx index 58f8c925..e9b666bd 100644 --- a/packages/propel/src/components/badge/badge.stories.tsx +++ b/packages/propel/src/components/badge/badge.stories.tsx @@ -127,7 +127,9 @@ export const WithTrailingIcon: Story = { /** * 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. + * 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 } }, @@ -142,6 +144,7 @@ export const IconOnly: Story = { magnitude={magnitude} label={undefined} startIcon={} + aria-label="Completed" /> ))} @@ -150,15 +153,18 @@ export const IconOnly: Story = { /** * 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), and the icon sits - * symmetrically — equal space on both sides. Tagged out of the sidebar/docs/manifest while still - * running under the default `test` tag. + * 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 }) => { - for (const pill of canvasElement.querySelectorAll("div > span")) { + 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; @@ -168,6 +174,8 @@ export const IconOnlyInteraction: Story = { 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"); } }, }; diff --git a/packages/propel/src/components/badge/badge.tsx b/packages/propel/src/components/badge/badge.tsx index 558bb68d..a9890562 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. Omit for an icon-only badge (a compact status indicator). */ + /** + * The badge label text. Omit for an icon-only badge (a compact status indicator) — the icon + * itself is decorative (`aria-hidden`), so pass an `aria-label` in that case; without one an + * icon-only badge has no accessible name at all. + */ label?: string; /** Element rendered before the label (inline-start), e.g. ``. */ startIcon?: React.ReactNode; diff --git a/packages/propel/src/elements/badge/badge.stories.tsx b/packages/propel/src/elements/badge/badge.stories.tsx index 2d8fb6b9..663cdc6d 100644 --- a/packages/propel/src/elements/badge/badge.stories.tsx +++ b/packages/propel/src/elements/badge/badge.stories.tsx @@ -126,7 +126,9 @@ export const WithIcon: Story = { /** * 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. + * limited" case: the pill holds just the `Icon` slot, 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 } }, @@ -134,7 +136,13 @@ export const IconOnly: Story = { render: (args) => ( <> {MAGNITUDES.map((magnitude) => ( - + diff --git a/packages/propel/src/elements/badge/variants.ts b/packages/propel/src/elements/badge/variants.ts index 34553286..5600a2aa 100644 --- a/packages/propel/src/elements/badge/variants.ts +++ b/packages/propel/src/elements/badge/variants.ts @@ -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; From 9dc386f63a2a8be901d7bebb996f6639eac2585a Mon Sep 17 00:00:00 2001 From: Atul Tameshwari Date: Fri, 10 Jul 2026 14:37:00 +0530 Subject: [PATCH 4/4] fix(badge): improve accessibility and update variant colors - Enhanced the `Badge` component to ensure icon-only badges have a valid `role="img"` and require an `aria-label` for accessibility. - Updated documentation to clarify the accessibility requirements for icon-only badges. - Adjusted the `success` and `warning` variant text colors for consistency with design specifications, changing `text-success-secondary` to `text-success-primary` and `text-warning-secondary` to `text-warning-primary`. --- packages/propel/src/components/badge/badge.tsx | 9 +++++---- packages/propel/src/elements/badge/badge.stories.tsx | 6 ++++-- packages/propel/src/elements/badge/variants.ts | 4 ++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/propel/src/components/badge/badge.tsx b/packages/propel/src/components/badge/badge.tsx index a9890562..0308dfd9 100644 --- a/packages/propel/src/components/badge/badge.tsx +++ b/packages/propel/src/components/badge/badge.tsx @@ -9,8 +9,8 @@ import { export type BadgeProps = Omit & { /** * The badge label text. Omit for an icon-only badge (a compact status indicator) — the icon - * itself is decorative (`aria-hidden`), so pass an `aria-label` in that case; without one an - * icon-only badge has no accessible name at all. + * 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. ``. */ @@ -23,11 +23,12 @@ 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. 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). + * 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 != 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 663cdc6d..07c945d1 100644 --- a/packages/propel/src/elements/badge/badge.stories.tsx +++ b/packages/propel/src/elements/badge/badge.stories.tsx @@ -127,8 +127,9 @@ export const WithIcon: Story = { /** * 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`), so the pill carries its own `aria-label` — otherwise this state has - * no accessible name at all. + * 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 } }, @@ -141,6 +142,7 @@ export const IconOnly: Story = { {...args} tone="success" magnitude={magnitude} + role="img" aria-label="Completed" > diff --git a/packages/propel/src/elements/badge/variants.ts b/packages/propel/src/elements/badge/variants.ts index 5600a2aa..ceb133bd 100644 --- a/packages/propel/src/elements/badge/variants.ts +++ b/packages/propel/src/elements/badge/variants.ts @@ -38,9 +38,9 @@ export const badgeVariants = cva( grey: "bg-label-grey-bg text-label-grey-text", brand: "bg-accent-subtle text-accent-primary", indigo: "bg-label-indigo-bg text-label-indigo-text", - success: "bg-success-subtle text-success-secondary", + success: "bg-success-subtle text-success-primary", emerald: "bg-label-emerald-bg text-label-emerald-text", - warning: "bg-warning-subtle text-warning-secondary", + warning: "bg-warning-subtle text-warning-primary", yellow: "bg-label-yellow-bg text-label-yellow-text", danger: "bg-danger-subtle text-danger-primary", crimson: "bg-label-crimson-bg text-label-crimson-text",