Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"react-dom": "^18.0.0 || ^19.0.0"
},
"dependencies": {
"@bc-forge/sdk": "file:../sdk"
"@bc-forge/sdk": "file:../sdk",
"clsx": "^2.1.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
Expand All @@ -34,6 +35,7 @@
"react-dom": "^18.0.0",
"ts-jest": "^29.1.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
"typescript": "^5.0.0",
"vitest": "^2.1.8"
}
}
80 changes: 80 additions & 0 deletions react/src/components/Card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { Card } from './Card';

const meta = {
title: 'Components/Card',
component: Card,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['elevated', 'outlined', 'filled'],
description: 'Visual style of the card',
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
description: 'Size preset controlling padding',
},
asChild: {
control: 'boolean',
description: 'Merge props onto child element instead of wrapping',
},
},
} satisfies Meta<typeof Card>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Elevated: Story = {
args: {
variant: 'elevated',
size: 'md',
children: 'This is an elevated card with a subtle shadow.',
},
};

export const Outlined: Story = {
args: {
variant: 'outlined',
size: 'md',
children: 'This is an outlined card with a border.',
},
};

export const Filled: Story = {
args: {
variant: 'filled',
size: 'md',
children: 'This is a filled card with a light background.',
},
};

export const Small: Story = {
args: {
size: 'sm',
children: 'Small card — 16px padding.',
},
};

export const Large: Story = {
args: {
size: 'lg',
children: 'Large card — 32px padding.',
},
};

export const Interactive: Story = {
args: {
onClick: () => alert('Card clicked'),
children: 'Click me — I support keyboard Enter/Space too.',
},
};

export const AsSection: Story = {
args: {
asChild: true,
children: <section>This card renders as a &lt;section&gt; element.</section>,
},
};
187 changes: 187 additions & 0 deletions react/src/components/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Card } from './Card';

