diff --git a/react/package.json b/react/package.json index f89666a..c5b8d0f 100644 --- a/react/package.json +++ b/react/package.json @@ -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", @@ -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" } } diff --git a/react/src/components/Card.stories.tsx b/react/src/components/Card.stories.tsx new file mode 100644 index 0000000..0b79b71 --- /dev/null +++ b/react/src/components/Card.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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:
This card renders as a <section> element.
, + }, +}; diff --git a/react/src/components/Card.test.tsx b/react/src/components/Card.test.tsx new file mode 100644 index 0000000..ed7dc1c --- /dev/null +++ b/react/src/components/Card.test.tsx @@ -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(Hello World); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + + it('renders as a div by default', () => { + render(Content); + expect(screen.getByText('Content').tagName).toBe('DIV'); + }); + + it('applies the bc-forge-card class', () => { + render(Content); + expect(screen.getByText('Content')).toHaveClass('bc-forge-card'); + }); + + it('merges custom className', () => { + render(Content); + const card = screen.getByText('Content'); + expect(card).toHaveClass('bc-forge-card', 'my-card'); + }); + + it('merges custom style', () => { + render(Content); + expect(screen.getByText('Content').style.marginTop).toBe('20px'); + }); + + it.each(['elevated', 'outlined', 'filled'] as const)( + 'renders %s variant with border-radius', + (variant) => { + render(Content); + expect(screen.getByText('Content').style.borderRadius).toBe('8px'); + }, + ); + + it.each(['sm', 'md', 'lg'] as const)('renders %s size with padding', (size) => { + render(Content); + expect(screen.getByText('Content').style.padding).toBeTruthy(); + }); + + it('forwards ref', () => { + const ref = React.createRef(); + render(Content); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current?.textContent).toBe('Content'); + }); + + it('passes through data attributes', () => { + render(Content); + expect(screen.getByTestId('test-card')).toBeInTheDocument(); + }); + + it('passes through aria attributes', () => { + render(Content); + expect(screen.getByLabelText('Card label')).toBeInTheDocument(); + }); + + it('renders the id attribute', () => { + render(Content); + expect(screen.getByText('Content')).toHaveAttribute('id', 'my-card'); + }); + + describe('asChild polymorphic behavior', () => { + it('renders as the child element type', () => { + render( + +
Content
+
, + ); + const card = screen.getByText('Content'); + expect(card.tagName).toBe('ARTICLE'); + expect(card).toHaveClass('bc-forge-card'); + }); + + it('merges parent and child classNames', () => { + render( + +
Content
+
, + ); + expect(screen.getByText('Content')).toHaveClass( + 'bc-forge-card', + 'parent-class', + 'child-class', + ); + }); + + it('merges parent and child styles (child wins on conflict)', () => { + render( + +
Content
+
, + ); + 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(Content); + fireEvent.click(screen.getByText('Content')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('calls onClick on Enter keydown', () => { + const handleClick = vi.fn(); + render(Content); + fireEvent.keyDown(screen.getByText('Content'), { key: 'Enter' }); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('calls onClick on Space keydown', () => { + const handleClick = vi.fn(); + render(Content); + fireEvent.keyDown(screen.getByText('Content'), { key: ' ' }); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('does not call onClick on other keys', () => { + const handleClick = vi.fn(); + render(Content); + 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( + + Content + , + ); + fireEvent.keyDown(screen.getByText('Content'), { key: 'Enter' }); + expect(handleClick).toHaveBeenCalledTimes(1); + expect(handleKeyDown).toHaveBeenCalledTimes(1); + }); + + it('sets role="button" by default', () => { + render( {}}>Content); + expect(screen.getByText('Content')).toHaveAttribute('role', 'button'); + }); + + it('sets tabIndex={0} by default', () => { + render( {}}>Content); + expect(screen.getByText('Content')).toHaveAttribute('tabindex', '0'); + }); + + it('respects a custom role override', () => { + render( + {}} role="link"> + Content + , + ); + expect(screen.getByText('Content')).toHaveAttribute('role', 'link'); + }); + + it('respects a custom tabIndex override', () => { + render( + {}} tabIndex={3}> + Content + , + ); + expect(screen.getByText('Content')).toHaveAttribute('tabindex', '3'); + }); + }); + + describe('non-interactive (no onClick)', () => { + it('does not set role', () => { + render(Content); + expect(screen.getByText('Content')).not.toHaveAttribute('role'); + }); + + it('does not set tabIndex', () => { + render(Content); + expect(screen.getByText('Content')).not.toHaveAttribute('tabindex'); + }); + }); +}); diff --git a/react/src/components/Card.tsx b/react/src/components/Card.tsx new file mode 100644 index 0000000..62a7202 --- /dev/null +++ b/react/src/components/Card.tsx @@ -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 { + /** 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 = { + 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 = { + sm: { padding: '16px' }, + md: { padding: '24px' }, + lg: { padding: '32px' }, +}; + +export const Card = forwardRef( + ( + { + variant = 'elevated', + size = 'md', + asChild = false, + className, + style, + onClick, + onKeyDown, + tabIndex, + role, + children, + ...props + }, + ref, + ) => { + const isInteractive = !!onClick; + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event); + if (onClick && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onClick(event as unknown as React.MouseEvent); + } + }, + [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 {children}; + } + + return
{children}
; + }, +); + +Card.displayName = 'Card'; diff --git a/react/src/components/Slot.tsx b/react/src/components/Slot.tsx new file mode 100644 index 0000000..836798b --- /dev/null +++ b/react/src/components/Slot.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +interface SlotProps extends React.HTMLAttributes { + children?: React.ReactNode; +} + +export const Slot = React.forwardRef( + ({ children, ...props }, ref) => { + if (React.isValidElement(children)) { + const childProps = children.props as Record; + const mergedStyle = { + ...(props.style as React.CSSProperties || {}), + ...(childProps.style as React.CSSProperties || {}), + }; + + const mergedClassName = [props.className, childProps.className] + .filter(Boolean) + .join(' '); + + return React.cloneElement(children, { + ...props, + style: mergedStyle, + className: mergedClassName || undefined, + ref: (node: HTMLElement) => { + const childRef = (children as unknown as { ref?: React.Ref }).ref; + if (typeof ref === 'function') ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + if (typeof childRef === 'function') childRef(node); + else if (childRef) (childRef as React.MutableRefObject).current = node; + }, + } as React.HTMLAttributes); + } + + return <>{children}; + }, +); + +Slot.displayName = 'Slot'; diff --git a/react/src/test/setup.ts b/react/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/react/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/react/vitest.config.ts b/react/vitest.config.ts new file mode 100644 index 0000000..5fc850c --- /dev/null +++ b/react/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +});