Skip to content

Commit

Permalink
Implement Card component (#2799)
Browse files Browse the repository at this point in the history
  • Loading branch information
spalmurray-codecov authored Apr 29, 2024
1 parent a6f2b4b commit a662e79
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .storybook/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { addons } from '@storybook/manager-api'
import { themes } from '@storybook/theming'

addons.setConfig({
theme: themes.dark,
theme: themes.light,
})
2 changes: 1 addition & 1 deletion .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const decorators = [
const preview: Preview = {
parameters: {
docs: {
theme: themes.dark,
theme: themes.light,
},
},
}
Expand Down
75 changes: 75 additions & 0 deletions src/ui/Card/Card.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { render, screen } from '@testing-library/react'

import { Card } from './Card'

describe('Card', () => {
it('renders arbitrary child', async () => {
render(<Card>hello</Card>)
const hello = await screen.findByText('hello')
expect(hello).toBeInTheDocument()
})

describe('Card.Header', () => {
it('renders', async () => {
render(
<Card>
<Card.Header>Header</Card.Header>
</Card>
)
const header = await screen.findByText('Header')
expect(header).toBeInTheDocument()
})
})

describe('Card.Title', () => {
it('renders', async () => {
render(
<Card>
<Card.Header>
<Card.Title>Title</Card.Title>
</Card.Header>
</Card>
)
const title = await screen.findByText('Title')
expect(title).toBeInTheDocument()
})
})

describe('Card.Description', () => {
it('renders', async () => {
render(
<Card>
<Card.Header>
<Card.Description>Description</Card.Description>
</Card.Header>
</Card>
)
const description = await screen.findByText('Description')
expect(description).toBeInTheDocument()
})
})

describe('Card.Content', () => {
it('renders', async () => {
render(
<Card>
<Card.Content>Content</Card.Content>
</Card>
)
const content = await screen.findByText('Content')
expect(content).toBeInTheDocument()
})
})

describe('Card.Footer', () => {
it('renders', async () => {
render(
<Card>
<Card.Footer>Footer</Card.Footer>
</Card>
)
const footer = await screen.findByText('Footer')
expect(footer).toBeInTheDocument()
})
})
})
101 changes: 101 additions & 0 deletions src/ui/Card/Card.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Meta, StoryObj } from '@storybook/react'

import { Card } from './Card'

// Hack to allow us to specify args that do not exist on Card
type CardStory = React.ComponentProps<typeof Card> & {
titleSize?: 'lg' | 'base'
}

const meta: Meta<CardStory> = {
title: 'Components/Card',
component: Card,
argTypes: {
titleSize: {
description: 'Controls the font size of Card.Title',
control: 'radio',
options: ['lg', 'base'],
},
},
} as Meta
export default meta

type Story = StoryObj<CardStory>

export const Default: Story = {
render: () => (
<Card>
<Card.Content>
Here is the Card component, you are free to render anything you would
like here.
</Card.Content>
</Card>
),
}

export const CardWithHeader: Story = {
args: {
titleSize: 'lg',
},
render: (args) => (
<Card>
<Card.Header>
<Card.Title size={args.titleSize}>
A header can have a title.
</Card.Title>
<Card.Description>And it can have a description.</Card.Description>
</Card.Header>
<Card.Content>
The header will place a border between it and the main Card content.
</Card.Content>
</Card>
),
}

export const CardWithFooter: Story = {
render: () => (
<Card>
<Card.Header>
<Card.Title>Card With Footer</Card.Title>
</Card.Header>
<Card.Content>
A footer will similarly have a border between it and the main Card
content.
</Card.Content>
<Card.Footer>Footer!</Card.Footer>
</Card>
),
}

export const CardWithCustomStyles: Story = {
render: () => (
<Card className="border-4 border-ds-pink">
<Card.Header className="border-b-4 border-inherit">
<Card.Title className="text-ds-blue">Custom Styles</Card.Title>
</Card.Header>
<Card.Content className="flex gap-5">
<Card className="flex-1">
<Card.Content className="text-center">
Using the <code className="bg-ds-gray-secondary">className</code>{' '}
prop,
</Card.Content>
</Card>
<Card className="flex-1">
<Card.Content className="text-center">
you can set custom styles!
</Card.Content>
</Card>
</Card.Content>
<Card.Footer className="border-t-4 border-inherit text-center">
But if you&apos;re going to do that, consider adding a{' '}
<a
href="https://cva.style/docs/getting-started/variants"
className="text-ds-blue hover:underline"
>
CVA variant
</a>{' '}
for the component instead.
</Card.Footer>
</Card>
),
}
94 changes: 94 additions & 0 deletions src/ui/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { cva, VariantProps } from 'cva'
import React from 'react'

const card = cva(['border border-ds-gray-secondary'])
interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof card> {}

const CardRoot = React.forwardRef<HTMLDivElement, CardProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={card({ className })} {...props} />
)
)
CardRoot.displayName = 'Card'

const header = cva(['border-b', 'border-ds-gray-secondary', 'p-5'])
interface HeaderProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof header> {}

const Header = React.forwardRef<HTMLDivElement, HeaderProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={header({ className })} {...props} />
)
)
Header.displayName = 'Card.Header'

const title = cva(['font-semibold'], {
variants: {
size: {
base: ['text-base'],
lg: ['text-lg'],
},
},
defaultVariants: {
size: 'lg',
},
})
interface TitleProps
extends React.HTMLAttributes<HTMLHeadingElement>,
VariantProps<typeof title> {}

const Title = React.forwardRef<HTMLParagraphElement, TitleProps>(
({ className, size, children, ...props }, ref) => (
<h3 ref={ref} className={title({ className, size })} {...props}>
{children}
</h3>
)
)
Title.displayName = 'Card.Title'

const description = cva()
interface DescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement>,
VariantProps<typeof description> {}

const Description = React.forwardRef<HTMLParagraphElement, DescriptionProps>(
({ className, ...props }, ref) => (
<p ref={ref} className={description({ className })} {...props} />
)
)
Description.displayName = 'Card.Description'

const content = cva(['m-5'])
interface ContentProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof content> {}

const Content = React.forwardRef<HTMLDivElement, ContentProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={content({ className })} {...props} />
)
)
Content.displayName = 'Card.Content'

const footer = cva(['border-t', 'border-ds-gray-secondary', 'p-5'])
interface FooterProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof footer> {}

const Footer = React.forwardRef<HTMLDivElement, FooterProps>(
({ className, ...props }, ref) => (
<div ref={ref} className={footer({ className })} {...props} />
)
)
Footer.displayName = 'Card.Footer'

export const Card = Object.assign(CardRoot, {
Header,
Title,
Description,
Content,
Footer,
})
1 change: 1 addition & 0 deletions src/ui/Card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Card } from './Card'

0 comments on commit a662e79

Please sign in to comment.