From 203fe2d98057d15b6d078ca025c23668f78b4275 Mon Sep 17 00:00:00 2001 From: QuietNatu Date: Sun, 21 Apr 2024 17:27:52 +0100 Subject: [PATCH] feat: add card component for React --- .../src/lib/components/card/card.stories.tsx | 75 +++++++++++++ .../src/lib/components/card/card.test.tsx | 34 ++++++ .../ui-react/src/lib/components/card/card.tsx | 102 ++++++++++++++++++ .../src/lib/components/card/card.vrt.ts | 25 +++++ 4 files changed, 236 insertions(+) create mode 100644 packages/ui-react/src/lib/components/card/card.stories.tsx create mode 100644 packages/ui-react/src/lib/components/card/card.test.tsx create mode 100644 packages/ui-react/src/lib/components/card/card.tsx create mode 100644 packages/ui-react/src/lib/components/card/card.vrt.ts diff --git a/packages/ui-react/src/lib/components/card/card.stories.tsx b/packages/ui-react/src/lib/components/card/card.stories.tsx new file mode 100644 index 00000000..cdf300f1 --- /dev/null +++ b/packages/ui-react/src/lib/components/card/card.stories.tsx @@ -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; + footerArgs?: Partial; +} + +const meta = { + title: 'Components/Card', + component: NatuCard, + tags: ['autodocs'], + render: (args) => { + const { headerArgs, footerArgs, hideHeader, hideFooter, ...cardArgs } = args; + + return ( + + {!hideHeader && ( + }> + Example header + + )} + + Example actions + + {!hideFooter && Example secondary actions} + + ); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/ui-react/src/lib/components/card/card.test.tsx b/packages/ui-react/src/lib/components/card/card.test.tsx new file mode 100644 index 00000000..064549f8 --- /dev/null +++ b/packages/ui-react/src/lib/components/card/card.test.tsx @@ -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(); + expect(container).toBeInTheDocument(); +}); + +test.each(storyTestCases)('%s has no accessibility violations', async (_, Story) => { + const { baseElement } = renderStory(); + expect(await axe(baseElement)).toHaveNoViolations(); +}); + +test('trigger onDimiss callback when clicked', async () => { + const onDismissSpy = vi.fn(); + const { userEvent } = render( + + Example header + + Example actions + + Example secondary actions + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(onDismissSpy).toHaveBeenCalledTimes(1); +}); diff --git a/packages/ui-react/src/lib/components/card/card.tsx b/packages/ui-react/src/lib/components/card/card.tsx new file mode 100644 index 00000000..dd30428d --- /dev/null +++ b/packages/ui-react/src/lib/components/card/card.tsx @@ -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 ( +
+ {props.children} + + {isDismissable && ( + + Dismiss + + )} +
+ ); +} + +export function NatuCardHeader(props: NatuCardHeaderProps) { + const { size = 'medium', className, icon, ...headerProps } = props; + + return ( +
+ {icon} +
{props.children}
+
+ ); +} + +export function NatuCardBody(props: NatuCardBodyProps) { + const { className, ...bodyProps } = props; + return ( +
+ {props.children} +
+ ); +} + +export function NatuCardFooter(props: NatuCardFooterProps) { + const { hasDivider, size = 'medium', className, ...footerProps } = props; + + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/ui-react/src/lib/components/card/card.vrt.ts b/packages/ui-react/src/lib/components/card/card.vrt.ts new file mode 100644 index 00000000..a650e109 --- /dev/null +++ b/packages/ui-react/src/lib/components/card/card.vrt.ts @@ -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); + }); +});