describe('Card', () => {
it('renders children', () => {
render(<Card>Hello World</Card>);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});

it('renders as a div by default', () => {
render(<Card>Content</Card>);
expect(screen.getByText('Content').tagName).toBe('DIV');
});

it('applies the bc-forge-card class', () => {
render(<Card>Content</Card>);
expect(screen.getByText('Content')).toHaveClass('bc-forge-card');
});

it('merges custom className', () => {
render(<Card className="my-card">Content</Card>);
const card = screen.getByText('Content');
expect(card).toHaveClass('bc-forge-card', 'my-card');
});

it('merges custom style', () => {
render(<Card style={{ marginTop: '20px' }}>Content</Card>);
expect(screen.getByText('Content').style.marginTop).toBe('20px');
});

it.each(['elevated', 'outlined', 'filled'] as const)(
'renders %s variant with border-radius',
(variant) => {
render(<Card variant={variant}>Content</Card>);
expect(screen.getByText('Content').style.borderRadius).toBe('8px');
},
);

it.each(['sm', 'md', 'lg'] as const)('renders %s size with padding', (size) => {
render(<Card size={size}>Content</Card>);
expect(screen.getByText('Content').style.padding).toBeTruthy();
});

it('forwards ref', () => {
const ref = React.createRef<HTMLDivElement>();
render(<Card ref={ref}>Content</Card>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
expect(ref.current?.textContent).toBe('Content');
});

it('passes through data attributes', () => {
render(<Card data-testid="test-card">Content</Card>);
expect(screen.getByTestId('test-card')).toBeInTheDocument();
});

it('passes through aria attributes', () => {
render(<Card aria-label="Card label">Content</Card>);
expect(screen.getByLabelText('Card label')).toBeInTheDocument();
});

it('renders the id attribute', () => {
render(<Card id="my-card">Content</Card>);
expect(screen.getByText('Content')).toHaveAttribute('id', 'my-card');
});

describe('asChild polymorphic behavior', () => {
it('renders as the child element type', () => {
render(
<Card asChild>
<article>Content</article>
</Card>,
);
const card = screen.getByText('Content');
expect(card.tagName).toBe('ARTICLE');
expect(card).toHaveClass('bc-forge-card');
});

it('merges parent and child classNames', () => {
render(
<Card asChild className="parent-class">
<section className="child-class">Content</section>
</Card>,
);
expect(screen.getByText('Content')).toHaveClass(
'bc-forge-card',
'parent-class',
'child-class',
);
});

it('merges parent and child styles (child wins on conflict)', () => {
render(
<Card asChild style={{ margin: '10px', padding: '10px' }}>
<section style={{ padding: '5px' }}>Content</section>
</Card>,
);
const card = screen.getByText('Content');
expect(card.style.margin).toBe('10px');
expect(card.style.padding).toBe('5px');
});
});

describe('interactive (onClick provided)', () => {
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Content</Card>);
fireEvent.click(screen.getByText('Content'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('calls onClick on Enter keydown', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Content</Card>);
fireEvent.keyDown(screen.getByText('Content'), { key: 'Enter' });
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('calls onClick on Space keydown', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Content</Card>);
fireEvent.keyDown(screen.getByText('Content'), { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('does not call onClick on other keys', () => {
const handleClick = vi.fn();
render(<Card onClick={handleClick}>Content</Card>);
fireEvent.keyDown(screen.getByText('Content'), { key: 'Escape' });
expect(handleClick).not.toHaveBeenCalled();
});

it('calls onKeyDown in addition to onClick', () => {
const handleClick = vi.fn();
const handleKeyDown = vi.fn();
render(
<Card onClick={handleClick} onKeyDown={handleKeyDown}>
Content
</Card>,
);
fireEvent.keyDown(screen.getByText('Content'), { key: 'Enter' });
expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleKeyDown).toHaveBeenCalledTimes(1);
});

it('sets role="button" by default', () => {
render(<Card onClick={() => {}}>Content</Card>);
expect(screen.getByText('Content')).toHaveAttribute('role', 'button');
});

it('sets tabIndex={0} by default', () => {
render(<Card onClick={() => {}}>Content</Card>);
expect(screen.getByText('Content')).toHaveAttribute('tabindex', '0');
});

it('respects a custom role override', () => {
render(
<Card onClick={() => {}} role="link">
Content
</Card>,
);
expect(screen.getByText('Content')).toHaveAttribute('role', 'link');
});

it('respects a custom tabIndex override', () => {
render(
<Card onClick={() => {}} tabIndex={3}>
Content
</Card>,
);
expect(screen.getByText('Content')).toHaveAttribute('tabindex', '3');
});
});

describe('non-interactive (no onClick)', () => {
it('does not set role', () => {
render(<Card>Content</Card>);
expect(screen.getByText('Content')).not.toHaveAttribute('role');
});

it('does not set tabIndex', () => {
render(<Card>Content</Card>);
expect(screen.getByText('Content')).not.toHaveAttribute('tabindex');
});
});
});
98 changes: 98 additions & 0 deletions react/src/components/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React, { forwardRef, useCallback } from 'react';
import { clsx } from 'clsx';
import { Slot } from './Slot';

export type CardVariant = 'elevated' | 'outlined' | 'filled';
export type CardSize = 'sm' | 'md' | 'lg';

export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Visual style variant of the card. @default 'elevated' */
variant?: CardVariant;
/** Size preset controlling padding. @default 'md' */
size?: CardSize;
/** When true, the card merges its props onto the single child element instead of rendering a wrapper div. */
asChild?: boolean;
}

const variantStyles: Record<CardVariant, React.CSSProperties> = {
elevated: {
backgroundColor: '#ffffff',
borderRadius: '8px',
boxShadow:
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
},
outlined: {
backgroundColor: 'transparent',
borderRadius: '8px',
border: '1px solid #e2e8f0',
},
filled: {
backgroundColor: '#f8fafc',
borderRadius: '8px',
},
};

const sizeStyles: Record<CardSize, React.CSSProperties> = {
sm: { padding: '16px' },
md: { padding: '24px' },
lg: { padding: '32px' },
};

export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'elevated',
size = 'md',
asChild = false,
className,
style,
onClick,
onKeyDown,
tabIndex,
role,
children,
...props
},
ref,
) => {
const isInteractive = !!onClick;

const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onClick(event as unknown as React.MouseEvent<HTMLDivElement>);
}
},
[onClick, onKeyDown],
);

const mergedStyle = {
...variantStyles[variant],
...sizeStyles[size],
...style,
};

const mergedClassName = clsx('bc-forge-card', className);

const commonProps = {
ref,
className: mergedClassName,
style: mergedStyle,
onClick,
onKeyDown: isInteractive ? handleKeyDown : onKeyDown,
tabIndex: isInteractive ? (tabIndex ?? 0) : tabIndex,
role: isInteractive ? (role ?? 'button') : role,
...props,
};

if (asChild) {
return <Slot {...commonProps}>{children}</Slot>;
}

return <div {...commonProps}>{children}</div>;
},
);

Card.displayName = 'Card';
Loading
Loading