Skip to content

Commit e4f4bc1

Browse files
Align badge to architecture goals (#200)
* Align badge to architecture goals - Fixes pill shape: moves border-radius from per-magnitude slots into the cva base string as `rounded-full` (spec: "Pill shape — always the same"). - Adds required `variant` prop (BadgeVariant type, cva `variant` axis) with `solid` as the only current value; "outline" is noted in the spec as a potential future step — introducing the axis now keeps call sites forward-compatible (closes #119). - Drops the per-magnitude `rounded-sm`/`rounded-md` values; every step now inherits the full-radius base. - Updates all Badge call sites (badge stories + menu submenu story) to pass the now-required `variant` prop. * Give badge single-element anatomy parts Break the badge into atomic single-element ui parts the way the accordion is: the Badge pill now renders only its container, with the leading icon, label, and the optional dismiss action each extracted into their own part (BadgeIcon, BadgeLabel, BadgeDismiss), each with its own cva. The ready-made components/badge composition wires them behind convenience props and carries no styling of its own. * Make BadgeDismiss a glyph-free single-element slot BadgeDismiss baked a default X glyph via children ?? <X />. Per the single-element rule, a ui part renders only its child and bakes no glyph. The default X now lives in the components-tier Badge composition (passed in when onDismiss is set) and the ui story passes it explicitly.
1 parent 2c0a12c commit e4f4bc1

11 files changed

Lines changed: 283 additions & 75 deletions

File tree

packages/propel/src/components/badge/badge.stories.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
22
import { Check } from "lucide-react";
3+
import { fn } from "storybook/test";
34

45
import { iconControl } from "../../storybook/icon-control";
5-
import { Badge, type BadgeMagnitude, type BadgeTone } from "./index";
6+
import {
7+
Badge,
8+
BadgeDismiss,
9+
BadgeIcon,
10+
BadgeLabel,
11+
type BadgeMagnitude,
12+
type BadgeTone,
13+
} from "./index";
614

715
const TONES: BadgeTone[] = [
816
"neutral",
@@ -22,15 +30,19 @@ const TONES: BadgeTone[] = [
2230
const MAGNITUDES: BadgeMagnitude[] = ["sm", "md", "lg"];
2331

2432
// Badge is static — no interaction-state styling — so it gets no pseudo-states story;
25-
// its variation is fully covered by Tones + Magnitudes.
33+
// its variation is fully covered by Tones + Magnitudes. This is the ready-made
34+
// composition: the `Badge` pill plus optional leading icon (`inlineStartNode`) and a
35+
// trailing dismiss action (`onDismiss`), all wired to the atomic `ui/badge` parts.
2636
const meta = {
2737
title: "Components/Badge",
2838
component: Badge,
39+
subcomponents: { BadgeIcon, BadgeLabel, BadgeDismiss },
2940
argTypes: { inlineStartNode: iconControl },
3041
args: {
3142
children: "Badge",
3243
tone: "neutral",
3344
magnitude: "md",
45+
variant: "solid",
3446
},
3547
parameters: {
3648
design: {
@@ -93,6 +105,27 @@ export const WithIcon: Story = {
93105
),
94106
};
95107

108+
/** A dismissible badge: passing `onDismiss` adds a trailing remove button. */
109+
export const Dismissible: Story = {
110+
parameters: { controls: { disable: true } },
111+
render: (args) => (
112+
<div className="flex items-center gap-3">
113+
{MAGNITUDES.map((magnitude) => (
114+
<Badge
115+
key={magnitude}
116+
{...args}
117+
tone="brand"
118+
magnitude={magnitude}
119+
dismissLabel="Remove label"
120+
onDismiss={fn()}
121+
>
122+
Label
123+
</Badge>
124+
))}
125+
</div>
126+
),
127+
};
128+
96129
// The Figma "Plan Badges" frame's two fills (`plans/brand/*`, `plans/neutral/*`) already
97130
// exist on Badge's `tone` axis, so a plan badge is just a tone choice:
98131
// paid -> `brand` free -> `grey`
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { X } from "lucide-react";
2+
import type * as React from "react";
3+
4+
import {
5+
Badge as BadgeRoot,
6+
type BadgeProps as BadgeRootProps,
7+
BadgeDismiss,
8+
BadgeIcon,
9+
BadgeLabel,
10+
} from "../../ui/badge";
11+
12+
export type BadgeProps = BadgeRootProps & {
13+
/** The badge label — text, a count, or any inline content. */
14+
children?: React.ReactNode;
15+
/**
16+
* Node rendered before the label (inline-start). An icon or any node; sized to the badge's
17+
* `--node-size` and tinted to the tone. Decorative, kept out of the name.
18+
*/
19+
inlineStartNode?: React.ReactNode;
20+
/**
21+
* When provided, shows a trailing dismiss/remove button that calls this on click. Pair with
22+
* `dismissLabel` for the button's accessible name.
23+
*/
24+
onDismiss?: () => void;
25+
/** Accessible name for the dismiss button (defaults to "Remove"). */
26+
dismissLabel?: string;
27+
};
28+
29+
/**
30+
* The ready-made badge: composes the atomic `Badge` pill with an optional leading `BadgeIcon`, the
31+
* `BadgeLabel`, and an optional trailing `BadgeDismiss` action.
32+
*/
33+
export function Badge({
34+
children,
35+
inlineStartNode,
36+
onDismiss,
37+
dismissLabel = "Remove",
38+
...props
39+
}: BadgeProps) {
40+
return (
41+
<BadgeRoot {...props}>
42+
{inlineStartNode ? <BadgeIcon>{inlineStartNode}</BadgeIcon> : null}
43+
<BadgeLabel>{children}</BadgeLabel>
44+
{onDismiss ? (
45+
<BadgeDismiss aria-label={dismissLabel} onClick={onDismiss}>
46+
<X />
47+
</BadgeDismiss>
48+
) : null}
49+
</BadgeRoot>
50+
);
51+
}
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1-
// Ready-made 1:1 re-export of the ui primitive. Drop down to `@plane/propel/ui/badge` only
2-
// when you need the lower-level parts.
3-
export * from "../../ui/badge";
1+
export { Badge, type BadgeProps } from "./badge";
2+
// Re-export the atomic parts so a full badge is composable from this convenience.
3+
export {
4+
BadgeDismiss,
5+
type BadgeDismissProps,
6+
BadgeIcon,
7+
type BadgeIconProps,
8+
BadgeLabel,
9+
type BadgeLabelProps,
10+
type BadgeMagnitude,
11+
type BadgeTone,
12+
type BadgeVariant,
13+
} from "../../ui/badge";

packages/propel/src/components/menu/menu.stories.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ export const Submenu: Story = {
878878
<MenuSubTrigger
879879
label="Priority"
880880
inlineEndNode={
881-
<Badge magnitude="sm" tone="neutral">
881+
<Badge magnitude="sm" tone="neutral" variant="solid">
882882
5
883883
</Badge>
884884
}
@@ -899,7 +899,7 @@ export const Submenu: Story = {
899899
<MenuSubTrigger
900900
label="State"
901901
inlineEndNode={
902-
<Badge magnitude="sm" tone="neutral">
902+
<Badge magnitude="sm" tone="neutral" variant="solid">
903903
5
904904
</Badge>
905905
}
@@ -920,7 +920,7 @@ export const Submenu: Story = {
920920
<MenuSubTrigger
921921
label="Assignee"
922922
inlineEndNode={
923-
<Badge magnitude="sm" tone="neutral">
923+
<Badge magnitude="sm" tone="neutral" variant="solid">
924924
5
925925
</Badge>
926926
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type * as React from "react";
2+
3+
import { badgeDismissVariants } from "./variants";
4+
5+
export type BadgeDismissProps = Omit<
6+
React.ComponentPropsWithoutRef<"button">,
7+
"className" | "style"
8+
> & {
9+
/** Accessible name — required because the control is an icon (e.g. "Remove label"). */
10+
"aria-label": string;
11+
};
12+
13+
/**
14+
* The optional dismiss/remove action at the badge's inline-end. A button that sizes its single
15+
* child to the badge's `--node-size` and inherits the tone's text color; pass the glyph as
16+
* `children`. Because the content is an icon, an `aria-label` is required.
17+
*/
18+
export function BadgeDismiss({ children, type = "button", ...props }: BadgeDismissProps) {
19+
return (
20+
<button type={type} className={badgeDismissVariants()} {...props}>
21+
{children}
22+
</button>
23+
);
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type * as React from "react";
2+
3+
import { badgeIconVariants } from "./variants";
4+
5+
export type BadgeIconProps = Omit<React.ComponentPropsWithoutRef<"span">, "className" | "style">;
6+
7+
/**
8+
* The decorative leading icon at the badge's inline-start (the Figma badge icon). Sizes its single
9+
* child to the badge's `--node-size` and inherits the tone's text color, so callers pass a bare
10+
* icon. Decorative (the label carries the name), so it is `aria-hidden`.
11+
*/
12+
export function BadgeIcon(props: BadgeIconProps) {
13+
return <span aria-hidden className={badgeIconVariants()} {...props} />;
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type * as React from "react";
2+
3+
import { badgeLabelVariants } from "./variants";
4+
5+
export type BadgeLabelProps = Omit<React.ComponentPropsWithoutRef<"span">, "className" | "style">;
6+
7+
/**
8+
* The badge's text label. Single-line (the pill clips wrapping); sits between an optional leading
9+
* `BadgeIcon` and a trailing `BadgeDismiss`.
10+
*/
11+
export function BadgeLabel(props: BadgeLabelProps) {
12+
return <span className={badgeLabelVariants()} {...props} />;
13+
}

packages/propel/src/ui/badge/badge.stories.tsx

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import type { Meta, StoryObj } from "@storybook/react-vite";
2-
import { Check } from "lucide-react";
2+
import { Check, X } from "lucide-react";
33

4-
import { iconControl } from "../../storybook/icon-control";
5-
import { Badge, type BadgeMagnitude, type BadgeTone } from "./index";
4+
import {
5+
Badge,
6+
BadgeDismiss,
7+
BadgeIcon,
8+
BadgeLabel,
9+
type BadgeMagnitude,
10+
type BadgeTone,
11+
} from "./index";
612

713
const TONES: BadgeTone[] = [
814
"neutral",
@@ -26,25 +32,33 @@ const MAGNITUDES: BadgeMagnitude[] = ["sm", "md", "lg"];
2632
// "Design" panel (@storybook/addon-designs) shows code + Figma side by side.
2733
// 2. Components that style interaction via CSS `hover:`/`active:`/`focus-visible:`
2834
// utilities get a `States` story using storybook-addon-pseudo-states to force
29-
// those pseudo-classes side by side (see e.g. Accordion). Badge is static — it
30-
// has no interaction-state styling — so it gets NO pseudo-states story; its
31-
// variation is fully covered by Tones + Magnitudes.
35+
// those pseudo-classes side by side (see e.g. Accordion). The badge pill itself
36+
// is static — it has no interaction-state styling — so it gets NO pseudo-states
37+
// story; its variation is fully covered by Tones + Magnitudes.
38+
//
39+
// These ui stories show the atomic anatomy: each part (`Badge`, `BadgeIcon`,
40+
// `BadgeLabel`, `BadgeDismiss`) is a single styled element, composed by hand. The
41+
// ready-made `Components/Badge` composition wraps the same parts behind convenience props.
3242
const meta = {
3343
title: "UI/Badge",
3444
component: Badge,
35-
// Give the `inlineStartNode` ReactNode prop a usable icon picker in the Controls panel.
36-
argTypes: { inlineStartNode: iconControl },
45+
subcomponents: { BadgeIcon, BadgeLabel, BadgeDismiss },
3746
args: {
38-
children: "Badge",
3947
tone: "neutral",
4048
magnitude: "md",
49+
variant: "solid",
4150
},
4251
parameters: {
4352
design: {
4453
type: "figma",
4554
url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=1532-1177",
4655
},
4756
},
57+
render: (args) => (
58+
<Badge {...args}>
59+
<BadgeLabel>Badge</BadgeLabel>
60+
</Badge>
61+
),
4862
} satisfies Meta<typeof Badge>;
4963

5064
export default meta;
@@ -54,15 +68,14 @@ export const Default: Story = {};
5468

5569
/** Every color/intent the badge supports, side by side. */
5670
export const Tones: Story = {
57-
// The grid iterates `tone` and labels each swatch with the tone name, so disable just
58-
// those two controls; `magnitude` and `inlineStartNode` stay live and update every swatch
59-
// at once.
60-
argTypes: { tone: { control: false }, children: { control: false } },
71+
// The grid iterates `tone` and labels each swatch with the tone name, so disable that
72+
// control; `magnitude` stays live and updates every swatch at once.
73+
argTypes: { tone: { control: false } },
6174
render: (args) => (
6275
<div className="flex flex-wrap items-center gap-3">
6376
{TONES.map((tone) => (
6477
<Badge key={tone} {...args} tone={tone}>
65-
{tone}
78+
<BadgeLabel>{tone}</BadgeLabel>
6679
</Badge>
6780
))}
6881
</div>
@@ -71,33 +84,47 @@ export const Tones: Story = {
7184

7285
/** The three sizes (Figma S / Base / Large). */
7386
export const Magnitudes: Story = {
74-
// Iterates `magnitude` (and labels with it); `tone` and `inlineStartNode` stay live.
75-
argTypes: { magnitude: { control: false }, children: { control: false } },
87+
// Iterates `magnitude` (and labels with it); `tone` stays live.
88+
argTypes: { magnitude: { control: false } },
7689
render: (args) => (
7790
<div className="flex items-center gap-3">
7891
{MAGNITUDES.map((magnitude) => (
7992
<Badge key={magnitude} {...args} magnitude={magnitude}>
80-
{magnitude}
93+
<BadgeLabel>{magnitude}</BadgeLabel>
8194
</Badge>
8295
))}
8396
</div>
8497
),
8598
};
8699

87-
/** Badges with an optional leading icon, which is sized to the magnitude and tinted to the tone. */
100+
/** A leading `BadgeIcon`, sized to the magnitude and tinted to the tone. */
88101
export const WithIcon: Story = {
89102
parameters: { controls: { disable: true } },
90103
render: (args) => (
91104
<div className="flex items-center gap-3">
92105
{MAGNITUDES.map((magnitude) => (
93-
<Badge
94-
key={magnitude}
95-
{...args}
96-
tone="success"
97-
magnitude={magnitude}
98-
inlineStartNode={<Check />}
99-
>
100-
Done
106+
<Badge key={magnitude} {...args} tone="success" magnitude={magnitude}>
107+
<BadgeIcon>
108+
<Check />
109+
</BadgeIcon>
110+
<BadgeLabel>Done</BadgeLabel>
111+
</Badge>
112+
))}
113+
</div>
114+
),
115+
};
116+
117+
/** A trailing `BadgeDismiss` remove action, sized to the magnitude and tinted to the tone. */
118+
export const WithDismiss: Story = {
119+
parameters: { controls: { disable: true } },
120+
render: (args) => (
121+
<div className="flex items-center gap-3">
122+
{MAGNITUDES.map((magnitude) => (
123+
<Badge key={magnitude} {...args} tone="brand" magnitude={magnitude}>
124+
<BadgeLabel>Label</BadgeLabel>
125+
<BadgeDismiss aria-label="Remove label">
126+
<X />
127+
</BadgeDismiss>
101128
</Badge>
102129
))}
103130
</div>
@@ -135,7 +162,7 @@ export const PlanBadges: Story = {
135162
<div key={plan} className="flex items-center gap-3">
136163
{MAGNITUDES.map((magnitude) => (
137164
<Badge key={magnitude} {...args} tone={PLAN_TONES[plan]} magnitude={magnitude}>
138-
{plan === "paid" ? "Paid" : "Free"}
165+
<BadgeLabel>{plan === "paid" ? "Paid" : "Free"}</BadgeLabel>
139166
</Badge>
140167
))}
141168
</div>

0 commit comments

Comments
 (0)