Card
v0.4.0A surface container with configurable padding and an optional header (icon + title + an `action` slot).
registry/behivetech/atoms/card · depends on @behivetech/get-class-name
card.tsx
1import {2 useId,3 type HTMLAttributes,4 type MouseEventHandler,5 type ReactNode,6} from "react";7import { getClassName } from "@behivetech/get-class-name";8import styles from "./card.module.scss";910export type CardPadding = "none" | "sm" | "md" | "lg";11export type CardHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;1213export interface CardProps extends Omit<14 HTMLAttributes<HTMLDivElement>,15 "title" | "onSelect"16> {17 /** Controls internal padding; defaults to none so consumers opt in. Applies to the body when a header is present. */18 padding?: CardPadding;19 /** Optional icon rendered before the title in the header */20 icon?: ReactNode;21 /** Optional header title; omit along with `icon`/`action` to render no header */22 title?: ReactNode;23 /**24 * Which heading tag (`h1`–`h6`) wraps `title`, so the card's title takes its25 * correct place in the page's heading outline. Defaults to `3`, matching a26 * card nested one level below a page's `h1` title and a `section`'s `h2` —27 * override per-instance when a card sits at a different depth.28 */29 headingLevel?: CardHeadingLevel;30 /** Optional content rendered at the end of the header (buttons, menus, a close control, etc.) */31 action?: ReactNode;32 /**33 * Makes the body a real `<button>` — hover/focus affordance plus a centered,34 * stacked content layout — for tile/action-card patterns (a picker grid,35 * for example) instead of a static surface. Distinct from `onClick` (a plain36 * pass-through to the root element, e.g. for stopping click propagation).37 * Omit for a plain, non-interactive card.38 */39 onSelect?: MouseEventHandler<HTMLButtonElement>;40 /**41 * Keeps the header fixed and scrolls only the body when content exceeds the42 * card's height. Only meaningful when the card has a bounded height (a43 * modal or drawer panel, for example).44 */45 scrollableBody?: boolean;46 /** Additional class names to merge with the component root element */47 className?: string;48 /** Card body content */49 children?: ReactNode;50}5152export const Card = ({53 padding = "none",54 icon,55 title,56 headingLevel = 3,57 action,58 onSelect,59 scrollableBody = false,60 className,61 children,62 ...rest63}: CardProps) => {64 const generatedTitleId = useId();65 const hasHeader =66 icon !== undefined || title !== undefined || action !== undefined;67 const hasTitle = title !== undefined;68 const interactive = onSelect !== undefined;69 const HeadingTag = `h${headingLevel}` as React.ElementType;70 // A section with no accessible name is not a meaningful landmark, so only71 // use <section> when there's an actual heading (title) to name it with.72 const RootTag = (hasTitle ? "section" : "div") as React.ElementType;73 const titleId = hasTitle ? `${generatedTitleId}-title` : undefined;7475 const [rootClass, getChildClass] = getClassName({76 className,77 rootClass: "BHT__Card",78 modifiers: {79 [`padding-${padding}`]: true,80 interactive,81 "scrollable-body": scrollableBody,82 },83 styles,84 });8586 const BodyTag = (interactive ? "button" : "div") as React.ElementType;8788 return (89 <RootTag {...rest} className={rootClass} aria-labelledby={titleId}>90 {hasHeader && (91 <div className={getChildClass("header")}>92 <span className={getChildClass("heading")}>93 {icon !== undefined && (94 <span className={getChildClass("icon")}>{icon}</span>95 )}96 {hasTitle && (97 <HeadingTag id={titleId} className={getChildClass("title")}>98 {title}99 </HeadingTag>100 )}101 </span>102 {action !== undefined && (103 <span className={getChildClass("action")}>{action}</span>104 )}105 </div>106 )}107 <BodyTag108 type={interactive ? "button" : undefined}109 onClick={onSelect}110 className={getChildClass("body-padding")}111 >112 {children}113 </BodyTag>114 </RootTag>115 );116};
card.module.scss
1// Material Design 3 outlined card.2//3// Modal and Drawer render their panels as a Card and re-theme it through the4// `--card-background` / `--card-border` / `--card-radius` custom properties5// rather than by reaching into these classes, so those three stay the only6// public styling hooks. Each color falls back to the pre-MD3 `--color-*` name,7// which `theme.base-styles` aliases to the matching MD3 role.89.BHT__Card {10 background: var(11 --card-background,12 var(--md-sys-color-surface, var(--color-surface))13 );14 color: var(--md-sys-color-on-surface, var(--color-on-surface));15 border: var(16 --card-border,17 1px solid var(--md-sys-color-outline-variant, var(--color-border))18 );19 border-radius: var(--card-radius, var(--md-sys-shape-corner-medium, 12px));20 // Grid/flex items default to their content's min-content width, not their21 // track/basis minimum — without this, a Card holding wide content (a long22 // stat value, an action row) forces its container wider instead of shrinking.23 min-width: 0;2425 &__header {26 padding: var(--spacing-4) var(--spacing-6);27 border-bottom: 1px solid28 var(--md-sys-color-outline-variant, var(--color-border));29 display: flex;30 align-items: center;31 justify-content: space-between;32 gap: var(--spacing-4);33 }3435 &__heading {36 display: flex;37 align-items: center;38 gap: var(--spacing-2);39 min-width: 0;40 }4142 &__icon {43 display: inline-flex;44 line-height: 1;45 }4647 &__title {48 margin: 0;49 font-family: var(--md-sys-typescale-title-medium-font, var(--font-heading));50 font-size: var(--md-sys-typescale-title-medium-size, 1rem);51 font-weight: var(--md-sys-typescale-title-medium-weight, 500);52 line-height: var(--md-sys-typescale-title-medium-line-height, 1.5rem);53 letter-spacing: var(--md-sys-typescale-title-medium-tracking, 0.0094em);54 color: var(--md-sys-color-on-surface, var(--color-text-heading));55 overflow: hidden;56 text-overflow: ellipsis;57 white-space: nowrap;58 }5960 &__action {61 display: inline-flex;62 align-items: center;63 gap: var(--spacing-2);64 flex-shrink: 0;65 }6667 &__body-padding {68 /* Resets so this works whether it renders as a <div> (static) or a69 <button> (interactive) — the button UA defaults are what actually70 need overriding here. */71 display: block;72 width: 100%;73 padding: 0;74 margin: 0;75 background: none;76 border: none;77 font: inherit;78 color: inherit;79 text-align: inherit;80 }8182 &--interactive {83 &.BHT__Card {84 overflow: hidden;85 }8687 .BHT__Card {88 &__body-padding {89 position: relative;90 isolation: isolate;91 display: flex;92 flex-direction: column;93 align-items: center;94 gap: var(--spacing-2);95 text-align: center;96 cursor: pointer;9798 // State layer: a veil of the content color, as on Button, so hover and99 // press follow the palette instead of swapping to a fixed surface.100 &::after {101 content: "";102 position: absolute;103 inset: 0;104 background: currentColor;105 opacity: 0;106 pointer-events: none;107 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)108 var(--md-sys-motion-easing-standard, ease);109 }110111 &:hover::after {112 opacity: var(--md-sys-state-hover-opacity, 0.08);113 }114115 &:focus-visible::after {116 opacity: var(--md-sys-state-focus-opacity, 0.1);117 }118119 &:active::after {120 opacity: var(--md-sys-state-pressed-opacity, 0.1);121 }122123 // Inset rather than the usual +2px offset: the root's `overflow:124 // hidden` (needed to clip the state layer to the corners) would cut125 // off a ring drawn outside the body.126 &:focus-visible {127 outline: 3px solid128 var(--md-sys-color-secondary, var(--color-border-focus));129 outline-offset: -3px;130 }131 }132 }133 }134135 // Header stays put; only the body scrolls when content exceeds the card's136 // height — for cards given a bounded height (a modal or drawer panel).137 &--scrollable-body {138 &.BHT__Card {139 display: flex;140 flex-direction: column;141 overflow: hidden;142 }143144 .BHT__Card {145 &__body-padding {146 overflow-y: auto;147 flex: 1;148 min-height: 0;149 }150 }151 }152153 // &--padding-none needs no rule — &__body-padding already resets padding to 0 above.154155 &--padding-sm {156 .BHT__Card {157 &__body-padding {158 padding: var(--spacing-3);159 }160 }161 }162163 &--padding-md {164 .BHT__Card {165 &__body-padding {166 padding: var(--spacing-5);167 }168 }169 }170171 &--padding-lg {172 .BHT__Card {173 &__body-padding {174 padding: var(--spacing-8);175 }176 }177 }178}
index.ts
1export { Card } from "./card.js";2export type { CardProps, CardPadding, CardHeadingLevel } from "./card.js";
card.composition.tsx
1import { Card } from "./card.js";23export const DefaultCard = () => (4 <Card padding="md">Default card with md padding</Card>5);67export const NoPaddingCard = () => (8 <Card padding="none">Card with no padding</Card>9);1011export const SmallPaddingCard = () => (12 <Card padding="sm">Card with sm padding</Card>13);1415export const LargePaddingCard = () => (16 <Card padding="lg">Card with lg padding</Card>17);1819export const CardWithHeader = () => (20 <Card title="Settings" padding="md">21 Card body content22 </Card>23);2425export const CardWithIconAndAction = () => (26 <Card27 icon="🧩"28 title="Block"29 action={<button onClick={() => {}}>✕</button>}30 padding="md"31 >32 Card body content33 </Card>34);3536export const InteractiveCard = () => (37 <Card padding="md" onSelect={() => {}}>38 <span>🧩</span>39 <span>Click me</span>40 </Card>41);4243export const CardAsPageLevelSection = () => (44 <Card title="Settings" headingLevel={2} padding="md">45 Used directly under a page’s h1, with no intervening section — its46 title takes the h2 slot instead of the h3 default.47 </Card>48);
card.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { describe, it, expect, vi } from "vitest";4import { Card } from "./card.js";56describe("Card", () => {7 it("renders children", () => {8 const { getByText } = render(<Card>Content</Card>);9 expect(getByText("Content")).toBeTruthy();10 });1112 it("applies padding modifier class to the root element", () => {13 const { container } = render(<Card padding="md">Content</Card>);14 expect((container.firstChild as HTMLElement).className).toContain(15 "BHT__Card--padding-md",16 );17 });1819 it("merges className prop", () => {20 const { container } = render(<Card className="extra">Content</Card>);21 expect(container.firstChild).toHaveClass("extra");22 });2324 it("renders no header when icon, title, and action are all omitted", () => {25 const { container } = render(<Card>Content</Card>);26 expect(container.querySelector('[class*="header"]')).toBeNull();27 });2829 it("renders a plain div root when there is no title", () => {30 const { container } = render(<Card icon="🧩">Content</Card>);31 expect(container.firstChild).toBeInstanceOf(HTMLDivElement);32 });3334 it("renders a section root, labelled by the title, when a title is given", () => {35 const { container } = render(<Card title="Settings">Content</Card>);36 const root = container.firstChild as HTMLElement;37 expect(root.tagName).toBe("SECTION");38 const labelledBy = root.getAttribute("aria-labelledby");39 expect(labelledBy).toBeTruthy();40 expect(document.getElementById(labelledBy as string)?.textContent).toBe(41 "Settings",42 );43 });4445 it("renders title and icon in the header", () => {46 render(47 <Card icon="🧩" title="Settings">48 Content49 </Card>,50 );51 expect(screen.getByText("Settings")).toBeTruthy();52 expect(screen.getByText("🧩")).toBeTruthy();53 });5455 it("renders the title in an h3 by default", () => {56 render(<Card title="Settings">Content</Card>);57 expect(58 screen.getByRole("heading", { level: 3, name: "Settings" }),59 ).toBeTruthy();60 });6162 it("renders the title in the heading level given by headingLevel", () => {63 render(64 <Card title="Settings" headingLevel={2}>65 Content66 </Card>,67 );68 expect(69 screen.getByRole("heading", { level: 2, name: "Settings" }),70 ).toBeTruthy();71 });7273 it("renders the action slot", () => {74 render(75 <Card title="Settings" action={<button>Close</button>}>76 Content77 </Card>,78 );79 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();80 });8182 it("keeps a static body className regardless of padding value — only the root modifier changes", () => {83 const { container } = render(84 <Card title="Settings" padding="lg">85 Content86 </Card>,87 );88 expect(container.querySelector('[class*="body-padding"]')).toBeTruthy();89 expect((container.firstChild as HTMLElement).className).toContain(90 "BHT__Card--padding-lg",91 );92 });9394 it("always wraps children in the body element, even without a header", () => {95 const { container } = render(<Card padding="sm">Content</Card>);96 expect(container.querySelector('[class*="body-padding"]')).toBeTruthy();97 });9899 it("renders a static div body when onSelect is omitted", () => {100 const { container } = render(<Card>Content</Card>);101 expect(container.querySelector('[class*="body-padding"]')?.tagName).toBe(102 "DIV",103 );104 });105106 it("renders the body as a button and applies the interactive modifier when onSelect is given", () => {107 const { container } = render(<Card onSelect={() => {}}>Content</Card>);108 expect(container.querySelector('[class*="body-padding"]')?.tagName).toBe(109 "BUTTON",110 );111 expect((container.firstChild as HTMLElement).className).toContain(112 "BHT__Card--interactive",113 );114 });115116 it("applies the scrollable-body modifier only when scrollableBody is set", () => {117 const { container, rerender } = render(<Card>Content</Card>);118 expect((container.firstChild as HTMLElement).className).not.toContain(119 "BHT__Card--scrollable-body",120 );121 rerender(<Card scrollableBody>Content</Card>);122 expect((container.firstChild as HTMLElement).className).toContain(123 "BHT__Card--scrollable-body",124 );125 });126127 it("calls onSelect when the interactive body is clicked", async () => {128 const handleSelect = vi.fn();129 render(<Card onSelect={handleSelect}>Content</Card>);130 await userEvent.click(screen.getByRole("button", { name: "Content" }));131 expect(handleSelect).toHaveBeenCalledTimes(1);132 });133134 it("still forwards a plain onClick to the root element for non-interactive cards", async () => {135 const handleClick = vi.fn();136 const { container } = render(<Card onClick={handleClick}>Content</Card>);137 await userEvent.click(container.firstChild as HTMLElement);138 expect(handleClick).toHaveBeenCalledTimes(1);139 });140});