Skip to content
Merged
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
830 changes: 415 additions & 415 deletions package-lock.json

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@hookform/resolvers": "4.1.2",
"@hookform/resolvers": "4.1.3",
"@react-spring/web": "9.7.5",
"@tailwindcss/vite": "4.0.9",
"@tanstack/react-query": "5.66.9",
"@tanstack/react-query-devtools": "5.66.9",
"@tailwindcss/vite": "4.0.10",
"@tanstack/react-query": "5.67.1",
"@tanstack/react-query-devtools": "5.67.1",
"@tanstack/react-table": "8.21.2",
"axios": "1.8.1",
"class-variance-authority": "0.7.1",
Expand All @@ -51,45 +51,45 @@
"react-router-dom": "7.2.0",
"recharts": "2.15.1",
"tailwind-merge": "3.0.2",
"tailwindcss": "4.0.9",
"tailwindcss": "4.0.10",
"uuid": "11.1.0",
"yup": "1.6.1"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.5",
"@eslint/js": "9.21.0",
"@storybook/addon-essentials": "8.6.1",
"@storybook/addon-interactions": "8.6.1",
"@storybook/addon-onboarding": "8.6.1",
"@storybook/addon-themes": "8.6.1",
"@storybook/blocks": "8.6.1",
"@storybook/react": "8.6.1",
"@storybook/react-vite": "8.6.1",
"@storybook/test": "8.6.1",
"@storybook/addon-essentials": "8.6.4",
"@storybook/addon-interactions": "8.6.4",
"@storybook/addon-onboarding": "8.6.4",
"@storybook/addon-themes": "8.6.4",
"@storybook/blocks": "8.6.4",
"@storybook/react": "8.6.4",
"@storybook/react-vite": "8.6.4",
"@storybook/test": "8.6.4",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
"@testing-library/user-event": "14.6.1",
"@types/eslint__js": "8.42.3",
"@types/lodash": "4.17.15",
"@types/lodash": "4.17.16",
"@types/qs": "6.9.18",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/uuid": "10.0.0",
"@vitejs/plugin-react": "4.3.4",
"@vitest/coverage-v8": "3.0.7",
"eslint": "9.21.0",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.19",
"eslint-plugin-storybook": "0.11.3",
"eslint-plugin-storybook": "0.11.4",
"globals": "16.0.0",
"jsdom": "26.0.0",
"msw": "2.7.3",
"prettier": "3.5.2",
"prettier": "3.5.3",
"prettier-plugin-tailwindcss": "0.6.11",
"rimraf": "6.0.1",
"storybook": "8.6.1",
"storybook": "8.6.4",
"typescript": "5.7.3",
"typescript-eslint": "8.25.0",
"typescript-eslint": "8.26.0",
"vite": "6.2.0",
"vitest": "3.0.7"
}
Expand Down
143 changes: 137 additions & 6 deletions src/common/components/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,157 @@
import { PropsWithChildren } from 'react';
import { ImgHTMLAttributes, PropsWithChildren } from 'react';

import { BaseComponentProps } from 'common/utils/types';
import { cn } from 'common/utils/css';
import Divider, { DividerProps } from '../Divider/Divider';

/**
* Properties for the `Card` React component.
* @see {@link PropsWithChildren}
* @see {@link BaseComponentProps}
*/
export interface CardProps extends BaseComponentProps, PropsWithChildren {}

/**
* The `Card` component renders a container for grouped, related content.
* @param {CardProps} props - Component properties, `CardProps`.
* @returns {JSX.Element} JSX
*
* **Example:**
* ```
<Card className="w-100" testId="example-card">
<Card.Image src="https://placehold.co/400x200" alt="placeholder" />
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Subtitle>with a subtitle</Card.Subtitle>
</Card.Header>
<Card.Body>
Nul nostrud non dui elit nul proin. Consectetur magna mi justo dui.
</Card.Body>
<Card.Separator />
<Card.Footer className="text-right text-sm">Read more...</Card.Footer>
</Card>
* ```
*/
const Card = ({ children, className, testId = 'card' }: CardProps): JSX.Element => {
return (
<div className={cn('rounded-lg bg-neutral-500/10 p-4', className)} data-testid={testId}>
<div
className={cn(
'flex flex-col gap-4 overflow-hidden rounded-md bg-neutral-500/10 *:not-[img]:px-4 *:first:not-[img]:pt-4 *:last:not-[img]:pb-4',
className,
)}
data-testid={testId}
>
{children}
</div>
);
};

/**
* The `Header` is a block within a card. It often contains a Title, Subtitle,
* or any components located at the top of the card.
*/
const Header = ({
children,
className,
testId = 'card-header',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<div className={cn(className)} data-testid={testId}>
{children}
</div>
);
};
Card.Header = Header;

/**
* The `Body` is a block which encloses the main content of the card.
*/
const Body = ({
children,
className,
testId = 'card-body',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<div className={cn(className)} data-testid={testId}>
{children}
</div>
);
};
Card.Body = Body;

/**
* The `Footer` is a block within a card. It may contain any components located
* at the bottom of the card.
*/
const Footer = ({
children,
className,
testId = 'card-footer',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<div className={cn(className)} data-testid={testId}>
{children}
</div>
);
};
Card.Footer = Footer;

/**
* The `Image` is an image which is styled for use within a Card. The image will
* respect the boundaries of the card when used as the first or last child of `Card`.
*/
const Image = ({
className,
testId = 'card-image',
...props
}: BaseComponentProps & ImgHTMLAttributes<HTMLImageElement>): JSX.Element => {
return <img className={cn(className)} data-testid={testId} {...props} />;
};
Card.Image = Image;

/**
* A `Title` for a `Card`. Typically used within the card `Header`, but not
* required.
*/
const Title = ({
children,
className,
testId = 'card-title',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<h5 className={cn('line-clamp-2 text-2xl', className)} data-testid={testId}>
{children}
</h5>
);
};
Card.Title = Title;

/**
* A `Subtitle` for a `Card`. Typically used within the card `Header`, but not
* required.
*/
const Subtitle = ({
children,
className,
testId = 'card-subtitle',
}: BaseComponentProps & PropsWithChildren): JSX.Element => {
return (
<div
className={cn(
'line-clamp-2 leading-tight text-neutral-500 font-stretch-condensed',
className,
)}
data-testid={testId}
>
{children}
</div>
);
};
Card.Subtitle = Subtitle;

/**
* The `Separator` component renders a horizontal divider.
* This is useful to organize and separate content.
*/
const Separator = ({ className, testId = 'card-separator' }: DividerProps): JSX.Element => {
return <Divider className={cn('my-1', className)} testId={testId} />;
};
Card.Separator = Separator;

export default Card;
18 changes: 9 additions & 9 deletions src/common/components/Card/MessageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ const MessageCard = ({
testId = 'card-message',
title,
}: MessageCardProps): JSX.Element => {
const hasHeader = !!iconProps || !!title;

return (
<Card className={cn('w-80', className)} testId={testId}>
<div className="flex flex-col items-center gap-2 text-center">
{iconProps && <FAIcon {...iconProps} testId={`${testId}-icon`} />}
{title && (
<div className="font-bold" data-testid={`${testId}-title`}>
{title}
</div>
)}
<div data-testid={`${testId}-message`}>{message}</div>
</div>
{hasHeader && (
<Card.Header className="flex items-center justify-center gap-2">
{iconProps && <FAIcon {...iconProps} testId={`${testId}-icon`} />}
{title && <Card.Title testId={`${testId}-title`}>{title}</Card.Title>}
</Card.Header>
)}
<Card.Body testId={`${testId}-message`}>{message}</Card.Body>
</Card>
);
};
Expand Down
106 changes: 83 additions & 23 deletions src/common/components/Card/__stories__/Card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,103 @@ const meta = {
},
tags: ['autodocs'],
argTypes: {
children: {
description: 'The content.',
control: { type: 'select' },
options: ['TextContent', 'ComplexContent'],
mapping: {
TextContent: 'This is a card with plain text content.',
ComplexContent: (
<div className="flex flex-col gap-4">
<div className="text-2xl font-bold">Card Title</div>
<div>This is a card with more complex content.</div>
<div>You may pass any desired content as children to the Card component.</div>
</div>
),
},
},
children: { description: 'The content.' },
className: { description: 'Additional CSS classes.' },
testId: { description: 'The test identifier.' },
},
args: {
children: 'ComplexContent',
},
} satisfies Meta<typeof Card>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Text: Story = {
export const CardComposition: Story = {
render: (args) => (
<Card {...args}>
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Subtitle>with a subtitle</Card.Subtitle>
</Card.Header>
<Card.Body>
Nul nostrud non dui elit nul proin. Consectetur magna mi justo dui. Aliquip proin incididunt
ero tempor occaecat consequat ea. Neque esse minim occaecat massa. Reprehenderit consequat
reprehenderit ipsum dui excepteur anim. Irure labore at at urna veniam enim consectetur ea.
Et urna aliquip dapibus magna eiusmod commodo officia.
</Card.Body>
<Card.Separator />
<Card.Footer className="text-right text-sm">Read more...</Card.Footer>
</Card>
),
args: {
children: 'TextContent',
className: 'w-100',
},
};

export const Complex: Story = {};
export const WithTopImage: Story = {
render: (args) => (
<Card {...args}>
<Card.Image src="https://placehold.co/400x200" alt="placeholder" />
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Subtitle>with a subtitle</Card.Subtitle>
</Card.Header>
<Card.Body>
Nul nostrud non dui elit nul proin. Consectetur magna mi justo dui. Aliquip proin incididunt
ero tempor occaecat consequat ea. Neque esse minim occaecat massa. Reprehenderit consequat
reprehenderit ipsum dui excepteur anim. Irure labore at at urna veniam enim consectetur ea.
Et urna aliquip dapibus magna eiusmod commodo officia.
</Card.Body>
<Card.Separator />
<Card.Footer className="text-right text-sm">Read more...</Card.Footer>
</Card>
),
args: {
className: 'w-100',
},
};

export const WithBottomImage: Story = {
render: (args) => (
<Card {...args}>
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Subtitle>with a subtitle</Card.Subtitle>
</Card.Header>
<Card.Body>
Nul nostrud non dui elit nul proin. Consectetur magna mi justo dui. Aliquip proin incididunt
ero tempor occaecat consequat ea. Neque esse minim occaecat massa. Reprehenderit consequat
reprehenderit ipsum dui excepteur anim. Irure labore at at urna veniam enim consectetur ea.
Et urna aliquip dapibus magna eiusmod commodo officia.
</Card.Body>
<Card.Separator />
<Card.Footer className="text-right text-sm">Read more...</Card.Footer>
<Card.Image src="https://placehold.co/400x200" alt="placeholder" />
</Card>
),
args: {
className: 'w-100',
},
};

export const Styled: Story = {
export const WithMiddleImage: Story = {
render: (args) => (
<Card {...args}>
<Card.Header>
<Card.Title>Card Title</Card.Title>
<Card.Subtitle>with a subtitle</Card.Subtitle>
</Card.Header>
<Card.Image src="https://placehold.co/400x200" alt="placeholder" />
<Card.Body>
Nul nostrud non dui elit nul proin. Consectetur magna mi justo dui. Aliquip proin incididunt
ero tempor occaecat consequat ea. Neque esse minim occaecat massa. Reprehenderit consequat
reprehenderit ipsum dui excepteur anim. Irure labore at at urna veniam enim consectetur ea.
Et urna aliquip dapibus magna eiusmod commodo officia.
</Card.Body>
<Card.Separator />
<Card.Footer className="text-right text-sm">Read more...</Card.Footer>
</Card>
),
args: {
className: 'bg-blue-600/80 text-white',
className: 'w-100',
},
};
Loading