Skip to content

Commit

Permalink
feat: add card component for React
Browse files Browse the repository at this point in the history
  • Loading branch information
QuietNatu committed Apr 21, 2024
1 parent f989dd9 commit 203fe2d
Show file tree
Hide file tree
Showing 4 changed files with 236 additions and 0 deletions.
75 changes: 75 additions & 0 deletions packages/ui-react/src/lib/components/card/card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
NatuCard,
NatuCardBody,
NatuCardFooter,
NatuCardFooterProps,
NatuCardHeader,
NatuCardHeaderProps,
NatuCardProps,
} from './card';
import RocketIcon from '@natu/assets/svg/rocket.svg?react';

interface StoryProps extends NatuCardProps {
hideHeader?: boolean;
hideFooter?: boolean;
headerArgs?: Partial<NatuCardHeaderProps>;
footerArgs?: Partial<NatuCardFooterProps>;
}

const meta = {
title: 'Components/Card',
component: NatuCard,
tags: ['autodocs'],
render: (args) => {
const { headerArgs, footerArgs, hideHeader, hideFooter, ...cardArgs } = args;

return (
<NatuCard {...cardArgs}>
{!hideHeader && (
<NatuCardHeader {...headerArgs} icon={<RocketIcon className="natu-svg-icon" />}>
Example header
</NatuCardHeader>
)}

<NatuCardBody>Example actions</NatuCardBody>

{!hideFooter && <NatuCardFooter {...footerArgs}>Example secondary actions</NatuCardFooter>}
</NatuCard>
);
},
} satisfies Meta<StoryProps>;

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

export const Default: Story = {};

export const Dismissable: Story = {
args: { isDismissable: true },
};

export const Embedded: Story = {
args: { isEmbedded: true },
};

export const Small: Story = {
args: {
isDismissable: true,
headerArgs: { size: 'small' },
footerArgs: { size: 'small' },
},
};

export const WithFooterDivider: Story = {
args: {
footerArgs: { hasDivider: true },
},
};

export const NoHeaderOrFooter: Story = {
args: {
hideHeader: true,
hideFooter: true,
},
};
34 changes: 34 additions & 0 deletions packages/ui-react/src/lib/components/card/card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { composeStories } from '@storybook/react';
import * as stories from './card.stories';
import { axe, render, renderStory } from '../../test';
import { NatuCard, NatuCardBody, NatuCardFooter, NatuCardHeader } from './card';
import { screen } from '@testing-library/react';

const storyTestCases = Object.entries(composeStories(stories));

test.each(storyTestCases)('renders %s story', (_, Story) => {
const { container } = renderStory(<Story />);
expect(container).toBeInTheDocument();
});

test.each(storyTestCases)('%s has no accessibility violations', async (_, Story) => {
const { baseElement } = renderStory(<Story />);
expect(await axe(baseElement)).toHaveNoViolations();
});

test('trigger onDimiss callback when clicked', async () => {
const onDismissSpy = vi.fn();
const { userEvent } = render(
<NatuCard isDismissable={true} onDismiss={onDismissSpy}>
<NatuCardHeader>Example header</NatuCardHeader>

<NatuCardBody>Example actions</NatuCardBody>

<NatuCardFooter>Example secondary actions</NatuCardFooter>
</NatuCard>,
);

await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }));

expect(onDismissSpy).toHaveBeenCalledTimes(1);
});
102 changes: 102 additions & 0 deletions packages/ui-react/src/lib/components/card/card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import clsx from 'clsx';
import { ComponentPropsWithoutRef, ReactNode } from 'react';
import { NatuButton } from '../button/button';
import XIcon from '@natu/assets/svg/x.svg?react';
import { Slot } from '@radix-ui/react-slot';

export interface NatuCardProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
/**
* Whether the card is part of another component or not.
* Will hide borders, box-shadows, etc if true.
*/
isEmbedded?: boolean;

/** Whether to show the dismissable button or not. */
isDismissable?: boolean;

onDismiss?: () => void;
}

export interface NatuCardHeaderProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
icon?: ReactNode;
size?: 'small' | 'medium';
}

export interface NatuCardBodyProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
}

export interface NatuCardFooterProps extends ComponentPropsWithoutRef<'div'> {
children?: ReactNode;
hasDivider?: boolean;
size?: 'small' | 'medium';
}

export function NatuCard(props: NatuCardProps) {
const { className, isEmbedded, isDismissable, onDismiss, ...cardProps } = props;

return (
<div
{...cardProps}
className={clsx('natu-card', { 'natu-card--embedded': isEmbedded }, className)}
>
{props.children}

{isDismissable && (
<NatuButton
type="button"
className="natu-card__dismiss"
variant="ghost"
isIconButton={true}
onPress={onDismiss}
>
<span className="natu-visually-hidden">Dismiss</span>
<XIcon className="natu-svg-icon" aria-hidden="true" />
</NatuButton>
)}
</div>
);
}

export function NatuCardHeader(props: NatuCardHeaderProps) {
const { size = 'medium', className, icon, ...headerProps } = props;

return (
<div
{...headerProps}
className={clsx('natu-card__header', `natu-card__header--${size}`, className)}
>
<Slot className="natu-card__header-icon">{icon}</Slot>
<div>{props.children}</div>
</div>
);
}

export function NatuCardBody(props: NatuCardBodyProps) {
const { className, ...bodyProps } = props;
return (
<div {...bodyProps} className={clsx('natu-card__body', className)}>
{props.children}
</div>
);
}

export function NatuCardFooter(props: NatuCardFooterProps) {
const { hasDivider, size = 'medium', className, ...footerProps } = props;

return (
<div
{...footerProps}
className={clsx(
'natu-card__footer',
`natu-card__footer--${size}`,
{ 'natu-card__footer--with-divider': hasDivider },
className,
)}
>
{props.children}
</div>
);
}
25 changes: 25 additions & 0 deletions packages/ui-react/src/lib/components/card/card.vrt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { VrtScenario, createVrtStorybookScenarios } from '@natu/vrt';
import { test } from '@playwright/test';
import { defaultVrtVariants } from '../../vrt/variants';

const scenarios: VrtScenario[] = [
{ story: 'default' },
{ story: 'dismissable' },
{ story: 'embedded' },
{ story: 'small' },
{ story: 'with-footer-divider' },
{ story: 'no-header-or-footer' },
];

const testScenarios = createVrtStorybookScenarios({
scenarios,
page: 'components-card',
viewports: [{ name: 'custom', width: 500, height: 500 }],
variants: defaultVrtVariants,
});

testScenarios.forEach((scenario) => {
test(scenario.id, async ({ page }, testInfo) => {
await scenario.test(page, testInfo);
});
});

0 comments on commit 203fe2d

Please sign in to comment.