From df0a359e45070dcb413ac8fe6f4e258a5641430a Mon Sep 17 00:00:00 2001 From: Matt Rothenberg Date: Mon, 13 Apr 2026 16:21:59 -0400 Subject: [PATCH] feat(LayerCard): add actions prop and LayerCard.Action component --- .changeset/layer-card-actions.md | 10 + .../src/components/demos/LayerCardDemo.tsx | 173 ++++++++++++++++-- .../src/pages/components/layer-card.mdx | 24 +++ .../kumo/src/components/layer-card/index.ts | 7 +- .../src/components/layer-card/layer-card.tsx | 144 ++++++++++++++- 5 files changed, 339 insertions(+), 19 deletions(-) create mode 100644 .changeset/layer-card-actions.md diff --git a/.changeset/layer-card-actions.md b/.changeset/layer-card-actions.md new file mode 100644 index 0000000000..2dd1bbc88b --- /dev/null +++ b/.changeset/layer-card-actions.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/kumo": minor +--- + +feat(LayerCard): Add `actions` prop to `LayerCard.Secondary` and `LayerCard.Action` component + +- `LayerCard.Secondary` now accepts an `actions` prop for header actions (buttons, menus) +- New `LayerCard.Action` component enforces consistent sizing (sm) and shape (square) +- `LayerCard.Action` supports a `render` prop for custom elements (e.g., links) +- Header height is now consistent (`min-h-10`) whether actions are present or not diff --git a/packages/kumo-docs-astro/src/components/demos/LayerCardDemo.tsx b/packages/kumo-docs-astro/src/components/demos/LayerCardDemo.tsx index 87678b2906..d2b392ae0a 100644 --- a/packages/kumo-docs-astro/src/components/demos/LayerCardDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/LayerCardDemo.tsx @@ -1,21 +1,25 @@ -import { LayerCard, Button } from "@cloudflare/kumo"; -import { ArrowRightIcon } from "@phosphor-icons/react"; +"use client"; + +import { useState } from "react"; +import { LayerCard, Badge, DropdownMenu } from "@cloudflare/kumo"; +import { + ArrowRightIcon, + PlusIcon, + DotsThreeIcon, + StarIcon, + FunnelIcon, +} from "@phosphor-icons/react"; export function LayerCardDemo() { return ( - -
Next Steps
- + + } + > + Next Steps - Get started with Kumo
); @@ -52,3 +56,146 @@ export function LayerCardMultipleDemo() { ); } + +/** + * LayerCard with badge in the header. + */ +export function LayerCardWithBadgeDemo() { + return ( + + + Domains + 3 + + +

example.com

+
+
+ ); +} + +/** + * LayerCard with multiple actions. + */ +export function LayerCardWithActionsDemo() { + return ( + + + + + + + + + + Edit + Duplicate + + Delete + + + + } + > + Domains + 1 + + +
+ + api.example.com +
+
+
+ ); +} + +/** + * Side by side comparison: with and without actions. + */ +export function LayerCardComparisonDemo() { + return ( +
+ + No Actions + +

Same header height

+
+
+ + } + > + With Actions + + +

Consistent alignment

+
+
+
+ ); +} + +/** + * LayerCard with view filter using dropdown radio (instead of tabs). + */ +export function LayerCardWithFilterDemo() { + const [view, setView] = useState("all"); + + const items = { + all: ["api.example.com", "dashboard.example.com", "archived.example.com"], + active: ["api.example.com", "dashboard.example.com"], + archived: ["archived.example.com"], + }; + + const filtered = items[view as keyof typeof items]; + + return ( + + + + + + + + + All + + + + Active + + + + Archived + + + + + + } + > + Domains + {filtered.length} + + +
+ {filtered.map((domain) => ( +
+ + {domain} +
+ ))} +
+
+
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/components/layer-card.mdx b/packages/kumo-docs-astro/src/pages/components/layer-card.mdx index b326a8196a..86767bb58b 100644 --- a/packages/kumo-docs-astro/src/pages/components/layer-card.mdx +++ b/packages/kumo-docs-astro/src/pages/components/layer-card.mdx @@ -13,6 +13,9 @@ import { LayerCardDemo, LayerCardBasicDemo, LayerCardMultipleDemo, + LayerCardComparisonDemo, + LayerCardWithActionsDemo, + LayerCardWithFilterDemo, } from "~/components/demos/LayerCardDemo"; {/* Hero Demo */} @@ -76,6 +79,27 @@ export default function Example() { +With Actions +

Use the `actions` prop on `LayerCard.Secondary` to add buttons or menus.

+ + + + +Comparison: With and Without Actions + + + + +View Filter (Alternative to Tabs) +

+ Instead of cramming tabs into the header, use a dropdown with radio items to + switch views. This pattern scales better and maintains consistent header + height. +

+ + + + {/* API Reference */} diff --git a/packages/kumo/src/components/layer-card/index.ts b/packages/kumo/src/components/layer-card/index.ts index ed0effa4b3..e62ce59592 100644 --- a/packages/kumo/src/components/layer-card/index.ts +++ b/packages/kumo/src/components/layer-card/index.ts @@ -1 +1,6 @@ -export { LayerCard } from "./layer-card"; +export { + LayerCard, + type LayerCardSecondaryProps, + type LayerCardActionProps, + type LayerCardActionRenderProps, +} from "./layer-card"; diff --git a/packages/kumo/src/components/layer-card/layer-card.tsx b/packages/kumo/src/components/layer-card/layer-card.tsx index 7e401cdcf9..8cc9165264 100644 --- a/packages/kumo/src/components/layer-card/layer-card.tsx +++ b/packages/kumo/src/components/layer-card/layer-card.tsx @@ -1,5 +1,12 @@ -import type { FC, PropsWithChildren } from "react"; +import { + forwardRef, + type FC, + type PropsWithChildren, + type ReactNode, +} from "react"; import { cn } from "../../utils/cn"; +import { Button, buttonVariants } from "../button"; +import type { Icon } from "@phosphor-icons/react"; /** LayerCard variant definitions (currently empty, reserved for future additions). */ export const KUMO_LAYER_CARD_VARIANTS = { @@ -36,6 +43,18 @@ export type LayerCardProps = PropsWithChildren< } >; +/** + * LayerCard.Secondary props with optional actions slot. + */ +export type LayerCardSecondaryProps = PropsWithChildren< + KumoLayerCardVariantsProps & { + /** Additional CSS classes merged via `cn()`. */ + className?: string; + /** Actions to display on the right side of the header (e.g., buttons, menus) */ + actions?: ReactNode; + } +>; + /** * Elevated card with primary/secondary content layers for dashboard widgets. * @@ -46,20 +65,43 @@ export type LayerCardProps = PropsWithChildren< * Quick start guide * * ``` + * + * @example With actions + * ```tsx + * + * + * + * + * } + * > + * Domains + * + * example.com + * + * ``` */ function LayerCardRoot({ children, className }: LayerCardProps) { return
{children}
; } -function LayerCardSecondary({ children, className }: LayerCardProps) { +function LayerCardSecondary({ + children, + className, + actions, +}: LayerCardSecondaryProps) { return (
- {children} +
{children}
+ {actions &&
{actions}
}
); } @@ -77,14 +119,106 @@ function LayerCardPrimary({ children, className }: LayerCardProps) { ); } +/** + * Props passed to the render prop function. + */ +export interface LayerCardActionRenderProps { + className: string; + "aria-label": string; + children: React.ReactNode; +} + +/** + * LayerCard.Action props - pre-configured icon button for header actions. + */ +export interface LayerCardActionProps { + /** Phosphor icon component */ + icon: Icon; + /** Accessible label (required) */ + label: string; + /** Button variant */ + variant?: "ghost" | "secondary"; + /** Click handler (for button) */ + onClick?: () => void; + /** Disabled state */ + disabled?: boolean; + /** Render prop for custom elements (e.g., links). Receives className, aria-label, and children. */ + render?: (props: LayerCardActionRenderProps) => React.ReactNode; +} + +/** + * Pre-configured action button for LayerCard headers. + * Enforces consistent sizing (sm) and shape (square). + * + * @example Button + * ```tsx + * + * ``` + * + * @example Link (with render prop) + * ```tsx + * } + * /> + * ``` + */ +const LayerCardAction = forwardRef( + ( + { + icon: IconComponent, + label, + variant = "ghost", + onClick, + disabled, + render, + }, + ref, + ) => { + const iconNode = ; + + // Render prop for custom elements (links, etc.) + if (render) { + return ( + <> + {render({ + className: buttonVariants({ variant, size: "sm", shape: "square" }), + "aria-label": label, + children: iconNode, + })} + + ); + } + + return ( + + ); + }, +); + +LayerCardAction.displayName = "LayerCard.Action"; + type LayerCardComponent = FC & { Primary: FC; - Secondary: FC; + Secondary: FC; + Action: typeof LayerCardAction; }; const LayerCard = Object.assign(LayerCardRoot, { Primary: LayerCardPrimary, Secondary: LayerCardSecondary, + Action: LayerCardAction, }) as LayerCardComponent; export { LayerCard };