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
83 changes: 76 additions & 7 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 All @@ -9,7 +10,6 @@ const TONES: BadgeTone[] = [
"neutral",
"grey",
"brand",
"info",
"indigo",
"success",
"emerald",
Expand Down Expand Up @@ -47,6 +47,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 +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) => (
<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 +101,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 +121,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
15 changes: 11 additions & 4 deletions packages/propel/src/components/badge/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
} from "../../elements/badge";

export type BadgeProps = Omit<BadgeElementProps, "children"> & {
/** 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 `<span>` 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. `<Icon icon={Check} />`. */
startIcon?: React.ReactNode;
Expand All @@ -17,13 +21,16 @@ export type BadgeProps = Omit<BadgeElementProps, "children"> & {

/**
* 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}>
{startIcon}
<BadgeLabel>{label}</BadgeLabel>
{label != null ? <BadgeLabel>{label}</BadgeLabel> : null}
{endIcon}
</BadgeElement>
);
Expand Down
52 changes: 47 additions & 5 deletions packages/propel/src/elements/badge/badge.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const TONES: BadgeTone[] = [
"neutral",
"grey",
"brand",
"info",
"indigo",
"success",
"emerald",
Expand Down Expand Up @@ -59,6 +58,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 +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) => (
<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 +109,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 +120,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
14 changes: 5 additions & 9 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,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",
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