diff --git a/src/components/card/Card.stories.tsx b/src/components/card/Card.stories.tsx new file mode 100644 index 0000000000..5acaa38236 --- /dev/null +++ b/src/components/card/Card.stories.tsx @@ -0,0 +1,523 @@ +import React from 'react' + +import { CardGroup } from './CardGroup/CardGroup' +import { Card } from './Card/Card' +import { CardHeader } from './CardHeader/CardHeader' +import { CardFooter } from './CardFooter/CardFooter' +import { CardBody } from './CardBody/CardBody' +import { CardMedia } from './CardMedia/CardMedia' +import { Button } from '../Button/Button' + +export default { + title: 'Card', + parameters: { + info: ` +USWDS 2.0 Card components + +Source: https://designsystem.digital.gov/components/card/ +`, + }, +} + +const card = ( + + +

Card

+
+ +

This is a standard card with a button in the footer.

+
+ + + +
+) + +const cardWithMedia = ( + + +

Card With Media

+
+ + A placeholder + + +

This is a standard card with media and a button in the footer.

+
+ + + +
+) + +const mediaWithSetAspectRatio = ( + + +

Media with Set Aspect Ratio

+
+ + A placeholder + + +

+ This is a standard card with media at a set aspect ratio of 3X1 and a + button in the footer. +

+
+ + + +
+) + +const mediaAndHeaderFirst = ( + + +

Media and Header First

+
+ + A placeholder + + +

+ This is a standard card with the header and media first and a button in + the footer. +

+
+ + + +
+) + +const insetMedia = ( + + +

Inset Media

+
+ + A placeholder + + +

+ This is a standard card with the header and media first, inset media, + and a button in the footer. +

+
+ + + +
+) + +const exdentMedia = ( + + +

Exdent Media

+
+ + A placeholder + + +

+ This is a standard card with the header and media first, exdent media, + and a button in the footer. +

+
+ + + +
+) + +const exdentCard = ( + + +

Exdent Card

+
+ + A placeholder + + +

+ This is a standard card with the header and media first, media, and a + button in the footer. All of which are exdent style. +

+
+ + + +
+) + +const flagDefault = ( + + +

Flag standardDefault

+
+ + A placeholder + + +

This is a flag card with a button in the footer.

+
+ + + +
+) + +const flagMediaOnRight = ( + + +

Flag Media on Right

+
+ + A placeholder + + +

+ This is a flag card with media on the right and a button in the footer. +

+
+ + + +
+) + +export const cardExamples = (): React.ReactElement => ( + <> + + {[ + card, + cardWithMedia, + mediaWithSetAspectRatio, + mediaAndHeaderFirst, + insetMedia, + exdentMedia, + exdentCard, + ]} + + {[flagDefault, flagMediaOnRight]} + +) + +export const cardTest = (): React.ReactElement => ( + + + +

He had a little small bull pup

+

+ To look at him you’d think he wan’s worth a cent. +

+
+ + {/* eslint-disable-next-line jsx-a11y/img-redundant-alt */} + An image's description + + +

+ His underjaw’d begin to stick out like the fo’castle of a steamboat, + and his teeth would uncover, and shine savage like the furnaces. +

+

+ And a dog might tackle him, and bully-rag him, and bite him, and throw + him over his shoulder two or three times, and Andrew Jackson which was + the name of the pup, Andrew Jackson would never let on but what he was + satisfied. +

+
+ + + +
+ + + +

+ There was a feller here once by the name of Jim Smiley +

+
+ +

+ In the winter of ’49 or may be it was the spring of ’50 I don’t + recollect exactly, somehow, though what makes me think it was one or + the other is because I remember the big flume wasn’t finished when he + first came to the camp: +

+
    +
  1. + But any way, he was the curiosest man about always betting on any + thing that turned up +
  2. +
  3. + If he could get anybody to bet on the other side; and if he + couldn’t, he’d change sides. +
  4. +
  5. + Any way that suited the other man would suit him any way just so’s + he got a bet, he was satisfied. +
  6. +
  7. + But still he was lucky, uncommon lucky; he most always come out + winner. +
  8. +
+
+ + + +
+ + + +

I hereunto append the result

+
+ + + + +

+ He roused up and gave me good-day. I told him a friend of mine had + commissioned me to make some inquiries about a cherished companion of + his boyhood named Leonidas W. Smiley. +

+
+ + + +
+ + + +

My friend’s friend

+
+ + + + +

+ He roused up and gave me good-day. I told him a friend of mine had + commissioned me to make some inquiries about a cherished companion of + his boyhood named Leonidas W. Smiley. +

+
+ + + +
+ + + +

+ If that was the design, it certainly succeeded +

+
+ + + + +

+ He roused up and gave me good-day. I told him a friend of mine had + commissioned me to make some inquiries about a cherished companion of + his boyhood named Leonidas W. Smiley. +

+
+ + + +
+ + + +

Garrulous old Simon Wheeler

+

+ I hereunto append the result. +

+
+ + +

+ This card has aria-hidden on the image container. +

+

+ I found Simon Wheeler dozing comfortably by the bar-room stove of the + old, dilapidated tavern in the ancient mining camp of Angel’s, and I + noticed that he was fat and bald-headed, and had an expression of + winning gentleness and simplicity upon his tranquil countenance. +

+
+ + + +
+ + + +

He never smiled, he never frowned

+
+ + + + +

+ Simon Wheeler backed me into a corner and blockaded me there with his + chair, and then sat me down and reeled off the monotonous narrative + which follows this paragraph. He never smiled, he never frowned, he + never changed his voice from the gentle-flowing key to which he tuned + the initial sentence, he never betrayed the slightest suspicion of + enthusiasm; but all through the interminable narrative there ran a + vein of impressive earnestness and sincerity, which showed me plainly + that, so far from his imagining that there was any thing ridiculous or + funny about his story, he regarded it as a really important matter, + and admired its two heroes as men of transcendent genius in finesse. +

+
+ + + +
+ + +

He never smiled, he never frowned

+
+ + + + +

+ Simon Wheeler backed me into a corner and blockaded me there with his + chair, and then sat me down and reeled off the monotonous narrative + which follows this paragraph. He never smiled, he never frowned, he + never changed his voice from the gentle-flowing key to which he tuned + the initial sentence, he never betrayed the slightest suspicion of + enthusiasm; but all through the interminable narrative there ran a + vein of impressive earnestness and sincerity, which showed me plainly + that, so far from his imagining that there was any thing ridiculous or + funny about his story, he regarded it as a really important matter, + and admired its two heroes as men of transcendent genius in finesse. +

+
+ + + +
+ + +

He never smiled, he never frowned

+
+ + + + +

+ Simon Wheeler backed me into a corner and blockaded me there with his + chair, and then sat me down and reeled off the monotonous narrative + which follows this paragraph. He never smiled, he never frowned, he + never changed his voice from the gentle-flowing key to which he tuned + the initial sentence, he never betrayed the slightest suspicion of + enthusiasm; but all through the interminable narrative there ran a + vein of impressive earnestness and sincerity, which showed me plainly + that, so far from his imagining that there was any thing ridiculous or + funny about his story, he regarded it as a really important matter, + and admired its two heroes as men of transcendent genius in finesse. +

+
+ + + +
+
+) diff --git a/src/components/card/Card/Card.test.tsx b/src/components/card/Card/Card.test.tsx new file mode 100644 index 0000000000..11bd420061 --- /dev/null +++ b/src/components/card/Card/Card.test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { Card } from './Card' + +describe('Card component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + expect(queryByTestId('Card')).toBeInTheDocument() + }) + + it('renders its children', () => { + const { queryByText } = render( + My Content + ) + expect(queryByText('My Content')).toBeInTheDocument() + }) + + it('renders the header first class when standardHeaderFirst is true', () => { + const { getByTestId } = render( + + ) + expect(getByTestId('Card')).toHaveClass('usa-card--header-first') + }) + + it('renders the flag class when layout is flag', () => { + const { getByTestId } = render() + expect(getByTestId('Card')).toHaveClass('usa-card--flag') + }) + + it('renders the media right class when layout is flag and mediaOrientation is right', () => { + const { getByTestId } = render() + expect(getByTestId('Card')).toHaveClass('usa-card--media-right') + }) +}) diff --git a/src/components/card/Card/Card.tsx b/src/components/card/Card/Card.tsx new file mode 100644 index 0000000000..41082746e0 --- /dev/null +++ b/src/components/card/Card/Card.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import classnames from 'classnames' + +import { GridLayoutProp, applyGridClasses } from '../../grid/Grid/Grid' + +interface CardProps { + layout?: 'standardDefault' | 'flagDefault' | 'flagMediaRight' + headerFirst?: boolean + containerProps?: React.HTMLAttributes +} + +export const Card = ( + props: CardProps & React.HTMLAttributes & GridLayoutProp +): React.ReactElement => { + const { + layout = 'standardDefault', + headerFirst, + children, + className, + gridLayout, + containerProps, + ...liProps + } = props + + const { className: containerClass, ...restContainerProps } = + containerProps || {} + + const gridClasses = gridLayout && applyGridClasses(gridLayout) + + const classes = classnames( + 'usa-card', + { + 'usa-card--header-first': headerFirst, + 'usa-card--flag': layout === 'flagDefault' || layout === 'flagMediaRight', + 'usa-card--media-right': layout === 'flagMediaRight', + }, + gridClasses, + className + ) + + const containerClasses = classnames('usa-card__container', containerClass) + + return ( +
  • +
    + {children} +
    +
  • + ) +} + +export default Card diff --git a/src/components/card/CardBody/CardBody.test.tsx b/src/components/card/CardBody/CardBody.test.tsx new file mode 100644 index 0000000000..3c2ecb4ba4 --- /dev/null +++ b/src/components/card/CardBody/CardBody.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { CardBody } from './CardBody' + +describe('CardBody component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardBody')).toBeInTheDocument() + }) + + it('renders its children', () => { + const { queryByText } = render(Body Content) + expect(queryByText('Body Content')).toBeInTheDocument() + }) + + it('renders optional header props', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardBody')).toHaveClass('testClass') + }) + + it('renders proper class when exdent is true', () => { + const { queryByTestId } = render(Content) + expect(queryByTestId('CardBody')).toHaveClass('usa-card__body--exdent') + }) +}) diff --git a/src/components/card/CardBody/CardBody.tsx b/src/components/card/CardBody/CardBody.tsx new file mode 100644 index 0000000000..0ed45ef1de --- /dev/null +++ b/src/components/card/CardBody/CardBody.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import classnames from 'classnames' + +export const CardBody = ( + props: { exdent?: boolean } & React.HTMLAttributes +): React.ReactElement => { + const { exdent, children, className, ...bodyProps } = props + + const classes = classnames( + 'usa-card__body', + { + 'usa-card__body--exdent': exdent, + }, + className + ) + + return ( +
    + {children} +
    + ) +} + +export default CardBody diff --git a/src/components/card/CardFooter/CardFooter.test.tsx b/src/components/card/CardFooter/CardFooter.test.tsx new file mode 100644 index 0000000000..c1dab2a772 --- /dev/null +++ b/src/components/card/CardFooter/CardFooter.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { CardFooter } from './CardFooter' + +describe('CardFooter component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardFooter')).toBeInTheDocument() + }) + + it('renders its children', () => { + const { queryByText } = render(My Header) + expect(queryByText('My Header')).toBeInTheDocument() + }) + + it('renders optional header props', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardFooter')).toHaveClass('testClass') + }) + + it('renders proper class when exdent is true', () => { + const { queryByTestId } = render(Content) + expect(queryByTestId('CardFooter')).toHaveClass('usa-card__footer--exdent') + }) +}) diff --git a/src/components/card/CardFooter/CardFooter.tsx b/src/components/card/CardFooter/CardFooter.tsx new file mode 100644 index 0000000000..62f2a51ecf --- /dev/null +++ b/src/components/card/CardFooter/CardFooter.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import classnames from 'classnames' + +export const CardFooter = ( + props: { exdent?: boolean } & React.HTMLAttributes +): React.ReactElement => { + const { exdent, children, className, ...footerProps } = props + + const classes = classnames( + 'usa-card__footer', + { + 'usa-card__footer--exdent': exdent, + }, + className + ) + + return ( +
    + {children} +
    + ) +} + +export default CardFooter diff --git a/src/components/card/CardGroup/CardGroup.test.tsx b/src/components/card/CardGroup/CardGroup.test.tsx new file mode 100644 index 0000000000..a37ce25ccb --- /dev/null +++ b/src/components/card/CardGroup/CardGroup.test.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { CardGroup } from './CardGroup' + +describe('CardGroup component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardGroup')).toBeInTheDocument() + }) + + it('renders its children', () => { + const { queryByText } = render( + +
  • My list item
  • +
    + ) + expect(queryByText('My list item')).toBeInTheDocument() + }) +}) diff --git a/src/components/card/CardGroup/CardGroup.tsx b/src/components/card/CardGroup/CardGroup.tsx new file mode 100644 index 0000000000..6b2e42f4e0 --- /dev/null +++ b/src/components/card/CardGroup/CardGroup.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import classnames from 'classnames' + +export const CardGroup = ( + props: React.HTMLAttributes +): React.ReactElement => { + const { children, className, ...ulProps } = props + + const classes = classnames('usa-card-group', className) + + return ( +
      + {children} +
    + ) +} + +export default CardGroup diff --git a/src/components/card/CardHeader/CardHeader.test.tsx b/src/components/card/CardHeader/CardHeader.test.tsx new file mode 100644 index 0000000000..3f0b167a78 --- /dev/null +++ b/src/components/card/CardHeader/CardHeader.test.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { CardHeader } from './CardHeader' + +describe('CardHeader component', () => { + it('renders without errors', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardHeader')).toBeInTheDocument() + }) + + it('renders its children', () => { + const { queryByText } = render(My Header) + expect(queryByText('My Header')).toBeInTheDocument() + }) + + it('renders optional header props', () => { + const { queryByTestId } = render() + expect(queryByTestId('CardHeader')).toHaveClass('testClass') + }) + + it('renders proper class when exdent is true', () => { + const { queryByTestId } = render(Content) + expect(queryByTestId('CardHeader')).toHaveClass('usa-card__header--exdent') + }) +}) diff --git a/src/components/card/CardHeader/CardHeader.tsx b/src/components/card/CardHeader/CardHeader.tsx new file mode 100644 index 0000000000..ebdc4c3d88 --- /dev/null +++ b/src/components/card/CardHeader/CardHeader.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import classnames from 'classnames' + +export const CardHeader = ( + props: { exdent?: boolean } & React.HTMLAttributes +): React.ReactElement => { + const { exdent, children, className, ...headerProps } = props + + const classes = classnames( + 'usa-card__header', + { + 'usa-card__header--exdent': exdent, + }, + className + ) + + return ( +
    + {children} +
    + ) +} + +export default CardHeader diff --git a/src/components/card/CardMedia/CardMedia.test.tsx b/src/components/card/CardMedia/CardMedia.test.tsx new file mode 100644 index 0000000000..e8fc902cb5 --- /dev/null +++ b/src/components/card/CardMedia/CardMedia.test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { CardMedia } from './CardMedia' + +describe('CardMedia component', () => { + it('renders without errors', () => { + const { queryByTestId } = render(Media Content) + expect(queryByTestId('CardMedia')).toBeInTheDocument() + }) + + it('renders its children', () => { + const { queryByText } = render(Media Content) + expect(queryByText('Media Content')).toBeInTheDocument() + }) + + it('renders optional media props', () => { + const { queryByTestId } = render( + Media Content + ) + expect(queryByTestId('CardMedia')).toHaveClass('testClass') + }) + + it('renders proper class when exdent is true', () => { + const { queryByTestId } = render( + Media Content + ) + expect(queryByTestId('CardMedia')).toHaveClass('usa-card__media--exdent') + }) + + it('renders proper class when inset is true', () => { + const { queryByTestId } = render(Inset Content) + expect(queryByTestId('CardMedia')).toHaveClass('usa-card__media--inset') + }) +}) diff --git a/src/components/card/CardMedia/CardMedia.tsx b/src/components/card/CardMedia/CardMedia.tsx new file mode 100644 index 0000000000..2c3542c2a6 --- /dev/null +++ b/src/components/card/CardMedia/CardMedia.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import classnames from 'classnames' + +interface CardMediaProps { + exdent?: boolean + inset?: boolean + imageClass?: string + children: React.ReactNode +} + +export const CardMedia = ( + props: CardMediaProps & React.HTMLAttributes +): React.ReactElement => { + const { + exdent, + inset, + imageClass, + children, + className, + ...mediaProps + } = props + + const classes = classnames( + 'usa-card__media', + { + 'usa-card__media--exdent': exdent, + 'usa-card__media--inset': inset, + }, + className + ) + + const imageClasses = classnames('usa-card__img', imageClass) + + return ( +
    +
    {children}
    +
    + ) +} + +export default CardMedia diff --git a/src/components/grid/Grid/Grid.test.tsx b/src/components/grid/Grid/Grid.test.tsx index e531a4316a..593589ae7d 100644 --- a/src/components/grid/Grid/Grid.test.tsx +++ b/src/components/grid/Grid/Grid.test.tsx @@ -1,9 +1,9 @@ import React from 'react' import { render } from '@testing-library/react' -import { Grid, getGridClasses } from './Grid' +import { Grid, getGridClasses, applyGridClasses } from './Grid' -describe('getGridClasses', () => { +describe('getGridClasses function', () => { it('returns the classes with no breakpoint', () => { expect( getGridClasses({ @@ -42,6 +42,34 @@ describe('getGridClasses', () => { }) }) +describe('applyGridClasses function', () => { + it('returns a complete set of grid classes with defaults and breakpoints', () => { + expect( + applyGridClasses({ + col: 6, + tablet: { col: 4 }, + desktop: { col: 3 }, + }) + ).toEqual('grid-col-6 tablet:grid-col-4 desktop:grid-col-3') + }) + + it('handles all of the possible breakpoints', () => { + expect( + applyGridClasses({ + mobile: { col: 12 }, + mobileLg: { col: 10 }, + tablet: { col: 8 }, + tabletLg: { col: 6 }, + desktop: { col: 4 }, + desktopLg: { col: 3 }, + widescreen: { col: 2 }, + }) + ).toEqual( + 'mobile:grid-col-12 mobile-lg:grid-col-10 tablet:grid-col-8 tablet-lg:grid-col-6 desktop:grid-col-4 desktop-lg:grid-col-3 widescreen:grid-col-2' + ) + }) +}) + describe('Grid component', () => { it('renders without errors', () => { const { queryByTestId } = render() diff --git a/src/components/grid/Grid/Grid.tsx b/src/components/grid/Grid/Grid.tsx index 4ed7abf46f..d809a819f3 100644 --- a/src/components/grid/Grid/Grid.tsx +++ b/src/components/grid/Grid/Grid.tsx @@ -3,11 +3,15 @@ import classnames from 'classnames' import { GridItemProps, BreakpointKeys, breakpoints } from '../types' -type GridProps = GridItemProps & +export type GridProps = GridItemProps & { [P in BreakpointKeys]?: GridItemProps } +export type GridLayoutProp = { + gridLayout?: GridProps +} + export const getGridClasses = ( itemProps: GridItemProps, breakpoint?: BreakpointKeys @@ -28,6 +32,20 @@ export const getGridClasses = ( }) } +export const applyGridClasses = (gridLayout: GridProps): string => { + let classes = getGridClasses(gridLayout) + + Object.keys(breakpoints).forEach((b) => { + const bp = b as BreakpointKeys + if (Object.prototype.hasOwnProperty.call(gridLayout, bp)) { + const bpProps = gridLayout[bp] as GridItemProps + classes = classnames(classes, getGridClasses(bpProps, bp)) + } + }) + + return classes +} + export const Grid = ({ children, className,