From 457f347925e4c8bceb926ea9eaf33d1c3b3e16e9 Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 17 Aug 2026 11:33:29 -0700 Subject: [PATCH 1/4] feat(badge): add href and icon props --- .changeset/linked-badges-base.md | 5 + .../src/components/demos/BadgeDemo.tsx | 17 +++ .../src/pages/components/badge.mdx | 18 +++ .../kumo/src/components/badge/badge.test.tsx | 110 +++++++----------- packages/kumo/src/components/badge/badge.tsx | 60 ++++++++-- packages/kumo/src/primitives/index.ts | 5 +- packages/kumo/src/primitives/otp-field.ts | 5 +- 7 files changed, 132 insertions(+), 88 deletions(-) create mode 100644 .changeset/linked-badges-base.md diff --git a/.changeset/linked-badges-base.md b/.changeset/linked-badges-base.md new file mode 100644 index 0000000000..1cf5298c07 --- /dev/null +++ b/.changeset/linked-badges-base.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +Add link and icon support to Badge through the `href` and `icon` props, and use the base surface background for outline badges. diff --git a/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx index a87582c517..cee560f2be 100644 --- a/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx @@ -1,4 +1,5 @@ import { Badge } from "@cloudflare/kumo"; +import { CheckCircleIcon } from "@phosphor-icons/react"; export function BadgeSemanticVariantsDemo() { return ( @@ -38,6 +39,22 @@ export function BadgeInSentenceDemo() { ); } +export function BadgeIconDemo() { + return ( + + Verified + + ); +} + +export function BadgeLinkDemo() { + return ( + + View changelog + + ); +} + export function BadgeDotDemo() { return (
diff --git a/packages/kumo-docs-astro/src/pages/components/badge.mdx b/packages/kumo-docs-astro/src/pages/components/badge.mdx index 6470cddae9..504c894466 100644 --- a/packages/kumo-docs-astro/src/pages/components/badge.mdx +++ b/packages/kumo-docs-astro/src/pages/components/badge.mdx @@ -11,6 +11,8 @@ import { BadgeSemanticVariantsDemo, BadgeColorVariantsDemo, BadgeInSentenceDemo, + BadgeIconDemo, + BadgeLinkDemo, BadgeDotDemo, } from "~/components/demos/BadgeDemo"; @@ -73,6 +75,22 @@ Use `appearance="dot"` for a subtle status indicator with a colored dot. Support +### With an icon + +Pass a Phosphor icon component or React element to `icon` to render it before the badge label. + + + + + +### Linked badge + +Pass `href` to render a badge as a link. Linked badges use the link component configured by [`LinkProvider`](/components/link#framework-integration-linkprovider). + + + + + ## API Reference diff --git a/packages/kumo/src/components/badge/badge.test.tsx b/packages/kumo/src/components/badge/badge.test.tsx index 939007d1d4..6a23700c00 100644 --- a/packages/kumo/src/components/badge/badge.test.tsx +++ b/packages/kumo/src/components/badge/badge.test.tsx @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; +import { forwardRef } from "react"; import { render, screen } from "@testing-library/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { + LinkProvider, + type LinkComponentProps, +} from "../../utils/link-provider"; import { Badge, badgeVariants, KUMO_BADGE_VARIANTS } from "./badge"; describe("Badge", () => { @@ -14,19 +20,40 @@ describe("Badge", () => { expect(el.tagName).toBe("SPAN"); }); - it("merges custom className", () => { - render(Tag); - const el = screen.getByText("Tag"); - expect(el.className).toContain("my-custom"); + it("renders as a link when href is provided", () => { + render(Docs); + const link = screen.getByRole("link", { name: "Docs" }); + expect(link.tagName).toBe("A"); + expect(link.getAttribute("href")).toBe("/docs"); }); - describe("filled appearance (default)", () => { - it("applies variant classes for filled badges", () => { - render(Error); - const el = screen.getByText("Error"); - expect(el.className).toContain("bg-kumo-danger-tint"); - }); + it("uses the component configured by LinkProvider", () => { + const RouterLink = forwardRef( + ({ children, ...props }, ref) => ( + + {children} + + ), + ); + RouterLink.displayName = "RouterLink"; + + render( + + Workers + , + ); + + const link = screen.getByRole("link", { name: "Workers" }); + expect(link.getAttribute("data-routed")).toBe("true"); + expect(link.getAttribute("href")).toBe("/workers"); + }); + it("renders an icon", () => { + render(Next); + expect(screen.getByText("Next").querySelector("svg")).toBeTruthy(); + }); + + describe("filled appearance (default)", () => { it("does not render a dot indicator", () => { render(OK); const el = screen.getByText("OK"); @@ -44,45 +71,6 @@ describe("Badge", () => { const badge = screen.getByText("Healthy").closest("span")!; const dot = badge.querySelector("[aria-hidden='true']"); expect(dot).toBeTruthy(); - expect(dot!.className).toContain("bg-kumo-success"); - }); - - it("applies dot appearance classes instead of variant classes", () => { - render( - - Down - , - ); - const badge = screen.getByText("Down").closest("span")!; - // Dot appearance overrides variant bg/text - expect(badge.className).toContain("bg-transparent"); - expect(badge.className).toContain("text-kumo-default"); - // Should NOT contain the filled error classes - expect(badge.className).not.toContain("bg-kumo-danger-tint"); - }); - - it("renders correct dot color per variant", () => { - const cases = [ - { variant: "success" as const, expected: "bg-kumo-success" }, - { variant: "warning" as const, expected: "bg-kumo-badge-orange" }, - { variant: "error" as const, expected: "bg-kumo-badge-red" }, - { variant: "neutral" as const, expected: "bg-kumo-badge-neutral" }, - ]; - - for (const { variant, expected } of cases) { - const { unmount } = render( - - {variant} - , - ); - const badge = screen.getByText(variant).closest("span")!; - const dot = badge.querySelector("[aria-hidden='true']"); - expect(dot, `dot should exist for variant="${variant}"`).toBeTruthy(); - expect(dot!.className, `dot class for variant="${variant}"`).toContain( - expected, - ); - unmount(); - } }); it("does not render a dot for unsupported variants", () => { @@ -116,23 +104,6 @@ describe("Badge", () => { }); describe("badgeVariants", () => { - it("returns base styles with no arguments", () => { - const result = badgeVariants(); - expect(result).toContain("inline-flex"); - expect(result).toContain("rounded-full"); - }); - - it("includes variant classes for filled appearance", () => { - const result = badgeVariants({ variant: "success" }); - expect(result).toContain("bg-kumo-success-tint"); - }); - - it("omits variant classes for dot appearance", () => { - const result = badgeVariants({ variant: "success", appearance: "dot" }); - expect(result).not.toContain("bg-kumo-success-tint"); - expect(result).toContain("bg-transparent"); - }); - it("survives invalid variant without throwing", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const BOGUS = "nope" as any; @@ -162,12 +133,9 @@ describe("KUMO_BADGE_VARIANTS", () => { expect(KUMO_BADGE_VARIANTS.dotColor).toBeDefined(); }); - it("every variant entry has classes and description", () => { + it("every variant entry has a description", () => { for (const [dim, entries] of Object.entries(KUMO_BADGE_VARIANTS)) { - for (const [key, entry] of Object.entries( - entries as Record, - )) { - expect(entry.classes, `${dim}.${key}.classes`).toBeDefined(); + for (const [key, entry] of Object.entries(entries)) { expect(typeof entry.description, `${dim}.${key}.description`).toBe( "string", ); diff --git a/packages/kumo/src/components/badge/badge.tsx b/packages/kumo/src/components/badge/badge.tsx index 92aeaa7863..fb185dbc38 100644 --- a/packages/kumo/src/components/badge/badge.tsx +++ b/packages/kumo/src/components/badge/badge.tsx @@ -1,10 +1,12 @@ -import type { ReactNode } from "react"; +import React, { type ReactNode } from "react"; +import type { Icon } from "@phosphor-icons/react"; import { cn } from "../../utils/cn"; +import { useLinkComponent } from "../../utils/link-provider"; import { resolveVariant } from "../../utils/resolve-variant"; /** Base styles applied to all badge variants. */ export const KUMO_BADGE_BASE_STYLES = - "inline-flex w-fit flex-none shrink-0 items-center justify-self-start rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap"; + "inline-flex w-fit flex-none shrink-0 items-center justify-self-start gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap"; /** Badge variant definitions mapping variant names to their Tailwind classes and descriptions. */ export const KUMO_BADGE_VARIANTS = { @@ -44,8 +46,8 @@ export const KUMO_BADGE_VARIANTS = { description: "Indicates beta or experimental features", }, outline: { - classes: "border border-kumo-fill bg-transparent text-kumo-default", - description: "Bordered badge with transparent background", + classes: "border border-kumo-fill bg-kumo-base text-kumo-default", + description: "Bordered badge with base background", }, /** Other color token variants */ @@ -162,6 +164,24 @@ export function badgeVariants({ // Legacy type alias for backwards compatibility export type BadgeVariant = KumoBadgeVariant; +const renderIconNode = (IconComponent?: Icon | ReactNode) => { + if (!IconComponent) return null; + const Component = IconComponent as React.ComponentType< + Record + >; + const icon = React.isValidElement(IconComponent) ? ( + IconComponent + ) : ( + + ); + + return ( + + {icon} + + ); +}; + /** * Badge component props. * @@ -188,7 +208,7 @@ export interface BadgeProps { * - `"red"`, `"orange"`, `"green"`, `"teal"`, `"blue"`, `"purple"`, `"neutral"` * - `"teal-subtle"`, `"neutral-subtle"` * - `"inverted"` - * - `"outline"` — Bordered badge with transparent background + * - `"outline"` — Bordered badge with the base background * - `"beta"` — Dashed-border badge for beta/experimental features * @default "primary" */ @@ -204,6 +224,10 @@ export interface BadgeProps { appearance?: KumoBadgeAppearance; /** Additional CSS classes merged via `cn()`. */ className?: string; + /** Icon from `@phosphor-icons/react` or a React element. Rendered before children. */ + icon?: Icon | ReactNode; + /** URL to navigate to. When provided, renders the badge as a link. */ + href?: string; /** Content rendered inside the badge. */ children: ReactNode; } @@ -221,8 +245,11 @@ export function Badge({ variant = KUMO_BADGE_DEFAULT_VARIANTS.variant, appearance = KUMO_BADGE_DEFAULT_VARIANTS.appearance, className, + icon, + href, children, }: BadgeProps) { + const LinkComponent = useLinkComponent(); // Crash-safe dot-color lookup via resolveVariant — unknown variants fall // back to "none" (no dot) instead of throwing. const dotColor = @@ -233,16 +260,31 @@ export function Badge({ KUMO_BADGE_DEFAULT_VARIANTS.dotColor, ).classes : ""; - - return ( - + const badgeClassName = cn( + badgeVariants({ variant, appearance }), + icon && "pl-1.5", + className, + ); + const content = ( + <> {dotColor ? ( + ); + + if (href !== undefined) { + return ( + + {content} + + ); + } + + return {content}; } diff --git a/packages/kumo/src/primitives/index.ts b/packages/kumo/src/primitives/index.ts index 85d5eaa7c3..a20d526488 100644 --- a/packages/kumo/src/primitives/index.ts +++ b/packages/kumo/src/primitives/index.ts @@ -37,10 +37,7 @@ export * from "@base-ui/react/meter"; export * from "@base-ui/react/navigation-menu"; export * from "@base-ui/react/number-field"; export * from "@base-ui/react/otp-field"; -export { - OTPField, - OTPField as OTPFieldPreview, -} from "@base-ui/react/otp-field"; +export { OTPField, OTPField as OTPFieldPreview } from "@base-ui/react/otp-field"; export * from "@base-ui/react/popover"; export * from "@base-ui/react/preview-card"; export * from "@base-ui/react/progress"; diff --git a/packages/kumo/src/primitives/otp-field.ts b/packages/kumo/src/primitives/otp-field.ts index 547b5218a7..c7f9cf2dfa 100644 --- a/packages/kumo/src/primitives/otp-field.ts +++ b/packages/kumo/src/primitives/otp-field.ts @@ -11,7 +11,4 @@ */ export * from "@base-ui/react/otp-field"; -export { - OTPField, - OTPField as OTPFieldPreview, -} from "@base-ui/react/otp-field"; +export { OTPField, OTPField as OTPFieldPreview } from "@base-ui/react/otp-field"; From 3da960ee163659d90a29b4a4e13d9704a5cab940 Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 17 Aug 2026 11:34:39 -0700 Subject: [PATCH 2/4] chore: revert accidental format changes --- packages/kumo/src/primitives/index.ts | 5 ++++- packages/kumo/src/primitives/otp-field.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/kumo/src/primitives/index.ts b/packages/kumo/src/primitives/index.ts index a20d526488..85d5eaa7c3 100644 --- a/packages/kumo/src/primitives/index.ts +++ b/packages/kumo/src/primitives/index.ts @@ -37,7 +37,10 @@ export * from "@base-ui/react/meter"; export * from "@base-ui/react/navigation-menu"; export * from "@base-ui/react/number-field"; export * from "@base-ui/react/otp-field"; -export { OTPField, OTPField as OTPFieldPreview } from "@base-ui/react/otp-field"; +export { + OTPField, + OTPField as OTPFieldPreview, +} from "@base-ui/react/otp-field"; export * from "@base-ui/react/popover"; export * from "@base-ui/react/preview-card"; export * from "@base-ui/react/progress"; diff --git a/packages/kumo/src/primitives/otp-field.ts b/packages/kumo/src/primitives/otp-field.ts index c7f9cf2dfa..547b5218a7 100644 --- a/packages/kumo/src/primitives/otp-field.ts +++ b/packages/kumo/src/primitives/otp-field.ts @@ -11,4 +11,7 @@ */ export * from "@base-ui/react/otp-field"; -export { OTPField, OTPField as OTPFieldPreview } from "@base-ui/react/otp-field"; +export { + OTPField, + OTPField as OTPFieldPreview, +} from "@base-ui/react/otp-field"; From a14d0bf90e35ff469b9e5aa48a62d173bf4f0418 Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 17 Aug 2026 11:37:33 -0700 Subject: [PATCH 3/4] fix: add hover ring to href buttons --- packages/kumo/src/components/badge/badge.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kumo/src/components/badge/badge.tsx b/packages/kumo/src/components/badge/badge.tsx index fb185dbc38..ce102b1bf2 100644 --- a/packages/kumo/src/components/badge/badge.tsx +++ b/packages/kumo/src/components/badge/badge.tsx @@ -263,6 +263,7 @@ export function Badge({ const badgeClassName = cn( badgeVariants({ variant, appearance }), icon && "pl-1.5", + href !== undefined && "hover:ring hover:ring-current", className, ); const content = ( From 7a25ff0d2d2a627d5cba0f381ca2908ae71ab138 Mon Sep 17 00:00:00 2001 From: nanda Date: Mon, 17 Aug 2026 12:08:05 -0700 Subject: [PATCH 4/4] fix(badge): remove href as a prop + fix review --- .changeset/linked-badges-base.md | 2 +- .../src/components/demos/BadgeDemo.tsx | 8 +- .../src/pages/components/badge.mdx | 4 +- .../src/pages/tests/badge.astro | 160 ++++++++++++++++++ .../kumo/src/components/badge/badge.test.tsx | 107 ++++++++---- packages/kumo/src/components/badge/badge.tsx | 70 ++++---- packages/kumo/tests/badge-types.test-d.tsx | 43 +++++ 7 files changed, 325 insertions(+), 69 deletions(-) create mode 100644 packages/kumo-docs-astro/src/pages/tests/badge.astro create mode 100644 packages/kumo/tests/badge-types.test-d.tsx diff --git a/.changeset/linked-badges-base.md b/.changeset/linked-badges-base.md index 1cf5298c07..1823a12037 100644 --- a/.changeset/linked-badges-base.md +++ b/.changeset/linked-badges-base.md @@ -2,4 +2,4 @@ "@cloudflare/kumo": minor --- -Add link and icon support to Badge through the `href` and `icon` props, and use the base surface background for outline badges. +Add icon support to filled Badge variants through the `icon` prop, add linked-badge hover styles, and use the base surface background for outline badges. diff --git a/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx b/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx index cee560f2be..1fdf87f8e5 100644 --- a/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/BadgeDemo.tsx @@ -1,4 +1,4 @@ -import { Badge } from "@cloudflare/kumo"; +import { Badge, Link } from "@cloudflare/kumo"; import { CheckCircleIcon } from "@phosphor-icons/react"; export function BadgeSemanticVariantsDemo() { @@ -49,9 +49,9 @@ export function BadgeIconDemo() { export function BadgeLinkDemo() { return ( - - View changelog - + + View changelog + ); } diff --git a/packages/kumo-docs-astro/src/pages/components/badge.mdx b/packages/kumo-docs-astro/src/pages/components/badge.mdx index 504c894466..06466d281e 100644 --- a/packages/kumo-docs-astro/src/pages/components/badge.mdx +++ b/packages/kumo-docs-astro/src/pages/components/badge.mdx @@ -77,7 +77,7 @@ Use `appearance="dot"` for a subtle status indicator with a colored dot. Support ### With an icon -Pass a Phosphor icon component or React element to `icon` to render it before the badge label. +Filled badges accept a Phosphor icon component or React element through `icon`. Dot badges use their status dot and do not accept icons. @@ -85,7 +85,7 @@ Pass a Phosphor icon component or React element to `icon` to render it before th ### Linked badge -Pass `href` to render a badge as a link. Linked badges use the link component configured by [`LinkProvider`](/components/link#framework-integration-linkprovider). +Wrap a badge in [`Link`](/components/link) to make it navigable. The badge adds a ring when its ancestor link is hovered. diff --git a/packages/kumo-docs-astro/src/pages/tests/badge.astro b/packages/kumo-docs-astro/src/pages/tests/badge.astro new file mode 100644 index 0000000000..7c719832c6 --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/tests/badge.astro @@ -0,0 +1,160 @@ +--- +import { Badge, Link, type BadgeVariant } from "@cloudflare/kumo"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import BaseLayout from "../../layouts/BaseLayout.astro"; + +const variants = [ + { value: "primary", label: "Primary" }, + { value: "secondary", label: "Secondary" }, + { value: "error", label: "Error" }, + { value: "warning", label: "Warning" }, + { value: "success", label: "Success" }, + { value: "destructive", label: "Destructive" }, + { value: "info", label: "Info" }, + { value: "beta", label: "Beta" }, + { value: "outline", label: "Outline" }, + { value: "red", label: "Red" }, + { value: "green", label: "Green" }, + { value: "neutral", label: "Neutral" }, + { value: "orange", label: "Orange" }, + { value: "purple", label: "Purple" }, + { value: "teal", label: "Teal" }, + { value: "teal-subtle", label: "Teal subtle" }, + { value: "blue", label: "Blue" }, +] satisfies Array<{ value: BadgeVariant; label: string }>; + +const dotVariants = [ + { value: "success", label: "Success" }, + { value: "warning", label: "Warning" }, + { value: "error", label: "Error" }, + { value: "neutral", label: "Neutral" }, +] satisfies Array<{ value: BadgeVariant; label: string }>; +--- + + +
+
+

Badge test

+

+ Visual fixtures for badge variants and prop combinations. Press D to toggle dark mode, and hover linked badges to verify their rings. +

+
+ +
+
+

+ Filled variants +

+

+ Every color variant rendered plain, with an icon, and inside a link. +

+
+ +
+ + + { + variants.map(({ value, label }) => ( +
+

{label}

+ {label} + + {label} + + + {label} + +
+ )) + } +
+
+ +
+
+

Dot variants

+

+ Supported dot colors rendered plain and inside a link. Dot badges do + not accept icons. +

+
+ +
+ { + dotVariants.map(({ value, label }) => ( +
+ + {label} + + + + {label} + + +
+ )) + } +
+
+ +
+
+

+ Content combinations +

+

+ Default props, nested content, a custom class, and combined icon and + link treatments. +

+
+ +
+ Default + Nested content + Custom padding + + + Linked with icon + + +
+
+
+
diff --git a/packages/kumo/src/components/badge/badge.test.tsx b/packages/kumo/src/components/badge/badge.test.tsx index 6a23700c00..d9fb4b640e 100644 --- a/packages/kumo/src/components/badge/badge.test.tsx +++ b/packages/kumo/src/components/badge/badge.test.tsx @@ -1,11 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { forwardRef } from "react"; import { render, screen } from "@testing-library/react"; import { ArrowRightIcon } from "@phosphor-icons/react"; -import { - LinkProvider, - type LinkComponentProps, -} from "../../utils/link-provider"; import { Badge, badgeVariants, KUMO_BADGE_VARIANTS } from "./badge"; describe("Badge", () => { @@ -20,32 +15,21 @@ describe("Badge", () => { expect(el.tagName).toBe("SPAN"); }); - it("renders as a link when href is provided", () => { - render(Docs); - const link = screen.getByRole("link", { name: "Docs" }); - expect(link.tagName).toBe("A"); - expect(link.getAttribute("href")).toBe("/docs"); + it("merges custom className", () => { + render(Tag); + const el = screen.getByText("Tag"); + expect(el.className).toContain("my-custom"); }); - it("uses the component configured by LinkProvider", () => { - const RouterLink = forwardRef( - ({ children, ...props }, ref) => ( - - {children} - - ), - ); - RouterLink.displayName = "RouterLink"; - + it("shows a ring when an ancestor link is hovered", () => { render( - - Workers - , + + Docs + , ); - - const link = screen.getByRole("link", { name: "Workers" }); - expect(link.getAttribute("data-routed")).toBe("true"); - expect(link.getAttribute("href")).toBe("/workers"); + const badge = screen.getByText("Docs"); + expect(badge.classList.contains("[a:hover_&]:ring")).toBe(true); + expect(badge.classList.contains("[a:hover_&]:ring-current")).toBe(true); }); it("renders an icon", () => { @@ -54,6 +38,12 @@ describe("Badge", () => { }); describe("filled appearance (default)", () => { + it("applies variant classes for filled badges", () => { + render(Error); + const el = screen.getByText("Error"); + expect(el.className).toContain("bg-kumo-danger-tint"); + }); + it("does not render a dot indicator", () => { render(OK); const el = screen.getByText("OK"); @@ -71,6 +61,45 @@ describe("Badge", () => { const badge = screen.getByText("Healthy").closest("span")!; const dot = badge.querySelector("[aria-hidden='true']"); expect(dot).toBeTruthy(); + expect(dot!.className).toContain("bg-kumo-success"); + }); + + it("applies dot appearance classes instead of variant classes", () => { + render( + + Down + , + ); + const badge = screen.getByText("Down").closest("span")!; + // Dot appearance overrides variant bg/text + expect(badge.className).toContain("bg-transparent"); + expect(badge.className).toContain("text-kumo-default"); + // Should NOT contain the filled error classes + expect(badge.className).not.toContain("bg-kumo-danger-tint"); + }); + + it("renders correct dot color per variant", () => { + const cases = [ + { variant: "success" as const, expected: "bg-kumo-success" }, + { variant: "warning" as const, expected: "bg-kumo-badge-orange" }, + { variant: "error" as const, expected: "bg-kumo-badge-red" }, + { variant: "neutral" as const, expected: "bg-kumo-badge-neutral" }, + ]; + + for (const { variant, expected } of cases) { + const { unmount } = render( + + {variant} + , + ); + const badge = screen.getByText(variant).closest("span")!; + const dot = badge.querySelector("[aria-hidden='true']"); + expect(dot, `dot should exist for variant="${variant}"`).toBeTruthy(); + expect(dot!.className, `dot class for variant="${variant}"`).toContain( + expected, + ); + unmount(); + } }); it("does not render a dot for unsupported variants", () => { @@ -104,6 +133,23 @@ describe("Badge", () => { }); describe("badgeVariants", () => { + it("returns base styles with no arguments", () => { + const result = badgeVariants(); + expect(result).toContain("inline-flex"); + expect(result).toContain("rounded-full"); + }); + + it("includes variant classes for filled appearance", () => { + const result = badgeVariants({ variant: "success" }); + expect(result).toContain("bg-kumo-success-tint"); + }); + + it("omits variant classes for dot appearance", () => { + const result = badgeVariants({ variant: "success", appearance: "dot" }); + expect(result).not.toContain("bg-kumo-success-tint"); + expect(result).toContain("bg-transparent"); + }); + it("survives invalid variant without throwing", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const BOGUS = "nope" as any; @@ -133,9 +179,12 @@ describe("KUMO_BADGE_VARIANTS", () => { expect(KUMO_BADGE_VARIANTS.dotColor).toBeDefined(); }); - it("every variant entry has a description", () => { + it("every variant entry has classes and description", () => { for (const [dim, entries] of Object.entries(KUMO_BADGE_VARIANTS)) { - for (const [key, entry] of Object.entries(entries)) { + for (const [key, entry] of Object.entries( + entries as Record, + )) { + expect(entry.classes, `${dim}.${key}.classes`).toBeDefined(); expect(typeof entry.description, `${dim}.${key}.description`).toBe( "string", ); diff --git a/packages/kumo/src/components/badge/badge.tsx b/packages/kumo/src/components/badge/badge.tsx index ce102b1bf2..f9b6355097 100644 --- a/packages/kumo/src/components/badge/badge.tsx +++ b/packages/kumo/src/components/badge/badge.tsx @@ -1,12 +1,11 @@ import React, { type ReactNode } from "react"; import type { Icon } from "@phosphor-icons/react"; import { cn } from "../../utils/cn"; -import { useLinkComponent } from "../../utils/link-provider"; import { resolveVariant } from "../../utils/resolve-variant"; /** Base styles applied to all badge variants. */ export const KUMO_BADGE_BASE_STYLES = - "inline-flex w-fit flex-none shrink-0 items-center justify-self-start gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap"; + "inline-flex w-fit flex-none shrink-0 items-center justify-self-start gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap [a:hover_&]:ring [a:hover_&]:ring-current"; /** Badge variant definitions mapping variant names to their Tailwind classes and descriptions. */ export const KUMO_BADGE_VARIANTS = { @@ -193,7 +192,7 @@ const renderIconNode = (IconComponent?: Icon | ReactNode) => { * Healthy * ``` */ -export interface BadgeProps { +interface BadgeBaseProps { /** * Color variant of the badge. * Recommended semantic variants: @@ -213,25 +212,42 @@ export interface BadgeProps { * @default "primary" */ variant?: KumoBadgeVariant; + /** Additional CSS classes merged via `cn()`. */ + className?: string; + /** Content rendered inside the badge. */ + children: ReactNode; +} + +interface FilledBadgeProps extends BadgeBaseProps { /** * Visual appearance of the badge. * - `"filled"` — Filled background using the variant color (default) * - `"dot"` — Outlined badge with a colored circle dot. Only `success`, - * `warning`, `error`, and `neutral` variants show a dot; other variants - * render the badge without a dot. + * `warning`, `error`, and `neutral` variants show a dot. Dot badges do not + * accept icons. * @default "filled" */ - appearance?: KumoBadgeAppearance; - /** Additional CSS classes merged via `cn()`. */ - className?: string; + appearance?: "filled"; /** Icon from `@phosphor-icons/react` or a React element. Rendered before children. */ icon?: Icon | ReactNode; - /** URL to navigate to. When provided, renders the badge as a link. */ - href?: string; - /** Content rendered inside the badge. */ - children: ReactNode; } +interface DotBadgeProps extends BadgeBaseProps { + /** + * Visual appearance of the badge. + * - `"filled"` — Filled background using the variant color (default) + * - `"dot"` — Outlined badge with a colored circle dot. Only `success`, + * `warning`, `error`, and `neutral` variants show a dot. Dot badges do not + * accept icons. + * @default "filled" + */ + appearance: "dot"; + /** Dot badges use their status dot instead of an icon. */ + icon?: never; +} + +export type BadgeProps = FilledBadgeProps | DotBadgeProps; + /** * Small status label for categorizing or highlighting content. * @@ -246,10 +262,8 @@ export function Badge({ appearance = KUMO_BADGE_DEFAULT_VARIANTS.appearance, className, icon, - href, children, }: BadgeProps) { - const LinkComponent = useLinkComponent(); // Crash-safe dot-color lookup via resolveVariant — unknown variants fall // back to "none" (no dot) instead of throwing. const dotColor = @@ -260,14 +274,14 @@ export function Badge({ KUMO_BADGE_DEFAULT_VARIANTS.dotColor, ).classes : ""; - const badgeClassName = cn( - badgeVariants({ variant, appearance }), - icon && "pl-1.5", - href !== undefined && "hover:ring hover:ring-current", - className, - ); - const content = ( - <> + return ( + {dotColor ? ( ); - - if (href !== undefined) { - return ( - - {content} - - ); - } - - return {content}; } diff --git a/packages/kumo/tests/badge-types.test-d.tsx b/packages/kumo/tests/badge-types.test-d.tsx new file mode 100644 index 0000000000..864cb3be2d --- /dev/null +++ b/packages/kumo/tests/badge-types.test-d.tsx @@ -0,0 +1,43 @@ +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { expectTypeOf } from "vite-plus/test"; +import { Badge, type BadgeProps } from "../src/components/badge/badge"; + +type DotBadgeProps = Extract; + +expectTypeOf["icon"]>().toEqualTypeOf(); + +function FilledBadgeWithIcon() { + return Verified; +} + +function ExplicitFilledBadgeWithIcon() { + return ( + + Verified + + ); +} + +function DotBadgeWithoutIcon() { + return ( + + Healthy + + ); +} + +function DotBadgeWithIcon() { + return ( + // @ts-expect-error Dot badges use their status dot instead of an icon. + + Healthy + + ); +} + +export { + DotBadgeWithIcon, + DotBadgeWithoutIcon, + ExplicitFilledBadgeWithIcon, + FilledBadgeWithIcon, +};