Skip to content

Commit 6f50222

Browse files
committed
Add Card Component
1 parent e33f7b4 commit 6f50222

10 files changed

Lines changed: 299 additions & 0 deletions

File tree

packages/ui/src/card/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Card as CardRoot } from './primitives/card';
2+
import { CardBody } from './primitives/card-body';
3+
import { CardHeader } from './primitives/card-header';
4+
import { CardSummary } from './primitives/card-summary';
5+
6+
/**
7+
* A card component with header, body, and footer slots.
8+
*/
9+
export const Card = Object.assign( CardRoot, {
10+
Header: CardHeader,
11+
Body: CardBody,
12+
Summary: CardSummary,
13+
} ) as typeof CardRoot & {
14+
Header: typeof CardHeader;
15+
Body: typeof CardBody;
16+
Summary: typeof CardSummary;
17+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import type { CardSectionProps } from '../types';
5+
import styles from '../style.module.css';
6+
import resetStyles from '../../utils/css/resets.module.css';
7+
8+
/**
9+
* Default render function that renders a div element with the given props.
10+
*/
11+
const DEFAULT_RENDER = ( props: React.ComponentPropsWithoutRef< 'div' > ) => (
12+
<div { ...props } />
13+
);
14+
15+
export const CardBody = forwardRef< HTMLDivElement, CardSectionProps >(
16+
function CardBody( { className, render = DEFAULT_RENDER, ...props }, ref ) {
17+
const element = useRender( {
18+
render,
19+
ref,
20+
props: mergeProps< 'div' >( props, {
21+
className: clsx(
22+
resetStyles[ 'box-sizing' ],
23+
styles.body,
24+
className
25+
),
26+
} ),
27+
} );
28+
29+
return element;
30+
}
31+
);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import type { CardSectionProps } from '../types';
5+
import styles from '../style.module.css';
6+
import resetStyles from '../../utils/css/resets.module.css';
7+
8+
const DEFAULT_RENDER = ( props: React.ComponentPropsWithoutRef< 'div' > ) => (
9+
<div { ...props } />
10+
);
11+
12+
export const CardHeader = forwardRef< HTMLDivElement, CardSectionProps >(
13+
function CardHeader(
14+
{ className, render = DEFAULT_RENDER, ...props },
15+
ref
16+
) {
17+
const element = useRender( {
18+
render,
19+
ref,
20+
props: mergeProps< 'div' >( props, {
21+
className: clsx(
22+
resetStyles[ 'box-sizing' ],
23+
styles.header,
24+
className
25+
),
26+
} ),
27+
} );
28+
29+
return element;
30+
}
31+
);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import styles from '../style.module.css';
5+
import resetStyles from '../../utils/css/resets.module.css';
6+
import type { CardSectionProps } from '../types';
7+
8+
const DEFAULT_RENDER = ( props: React.ComponentPropsWithoutRef< 'div' > ) => (
9+
<div { ...props } />
10+
);
11+
12+
export const CardSummary = forwardRef< HTMLDivElement, CardSectionProps >(
13+
function CardSummary(
14+
{ className, render = DEFAULT_RENDER, ...props },
15+
ref
16+
) {
17+
const element = useRender( {
18+
render,
19+
ref,
20+
props: mergeProps< 'div' >( props, {
21+
className: clsx(
22+
resetStyles[ 'box-sizing' ],
23+
styles.summary,
24+
className
25+
),
26+
} ),
27+
} );
28+
29+
return element;
30+
}
31+
);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { mergeProps, useRender } from '@base-ui/react';
2+
import clsx from 'clsx';
3+
import { forwardRef } from '@wordpress/element';
4+
import type { CardProps } from '../types';
5+
import styles from '../style.module.css';
6+
import resetStyles from '../../utils/css/resets.module.css';
7+
8+
const DEFAULT_RENDER = ( props: React.ComponentPropsWithoutRef< 'div' > ) => (
9+
<div { ...props } />
10+
);
11+
12+
export const Card = forwardRef< HTMLDivElement, CardProps >( function Card(
13+
{ className, render = DEFAULT_RENDER, ...props },
14+
ref
15+
) {
16+
const element = useRender( {
17+
render,
18+
ref,
19+
props: mergeProps< 'div' >( props, {
20+
className: clsx(
21+
resetStyles[ 'box-sizing' ],
22+
styles.card,
23+
className
24+
),
25+
} ),
26+
} );
27+
28+
return element;
29+
} );
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import type { CSSProperties } from 'react';
3+
import { Badge } from '../../badge';
4+
import { Card } from '../index';
5+
6+
const meta: Meta< typeof Card > = {
7+
title: 'Design System/Components/Card',
8+
component: Card,
9+
};
10+
export default meta;
11+
12+
type Story = StoryObj< typeof Card >;
13+
14+
const placeholderStyles: CSSProperties = {
15+
height: '240px',
16+
background: 'var(--wpds-color-bg-surface-neutral-weak)',
17+
borderRadius: 'var(--wpds-border-radius-surface-md)',
18+
display: 'grid',
19+
placeItems: 'center',
20+
textTransform: 'uppercase',
21+
color: 'var(--wpds-color-fg-content-neutral)',
22+
fontSize: 'var(--wpds-font-size-xs)',
23+
fontWeight: 'var(--wpds-font-weight-medium)',
24+
lineHeight: 'var(--wpds-font-line-height-xs)',
25+
};
26+
27+
export const Default: Story = {
28+
render: ( args ) => (
29+
<Card { ...args }>
30+
<Card.Header>Card title</Card.Header>
31+
<Card.Body>
32+
<div style={ placeholderStyles }>Content</div>
33+
</Card.Body>
34+
</Card>
35+
),
36+
};
37+
38+
export const WithSummary: Story = {
39+
render: ( args ) => (
40+
<Card { ...args }>
41+
<Card.Header>
42+
<span>Card title</span>
43+
<Card.Summary>
44+
<Badge intent="low">Summary</Badge>
45+
</Card.Summary>
46+
</Card.Header>
47+
<Card.Body>
48+
<div style={ placeholderStyles }>Content</div>
49+
</Card.Body>
50+
</Card>
51+
),
52+
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2+
3+
@layer wp-ui-components {
4+
.card {
5+
--wp-ui-card-gap: calc(var(--wpds-dimension-gap-md) + var(--wpds-dimension-gap-2xs));
6+
--wp-ui-card-padding-inline: var(--wpds-dimension-padding-surface-md);
7+
--wp-ui-card-padding-block-start:
8+
calc(var(--wpds-dimension-padding-surface-sm) +
9+
var(--wpds-dimension-gap-2xs));
10+
--wp-ui-card-padding-block-end: var(--wpds-dimension-padding-surface-md);
11+
12+
display: flex;
13+
flex-direction: column;
14+
gap: var(--wp-ui-card-gap);
15+
padding-block-start: var(--wp-ui-card-padding-block-start);
16+
padding-block-end: var(--wp-ui-card-padding-block-end);
17+
padding-inline: var(--wp-ui-card-padding-inline);
18+
border:
19+
var(--wpds-border-width-surface-xs) solid
20+
var(--wpds-color-stroke-surface-neutral-weak);
21+
border-radius: var(--wpds-border-radius-surface-lg);
22+
background-color: var(--wpds-color-bg-surface-neutral-strong);
23+
}
24+
25+
.header {
26+
display: flex;
27+
align-items: center;
28+
justify-content: space-between;
29+
gap: var(--wpds-dimension-gap-xs);
30+
font-family: var(--wpds-font-family-body);
31+
font-size: var(--wpds-font-size-lg);
32+
font-weight: var(--wpds-font-weight-medium);
33+
line-height: var(--wpds-font-line-height-sm);
34+
color: var(--wpds-color-fg-content-neutral);
35+
}
36+
37+
.summary {
38+
display: flex;
39+
align-items: center;
40+
justify-content: space-between;
41+
gap: var(--wpds-dimension-gap-xs);
42+
}
43+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen } from '@testing-library/react';
5+
6+
/**
7+
* WordPress dependencies
8+
*/
9+
import { createRef } from '@wordpress/element';
10+
11+
/**
12+
* Internal dependencies
13+
*/
14+
import { Card } from '../index';
15+
16+
describe( 'Card', () => {
17+
it( 'renders a div by default', () => {
18+
render( <Card>Content</Card> );
19+
20+
expect( screen.getByText( 'Content' ).tagName ).toBe( 'DIV' );
21+
} );
22+
23+
it( 'forwards ref', () => {
24+
const ref = createRef< HTMLDivElement >();
25+
26+
render( <Card ref={ ref }>Content</Card> );
27+
28+
expect( ref.current ).toBeInstanceOf( HTMLDivElement );
29+
} );
30+
31+
it( 'merges custom className with built-in classes', () => {
32+
const customClass = 'my-card';
33+
render( <Card className={ customClass }>Content</Card> );
34+
35+
expect( screen.getByText( 'Content' ) ).toHaveClass( customClass );
36+
} );
37+
38+
it( 'renders header and body sections', () => {
39+
render(
40+
<Card>
41+
<Card.Header>Card title</Card.Header>
42+
<Card.Body>Card content</Card.Body>
43+
</Card>
44+
);
45+
46+
expect( screen.getByText( 'Card title' ).tagName ).toBe( 'DIV' );
47+
expect( screen.getByText( 'Card content' ).tagName ).toBe( 'DIV' );
48+
} );
49+
} );

packages/ui/src/card/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { ComponentProps } from '../utils/types';
2+
3+
export interface CardProps extends ComponentProps< 'div' > {
4+
/**
5+
* The content to be rendered inside the component.
6+
*/
7+
children?: React.ReactNode;
8+
}
9+
10+
export interface CardSectionProps extends ComponentProps< 'div' > {
11+
/**
12+
* The content to be rendered inside the component.
13+
*/
14+
children?: React.ReactNode;
15+
}

packages/ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './badge';
22
export * from './box';
33
export * from './button';
4+
export * from './card';
45
export * from './form/primitives';
56
export * from './icon';
67
export * from './stack';

0 commit comments

Comments
 (0)