Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 76 additions & 6 deletions packages/propel/src/components/badge/badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,6 +48,17 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

// 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) => (
<div className="flex items-center gap-3">
<Story />
</div>
),
];

export const Default: Story = {};

/** Every color/intent the badge supports, side by side. */
Expand All @@ -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) => (
<div className="flex items-center gap-3">
<>
{MAGNITUDES.map((magnitude) => (
<Badge key={magnitude} {...args} magnitude={magnitude} label={magnitude} />
))}
</div>
</>
),
};

/** 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) => (
<div className="flex items-center gap-3">
<>
{MAGNITUDES.map((magnitude) => (
<Badge
key={magnitude}
Expand All @@ -88,15 +102,16 @@ export const WithLeadingIcon: Story = {
label="Done"
/>
))}
</div>
</>
),
};

/** 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) => (
<div className="flex items-center gap-3">
<>
{MAGNITUDES.map((magnitude) => (
<Badge
key={magnitude}
Expand All @@ -107,10 +122,65 @@ export const WithTrailingIcon: Story = {
label="Pro"
/>
))}
</div>
</>
),
};

/**
* 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) => (
<Badge
key={magnitude}
{...args}
tone="success"
magnitude={magnitude}
label={undefined}
startIcon={<Icon icon={Check} />}
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<HTMLElement>("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`
Expand Down
22 changes: 16 additions & 6 deletions packages/propel/src/components/badge/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,34 @@ import {
BadgeLabel,
} from "../../elements/badge";

export type BadgeProps = Omit<BadgeElementProps, "children"> & {
/** The badge label text. */
label?: string;
type BadgeOwnProps = Omit<BadgeElementProps, "children" | "aria-label"> & {
/** Element rendered before the label (inline-start), e.g. `<Icon icon={Check} />`. */
startIcon?: React.ReactNode;
/** Element rendered after the label (inline-end), e.g. `<Icon icon={Sparkles} />`. */
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 `<span>`'s `generic` role doesn't support naming).
*/
export function Badge({ label, startIcon, endIcon, ...props }: BadgeProps) {
return (
<BadgeElement {...props}>
<BadgeElement role={label == null ? "img" : undefined} {...props}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only documents the aria-label requirement: callers can still omit both label and aria-label, producing an unnamed role="img". Could we model the label/icon-only cases as a union so the accessible name is required here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Modeled BadgeProps as a discriminated union so the name is required either way:

export type BadgeProps = BadgeOwnProps &
  ({ label: string; "aria-label"?: string } | { label?: undefined; "aria-label": string });

Labeled → named by its text; icon-only (no label) → aria-label required at compile time, so omitting both is now a type error instead of a silent unnamed role="img". Went with a union rather than the aria-hidden fallback used for Avatar because an icon-only badge is meaningful status — it should be named, not hidden. Type-check confirms all existing usages already pass a name.

{startIcon}
<BadgeLabel>{label}</BadgeLabel>
{label != null ? <BadgeLabel>{label}</BadgeLabel> : null}
{endIcon}
</BadgeElement>
);
Expand Down
51 changes: 47 additions & 4 deletions packages/propel/src/elements/badge/badge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

// 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) => (
<div className="flex items-center gap-3">
<Story />
</div>
),
];

export const Default: Story = {};

/** Every color/intent the badge supports, side by side. */
Expand All @@ -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) => (
<div className="flex items-center gap-3">
<>
{MAGNITUDES.map((magnitude) => (
<Badge key={magnitude} {...args} magnitude={magnitude}>
<BadgeLabel>{magnitude}</BadgeLabel>
</Badge>
))}
</div>
</>
),
};

Expand All @@ -98,8 +110,9 @@ export const Magnitudes: Story = {
*/
export const WithIcon: Story = {
parameters: { controls: { disable: true } },
decorators: rowLayout,
render: (args) => (
<div className="flex items-center gap-3">
<>
{MAGNITUDES.map((magnitude) => (
<Badge key={magnitude} {...args} tone="success" magnitude={magnitude}>
<Icon>
Expand All @@ -108,7 +121,37 @@ export const WithIcon: Story = {
<BadgeLabel>Done</BadgeLabel>
</Badge>
))}
</div>
</>
),
};

/**
* 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 `<span>`'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) => (
<Badge
key={magnitude}
{...args}
tone="success"
magnitude={magnitude}
role="img"
aria-label="Completed"
>
<Icon>
<Check />
</Icon>
</Badge>
))}
</>
),
};

Expand Down
12 changes: 4 additions & 8 deletions packages/propel/src/elements/badge/variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand All @@ -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",
Expand All @@ -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<typeof badgeVariants>;
export type BadgeMagnitude = NonNullable<BadgeVariantConfig["magnitude"]>;
export type BadgeTone = NonNullable<BadgeVariantConfig["tone"]>;
Expand Down