Behive Tech Registry

Card

v0.4.0

A surface container with configurable padding and an optional header (icon + title + an `action` slot).

pnpm add @behivetech/atoms.card

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";
9
10export type CardPadding = "none" | "sm" | "md" | "lg";
11export type CardHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
12
13export 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 its
25 * correct place in the page's heading outline. Defaults to `3`, matching a
26 * 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 plain
36 * 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 the
42 * card's height. Only meaningful when the card has a bounded height (a
43 * 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}
51
52export const Card = ({
53 padding = "none",
54 icon,
55 title,
56 headingLevel = 3,
57 action,
58 onSelect,
59 scrollableBody = false,
60 className,
61 children,
62 ...rest
63}: 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 only
71 // 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;
74
75 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 });
85
86 const BodyTag = (interactive ? "button" : "div") as React.ElementType;
87
88 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 <BodyTag
108 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 the
4// `--card-background` / `--card-border` / `--card-radius` custom properties
5// rather than by reaching into these classes, so those three stay the only
6// public styling hooks. Each color falls back to the pre-MD3 `--color-*` name,
7// which `theme.base-styles` aliases to the matching MD3 role.
8
9.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 their
21 // track/basis minimum — without this, a Card holding wide content (a long
22 // stat value, an action row) forces its container wider instead of shrinking.
23 min-width: 0;
24
25 &__header {
26 padding: var(--spacing-4) var(--spacing-6);
27 border-bottom: 1px solid
28 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 }
34
35 &__heading {
36 display: flex;
37 align-items: center;
38 gap: var(--spacing-2);
39 min-width: 0;
40 }
41
42 &__icon {
43 display: inline-flex;
44 line-height: 1;
45 }
46
47 &__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 }
59
60 &__action {
61 display: inline-flex;
62 align-items: center;
63 gap: var(--spacing-2);
64 flex-shrink: 0;
65 }
66
67 &__body-padding {
68 /* Resets so this works whether it renders as a <div> (static) or a
69 <button> (interactive) — the button UA defaults are what actually
70 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 }
81
82 &--interactive {
83 &.BHT__Card {
84 overflow: hidden;
85 }
86
87 .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;
97
98 // State layer: a veil of the content color, as on Button, so hover and
99 // 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 }
110
111 &:hover::after {
112 opacity: var(--md-sys-state-hover-opacity, 0.08);
113 }
114
115 &:focus-visible::after {
116 opacity: var(--md-sys-state-focus-opacity, 0.1);
117 }
118
119 &:active::after {
120 opacity: var(--md-sys-state-pressed-opacity, 0.1);
121 }
122
123 // Inset rather than the usual +2px offset: the root's `overflow:
124 // hidden` (needed to clip the state layer to the corners) would cut
125 // off a ring drawn outside the body.
126 &:focus-visible {
127 outline: 3px solid
128 var(--md-sys-color-secondary, var(--color-border-focus));
129 outline-offset: -3px;
130 }
131 }
132 }
133 }
134
135 // Header stays put; only the body scrolls when content exceeds the card's
136 // 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 }
143
144 .BHT__Card {
145 &__body-padding {
146 overflow-y: auto;
147 flex: 1;
148 min-height: 0;
149 }
150 }
151 }
152
153 // &--padding-none needs no rule — &__body-padding already resets padding to 0 above.
154
155 &--padding-sm {
156 .BHT__Card {
157 &__body-padding {
158 padding: var(--spacing-3);
159 }
160 }
161 }
162
163 &--padding-md {
164 .BHT__Card {
165 &__body-padding {
166 padding: var(--spacing-5);
167 }
168 }
169 }
170
171 &--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";
2
3export const DefaultCard = () => (
4 <Card padding="md">Default card with md padding</Card>
5);
6
7export const NoPaddingCard = () => (
8 <Card padding="none">Card with no padding</Card>
9);
10
11export const SmallPaddingCard = () => (
12 <Card padding="sm">Card with sm padding</Card>
13);
14
15export const LargePaddingCard = () => (
16 <Card padding="lg">Card with lg padding</Card>
17);
18
19export const CardWithHeader = () => (
20 <Card title="Settings" padding="md">
21 Card body content
22 </Card>
23);
24
25export const CardWithIconAndAction = () => (
26 <Card
27 icon="🧩"
28 title="Block"
29 action={<button onClick={() => {}}>✕</button>}
30 padding="md"
31 >
32 Card body content
33 </Card>
34);
35
36export const InteractiveCard = () => (
37 <Card padding="md" onSelect={() => {}}>
38 <span>🧩</span>
39 <span>Click me</span>
40 </Card>
41);
42
43export const CardAsPageLevelSection = () => (
44 <Card title="Settings" headingLevel={2} padding="md">
45 Used directly under a page&rsquo;s h1, with no intervening section — its
46 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";
5
6describe("Card", () => {
7 it("renders children", () => {
8 const { getByText } = render(<Card>Content</Card>);
9 expect(getByText("Content")).toBeTruthy();
10 });
11
12 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 });
18
19 it("merges className prop", () => {
20 const { container } = render(<Card className="extra">Content</Card>);
21 expect(container.firstChild).toHaveClass("extra");
22 });
23
24 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 });
28
29 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 });
33
34 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 });
44
45 it("renders title and icon in the header", () => {
46 render(
47 <Card icon="🧩" title="Settings">
48 Content
49 </Card>,
50 );
51 expect(screen.getByText("Settings")).toBeTruthy();
52 expect(screen.getByText("🧩")).toBeTruthy();
53 });
54
55 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 });
61
62 it("renders the title in the heading level given by headingLevel", () => {
63 render(
64 <Card title="Settings" headingLevel={2}>
65 Content
66 </Card>,
67 );
68 expect(
69 screen.getByRole("heading", { level: 2, name: "Settings" }),
70 ).toBeTruthy();
71 });
72
73 it("renders the action slot", () => {
74 render(
75 <Card title="Settings" action={<button>Close</button>}>
76 Content
77 </Card>,
78 );
79 expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
80 });
81
82 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 Content
86 </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 });
93
94 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 });
98
99 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 });
105
106 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 });
115
116 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 });
126
127 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 });
133
134 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});