diff --git a/.changeset/linked-badges-base.md b/.changeset/linked-badges-base.md new file mode 100644 index 0000000000..1823a12037 --- /dev/null +++ b/.changeset/linked-badges-base.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": minor +--- + +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 a87582c517..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,5 @@ -import { Badge } from "@cloudflare/kumo"; +import { Badge, Link } 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..06466d281e 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 + +Filled badges accept a Phosphor icon component or React element through `icon`. Dot badges use their status dot and do not accept icons. + + + + + +### Linked badge + +Wrap a badge in [`Link`](/components/link) to make it navigable. The badge adds a ring when its ancestor link is hovered. + + + + + ## API Reference 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 939007d1d4..d9fb4b640e 100644 --- a/packages/kumo/src/components/badge/badge.test.tsx +++ b/packages/kumo/src/components/badge/badge.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { render, screen } from "@testing-library/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; import { Badge, badgeVariants, KUMO_BADGE_VARIANTS } from "./badge"; describe("Badge", () => { @@ -20,6 +21,22 @@ describe("Badge", () => { expect(el.className).toContain("my-custom"); }); + it("shows a ring when an ancestor link is hovered", () => { + render( + + Docs + , + ); + 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", () => { + render(Next); + expect(screen.getByText("Next").querySelector("svg")).toBeTruthy(); + }); + describe("filled appearance (default)", () => { it("applies variant classes for filled badges", () => { render(Error); diff --git a/packages/kumo/src/components/badge/badge.tsx b/packages/kumo/src/components/badge/badge.tsx index 92aeaa7863..f9b6355097 100644 --- a/packages/kumo/src/components/badge/badge.tsx +++ b/packages/kumo/src/components/badge/badge.tsx @@ -1,10 +1,11 @@ -import type { ReactNode } from "react"; +import React, { type ReactNode } from "react"; +import type { Icon } from "@phosphor-icons/react"; import { cn } from "../../utils/cn"; 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 [a:hover_&]:ring [a:hover_&]:ring-current"; /** Badge variant definitions mapping variant names to their Tailwind classes and descriptions. */ export const KUMO_BADGE_VARIANTS = { @@ -44,8 +45,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 +163,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. * @@ -173,7 +192,7 @@ export type BadgeVariant = KumoBadgeVariant; * Healthy * ``` */ -export interface BadgeProps { +interface BadgeBaseProps { /** * Color variant of the badge. * Recommended semantic variants: @@ -188,26 +207,47 @@ 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" */ 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; - /** Content rendered inside the badge. */ - children: ReactNode; + appearance?: "filled"; + /** Icon from `@phosphor-icons/react` or a React element. Rendered before children. */ + icon?: Icon | 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. * @@ -221,6 +261,7 @@ export function Badge({ variant = KUMO_BADGE_DEFAULT_VARIANTS.variant, appearance = KUMO_BADGE_DEFAULT_VARIANTS.appearance, className, + icon, children, }: BadgeProps) { // Crash-safe dot-color lookup via resolveVariant — unknown variants fall @@ -233,15 +274,21 @@ export function Badge({ KUMO_BADGE_DEFAULT_VARIANTS.dotColor, ).classes : ""; - return ( - + {dotColor ? ( ); 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, +};