Skip to content
Merged
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}>
Comment thread
anmolsinghbhatia marked this conversation as resolved.
{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