diff options
Diffstat (limited to 'src/components/molecules/card')
| -rw-r--r-- | src/components/molecules/card/card-actions.test.tsx | 37 | ||||
| -rw-r--r-- | src/components/molecules/card/card-actions.tsx | 42 | ||||
| -rw-r--r-- | src/components/molecules/card/card-body.tsx | 18 | ||||
| -rw-r--r-- | src/components/molecules/card/card-cover.test.tsx | 23 | ||||
| -rw-r--r-- | src/components/molecules/card/card-cover.tsx | 29 | ||||
| -rw-r--r-- | src/components/molecules/card/card-footer.tsx | 32 | ||||
| -rw-r--r-- | src/components/molecules/card/card-header.tsx | 32 | ||||
| -rw-r--r-- | src/components/molecules/card/card-meta.tsx | 16 | ||||
| -rw-r--r-- | src/components/molecules/card/card-provider.tsx | 43 | ||||
| -rw-r--r-- | src/components/molecules/card/card-title.test.tsx | 24 | ||||
| -rw-r--r-- | src/components/molecules/card/card-title.tsx | 27 | ||||
| -rw-r--r-- | src/components/molecules/card/card.module.scss | 296 | ||||
| -rw-r--r-- | src/components/molecules/card/card.stories.tsx | 545 | ||||
| -rw-r--r-- | src/components/molecules/card/card.test.tsx | 129 | ||||
| -rw-r--r-- | src/components/molecules/card/card.tsx | 112 | ||||
| -rw-r--r-- | src/components/molecules/card/index.ts | 8 |
16 files changed, 1413 insertions, 0 deletions
diff --git a/src/components/molecules/card/card-actions.test.tsx b/src/components/molecules/card/card-actions.test.tsx new file mode 100644 index 0000000..94c2677 --- /dev/null +++ b/src/components/molecules/card/card-actions.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { CardActions } from './card-actions'; + +describe('CardActions', () => { + it('renders its children', () => { + const actions = 'animi et omnis'; + + render(<CardActions>{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toBeInTheDocument(); + }); + + it('can render its children with start alignment', () => { + const actions = 'animi et omnis'; + + render(<CardActions alignment="start">{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: flex-start`); + }); + + it('can render its children with centered alignment', () => { + const actions = 'animi et omnis'; + + render(<CardActions alignment="center">{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: center`); + }); + + it('can render its children with end alignment', () => { + const actions = 'animi et omnis'; + + render(<CardActions alignment="end">{actions}</CardActions>); + + expect(rtlScreen.getByText(actions)).toHaveStyle(`--alignment: flex-end`); + }); +}); diff --git a/src/components/molecules/card/card-actions.tsx b/src/components/molecules/card/card-actions.tsx new file mode 100644 index 0000000..9d3e09e --- /dev/null +++ b/src/components/molecules/card/card-actions.tsx @@ -0,0 +1,42 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, + type ReactNode, +} from 'react'; +import styles from './card.module.scss'; + +export type CardActionsProps = Omit< + HTMLAttributes<HTMLDivElement>, + 'children' +> & { + /** + * The actions alignment. + * + * @default 'end' + */ + alignment?: 'center' | 'end' | 'start'; + /** + * The card actions (ie. buttons, links...). + */ + children: ReactNode; +}; + +const CardActionsWithRef: ForwardRefRenderFunction< + HTMLDivElement, + CardActionsProps +> = ({ alignment = 'end', children, className = '', style, ...props }, ref) => { + const actionsClass = `${styles.actions} ${className}`; + const actionsStyles = { + ...style, + '--alignment': alignment === 'center' ? alignment : `flex-${alignment}`, + }; + + return ( + <div {...props} className={actionsClass} ref={ref} style={actionsStyles}> + {children} + </div> + ); +}; + +export const CardActions = forwardRef(CardActionsWithRef); diff --git a/src/components/molecules/card/card-body.tsx b/src/components/molecules/card/card-body.tsx new file mode 100644 index 0000000..40cf515 --- /dev/null +++ b/src/components/molecules/card/card-body.tsx @@ -0,0 +1,18 @@ +import type { FC, HTMLAttributes } from 'react'; +import styles from './card.module.scss'; + +export type CardBodyProps = HTMLAttributes<HTMLElement>; + +export const CardBody: FC<CardBodyProps> = ({ + children, + className = '', + ...props +}) => { + const bodyClass = `${styles.body} ${className}`; + + return ( + <div {...props} className={bodyClass}> + {children} + </div> + ); +}; diff --git a/src/components/molecules/card/card-cover.test.tsx b/src/components/molecules/card/card-cover.test.tsx new file mode 100644 index 0000000..541da44 --- /dev/null +++ b/src/components/molecules/card/card-cover.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import NextImage from 'next/image'; +import { CardCover } from './card-cover'; + +describe('CardCover', () => { + it('renders a cover', () => { + const altTxt = 'nam cupiditate ex'; + + render( + <CardCover> + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ); + + expect(rtlScreen.getByRole('img', { name: altTxt })).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/card/card-cover.tsx b/src/components/molecules/card/card-cover.tsx new file mode 100644 index 0000000..3d6bc7b --- /dev/null +++ b/src/components/molecules/card/card-cover.tsx @@ -0,0 +1,29 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + forwardRef, +} from 'react'; +import { Figure, type FigureProps } from '../../atoms'; +import styles from './card.module.scss'; + +export type CardCoverProps = Omit<FigureProps, 'caption' | 'children'> & { + /** + * The cover. + */ + children: ReactElement; +}; + +const CardCoverWithRef: ForwardRefRenderFunction< + HTMLElement, + CardCoverProps +> = ({ className = '', children, ...props }, ref) => { + const coverClass = `${styles.cover} ${className}`; + + return ( + <Figure {...props} className={coverClass} ref={ref}> + {children} + </Figure> + ); +}; + +export const CardCover = forwardRef(CardCoverWithRef); diff --git a/src/components/molecules/card/card-footer.tsx b/src/components/molecules/card/card-footer.tsx new file mode 100644 index 0000000..56a6513 --- /dev/null +++ b/src/components/molecules/card/card-footer.tsx @@ -0,0 +1,32 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type ReactNode, +} from 'react'; +import { Footer, type FooterProps } from '../../atoms'; +import { useCardFooterMeta } from './card-provider'; +import styles from './card.module.scss'; + +export type CardFooterProps = Omit<FooterProps, 'children'> & { + /** + * The card footer contents. + */ + children?: ReactNode; +}; + +const CardFooterWithRef: ForwardRefRenderFunction< + HTMLElement, + CardFooterProps +> = ({ children, className = '', ...props }, ref) => { + const footerClass = `${styles.footer} ${className}`; + const meta = useCardFooterMeta(); + + return ( + <Footer {...props} className={footerClass} ref={ref}> + {children} + {meta} + </Footer> + ); +}; + +export const CardFooter = forwardRef(CardFooterWithRef); diff --git a/src/components/molecules/card/card-header.tsx b/src/components/molecules/card/card-header.tsx new file mode 100644 index 0000000..213d1bc --- /dev/null +++ b/src/components/molecules/card/card-header.tsx @@ -0,0 +1,32 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type ReactNode, +} from 'react'; +import { Header, type HeaderProps } from '../../atoms'; +import { useCardCover } from './card-provider'; +import styles from './card.module.scss'; + +export type CardHeaderProps = Omit<HeaderProps, 'children'> & { + /** + * The card header contents. + */ + children?: ReactNode; +}; + +const CardHeaderWithRef: ForwardRefRenderFunction< + HTMLElement, + CardHeaderProps +> = ({ children, className = '', ...props }, ref) => { + const cover = useCardCover(); + const headerClass = `${styles.header} ${className}`; + + return ( + <Header {...props} className={headerClass} ref={ref}> + {cover} + {children} + </Header> + ); +}; + +export const CardHeader = forwardRef(CardHeaderWithRef); diff --git a/src/components/molecules/card/card-meta.tsx b/src/components/molecules/card/card-meta.tsx new file mode 100644 index 0000000..403d543 --- /dev/null +++ b/src/components/molecules/card/card-meta.tsx @@ -0,0 +1,16 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { MetaList, type MetaListProps } from '../meta-list'; +import styles from './card.module.scss'; + +export type CardMetaProps = MetaListProps; + +const CardMetaWithRef: ForwardRefRenderFunction< + HTMLDListElement, + CardMetaProps +> = ({ className = '', ...props }, ref) => { + const metaClass = `${styles.meta} ${className}`; + + return <MetaList {...props} className={metaClass} ref={ref} />; +}; + +export const CardMeta = forwardRef(CardMetaWithRef); diff --git a/src/components/molecules/card/card-provider.tsx b/src/components/molecules/card/card-provider.tsx new file mode 100644 index 0000000..5bcdb49 --- /dev/null +++ b/src/components/molecules/card/card-provider.tsx @@ -0,0 +1,43 @@ +import { + createContext, + type FC, + type ReactElement, + type ReactNode, + useContext, +} from 'react'; + +export type CardCoverProviderProps = { + children: ReactNode; + cover?: ReactElement; +}; + +export const CardCoverContext = createContext<ReactElement | null>(null); + +export const useCardCover = () => useContext(CardCoverContext); + +export const CardCoverProvider: FC<CardCoverProviderProps> = ({ + children, + cover, +}) => ( + <CardCoverContext.Provider value={cover ?? null}> + {children} + </CardCoverContext.Provider> +); + +export type CardFooterMetaProviderProps = { + children: ReactNode; + meta?: ReactElement; +}; + +export const CardFooterMetaContext = createContext<ReactElement | null>(null); + +export const useCardFooterMeta = () => useContext(CardFooterMetaContext); + +export const CardFooterMetaProvider: FC<CardFooterMetaProviderProps> = ({ + children, + meta, +}) => ( + <CardFooterMetaContext.Provider value={meta ?? null}> + {children} + </CardFooterMetaContext.Provider> +); diff --git a/src/components/molecules/card/card-title.test.tsx b/src/components/molecules/card/card-title.test.tsx new file mode 100644 index 0000000..9dbf6ac --- /dev/null +++ b/src/components/molecules/card/card-title.test.tsx @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { CardTitle } from './card-title'; + +describe('CardTitle', () => { + it('renders a title of level 2', () => { + const title = 'animi et omnis'; + + render(<CardTitle>{title}</CardTitle>); + + expect(rtlScreen.getByRole('heading', { level: 2 })).toHaveTextContent( + title + ); + }); + + it('can render a title with a custom level', () => { + const level = 4; + const title = 'animi et omnis'; + + render(<CardTitle level={level}>{title}</CardTitle>); + + expect(rtlScreen.getByRole('heading', { level })).toHaveTextContent(title); + }); +}); diff --git a/src/components/molecules/card/card-title.tsx b/src/components/molecules/card/card-title.tsx new file mode 100644 index 0000000..09d61ea --- /dev/null +++ b/src/components/molecules/card/card-title.tsx @@ -0,0 +1,27 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { Heading, type HeadingProps } from '../../atoms'; +import styles from './card.module.scss'; + +export type CardTitleProps = Omit<HeadingProps, 'level'> & { + /** + * The title level (between 1 and 6). + * + * @default 2 + */ + level?: HeadingProps['level']; +}; + +const CardTitleWithRef: ForwardRefRenderFunction< + HTMLHeadingElement, + CardTitleProps +> = ({ children, className = '', level = 2, ...props }, ref) => { + const headingClass = `${styles.title} ${className}`; + + return ( + <Heading {...props} className={headingClass} level={level} ref={ref}> + {children} + </Heading> + ); +}; + +export const CardTitle = forwardRef(CardTitleWithRef); diff --git a/src/components/molecules/card/card.module.scss b/src/components/molecules/card/card.module.scss new file mode 100644 index 0000000..65f92f6 --- /dev/null +++ b/src/components/molecules/card/card.module.scss @@ -0,0 +1,296 @@ +@use "../../../styles/abstracts/functions" as fun; + +$breakpoint: 50ch; + +.cover { + width: var(--cover-width, 100%); + height: var(--cover-height, fun.convert-px(150)); + max-width: none; + position: relative; + + > * { + width: 100%; + height: 100%; + max-width: 100%; + position: absolute; + inset: 0; + object-position: center; + object-fit: cover; + } +} + +.title { + background: none; + padding: 0; +} + +.body { + max-width: 80ch; + color: var(--color-fg); + font-weight: 400; +} + +.actions { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: var(--alignment); + column-gap: var(--spacing-md); + row-gap: var(--spacing-sm); + padding-block: var(--spacing-sm); +} + +.meta { + font-size: var(--font-size-sm); +} + +:where(.footer) .meta { + flex-flow: row wrap; +} + +.header, +.footer { + display: contents; +} + +.card { + --card-padding: clamp(var(--spacing-sm), 3vw, var(--spacing-md)); + + column-gap: var(--spacing-md); + row-gap: var(--spacing-sm); + width: 100%; + height: 100%; + background: var(--color-bg); + + &--variant-1 { + --cover-height: clamp( + #{fun.convert-px(100)}, + calc(#{fun.convert-px(200)} - 10cqw), + #{fun.convert-px(200)} + ); + } + + &--variant-2 { + --cover-height: #{fun.convert-px(90)}; + --cover-width: var(--cover-height); + } +} + +:where(.card--variant-2) .cover { + border-radius: fun.convert-px(3); + box-shadow: + 0 0 0 fun.convert-px(1) var(--color-shadow-light), + fun.convert-px(2) fun.convert-px(2) 0 fun.convert-px(1) var(--color-shadow); +} + +.wrapper { + container: card / inline-size; + + &--centered { + .header, + .body, + .footer { + text-align: center; + } + + .meta { + margin-inline: auto; + } + } + + &--is-link { + --scale-up: 1.05; + + &:not(:disabled):focus:not(:hover) { + text-decoration: none; + + .title { + text-decoration: underline solid var(--color-primary) 0.3ex; + } + } + } +} + +/* stylelint-disable no-descending-specificity -- Stylelint complains about + * specificity but I think it is clearer and DRY this way. */ + +:where(.wrapper--is-block) .card--variant-2 { + border: fun.convert-px(1) solid var(--color-border); + box-shadow: + fun.convert-px(3) fun.convert-px(3) 0 0 var(--color-shadow-light), + fun.convert-px(4) fun.convert-px(4) fun.convert-px(3) fun.convert-px(-2) + var(--color-shadow); +} + +@container card (width <= #{$breakpoint}) { + .card { + display: flex; + flex-flow: column wrap; + row-gap: var(--card-padding); + + &:where(:not(&--has-cover)) { + padding: var(--card-padding); + } + + &:where(&--has-cover) { + padding-block-end: var(--card-padding); + + :where(.header, .footer) > *:not(.cover), + .body { + padding-inline: var(--card-padding); + } + } + } + + :where(.card--variant-1) { + .cover { + --cover-width: 100%; + } + + :where(.footer) .meta { + margin-block-start: auto; + justify-content: space-between; + } + } + + :where(.card--variant-2) { + :where(.header) { + .cover, + .title { + margin-inline: auto; + } + + .cover { + margin-block-start: var(--card-padding); + } + } + } +} + +@container card (width > #{$breakpoint}) { + .card { + display: grid; + grid-auto-rows: max-content; + padding: var(--card-padding); + } + + .cover, + .title, + .meta, + .body, + .actions { + grid-column: 1; + } + + .card--variant-1 { + grid-auto-columns: minmax(30ch, 80ch) 1fr minmax(min-content, 25cqw); + } + + :where(.wrapper--is-block) .card--variant-1 { + border: fun.convert-px(1) solid var(--color-primary-dark); + border-radius: fun.convert-px(3); + box-shadow: + fun.convert-px(1) fun.convert-px(1) fun.convert-px(1) 0 + var(--color-shadow), + fun.convert-px(3) fun.convert-px(3) fun.convert-px(3) fun.convert-px(-1) + var(--color-shadow-light), + fun.convert-px(5) fun.convert-px(5) fun.convert-px(7) fun.convert-px(-1) + var(--color-shadow-light); + } + + .card--variant-2 { + grid-auto-columns: minmax(min-content, 15cqw) 1fr minmax(30ch, 80ch); + } + + .cover { + grid-row-start: 1; + } + + :where(.wrapper--is-link:not(:disabled, :hover):focus) .title { + text-decoration: underline solid var(--color-primary) 0.3ex; + } + + :where(.card--has-cover .header:only-child) .cover:only-child { + --cover-width: calc(100% + (2 * var(--card-padding))); + --cover-height: calc(100% + (2 * var(--card-padding))); + + margin-block-start: calc(var(--card-padding) * -1); + margin-inline-start: calc(var(--card-padding) * -1); + } + + :where(.card--variant-1) { + :where(.header:only-child) > *:only-child, + .body:only-child, + :where(.footer:only-child) > *:only-child { + grid-column: 1 / span 2; + } + + :where(.header:not(:only-child)) .cover, + :where(.header:only-child) .cover:not(:only-child), + :where(.footer:not(:only-child)) .meta, + :where(.footer:only-child) .meta:not(:only-child) { + grid-column: 3; + } + + :where(.footer) .meta { + grid-row-start: 1; + flex-flow: column wrap; + } + + :where(.body:first-child + .footer) .meta, + :where(.footer:only-child) .meta { + grid-row-end: 1; + } + + :where(.header) .title, + :where(.header) .cover + .meta { + grid-row: 1; + align-self: center; + } + } + + :where(.card--variant-1.card--has-cover .footer) .meta { + grid-row: 2 / span 3; + } + + :where(.card--variant-1.card--no-footer-meta .header:not(:only-child)) .cover, + :where(.card--variant-1.card--no-footer-meta .header) .cover:not(:only-child), + :where(.card--variant-1.card--no-cover .footer) .meta { + --cover-height: 100%; + + grid-row-end: span 4; + } + + :where(.card--variant-2) { + .header:only-child > *, + .body:only-child, + .footer:only-child > * { + grid-column: 1 / span 2; + } + + .header:only-child > .meta { + justify-self: center; + } + + :where(.header:not(:only-child)) .meta, + .body, + :where(.footer:not(:only-child)) .actions, + :where(.footer:not(:only-child)) .meta { + grid-column: 3; + } + + :where(.header) { + .cover, + .title { + grid-row: 1 / span 2; + justify-self: center; + } + } + } + + :where(.card--variant-2.card--has-cover .header) .title { + margin-top: calc(var(--cover-height) + var(--spacing-sm)); + } +} + +/* stylelint-enable no-descending-specificity */ diff --git a/src/components/molecules/card/card.stories.tsx b/src/components/molecules/card/card.stories.tsx new file mode 100644 index 0000000..74f426c --- /dev/null +++ b/src/components/molecules/card/card.stories.tsx @@ -0,0 +1,545 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import NextImage from 'next/image'; +import { Button, ButtonLink, Link, Time } from '../../atoms'; +import { Card, type CardProps } from './card'; +import { CardActions } from './card-actions'; +import { CardBody } from './card-body'; +import { CardCover } from './card-cover'; +import { CardFooter } from './card-footer'; +import { CardHeader } from './card-header'; +import { CardMeta } from './card-meta'; +import { CardTitle } from './card-title'; + +/** + * Card - Storybook Meta + */ +export default { + title: 'Molecules/Card', + component: Card, + argTypes: {}, +} as ComponentMeta<typeof Card>; + +const Template: ComponentStory<typeof Card> = <T extends string | undefined>( + args: CardProps<T> +) => <Card {...args} />; + +/** + * Card Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + children: 'The card contents.', +}; + +/** + * Card Stories - AsLink + */ +export const AsLink = Template.bind({}); +AsLink.args = { + 'aria-label': 'Learn more about this card', + children: 'The card contents.', + linkTo: '#card', +}; + +export const HeaderCover = Template.bind({}); +HeaderCover.args = { + children: <CardHeader />, + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const HeaderTitle = Template.bind({}); +HeaderTitle.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + ), +}; + +export const HeaderMeta = Template.bind({}); +HeaderMeta.args = { + children: ( + <CardHeader> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + ), +}; + +export const BodyContents = Template.bind({}); +BodyContents.args = { + children: <CardBody>The card contents</CardBody>, +}; + +export const FooterActions = Template.bind({}); +FooterActions.args = { + children: ( + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + ), +}; + +export const FooterMeta = Template.bind({}); +FooterMeta.args = { + children: <CardFooter />, + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; + +export const CompositionCoverTitle = Template.bind({}); +CompositionCoverTitle.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleMeta = Template.bind({}); +CompositionTitleMeta.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + ), +}; + +export const CompositionCoverTitleMeta = Template.bind({}); +CompositionCoverTitleMeta.args = { + children: ( + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleBody = Template.bind({}); +CompositionTitleBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), +}; + +export const CompositionCoverTitleBody = Template.bind({}); +CompositionCoverTitleBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleMetaBody = Template.bind({}); +CompositionTitleMetaBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), +}; + +export const CompositionCoverTitleMetaBody = Template.bind({}); +CompositionCoverTitleMetaBody.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleActions = Template.bind({}); +CompositionTitleActions.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), +}; + +export const CompositionCoverTitleActions = Template.bind({}); +CompositionCoverTitleActions.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), +}; + +export const CompositionTitleBodyActions = Template.bind({}); +CompositionTitleBodyActions.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), +}; + +export const CompositionTitleBodyActionsMeta = Template.bind({}); +CompositionTitleBodyActionsMeta.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; + +export const CompositionCoverTitleBodyActionsMeta = Template.bind({}); +CompositionCoverTitleBodyActionsMeta.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; + +export const CompositionAllContents = Template.bind({}); +CompositionAllContents.args = { + children: ( + <> + <CardHeader> + <CardTitle>The card title</CardTitle> + <CardMeta + isInline + items={[ + { id: 'author', label: 'Written by:', value: 'The author' }, + { + id: 'publication-date', + label: 'Published on:', + value: <Time date={new Date().toISOString()} />, + }, + ]} + /> + </CardHeader> + <CardBody> + Nihil magnam tempora voluptatem. Reiciendis ut cum vel. Odit et + necessitatibus esse laudantium sequi ad. Et est quas pariatur facere + velit veritatis nihil. Ratione aperiam omnis quia ut asperiores tenetur + dolores veniam. Nostrum est ullam aliquam aliquid expedita ea. + </CardBody> + <CardFooter> + <CardActions> + <ButtonLink to="#post">Read more</ButtonLink> + <Button>Share</Button> + </CardActions> + </CardFooter> + </> + ), + cover: ( + <CardCover> + <NextImage + alt="A cover example" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + </CardCover> + ), + meta: ( + <CardMeta + items={[ + { + id: 'categories', + label: 'Categories:', + value: [ + { id: 'cat-1', value: <Link href="#cat1">Category 1</Link> }, + { id: 'cat-2', value: <Link href="#cat2">Category 2</Link> }, + ], + }, + { + id: 'tags', + label: 'Tags:', + value: [ + { id: 'tag-1', value: 'Tag 1' }, + { id: 'tag-2', value: 'Tag 2' }, + { id: 'tag-3', value: 'Tag 3' }, + ], + }, + ]} + /> + ), +}; diff --git a/src/components/molecules/card/card.test.tsx b/src/components/molecules/card/card.test.tsx new file mode 100644 index 0000000..40a5830 --- /dev/null +++ b/src/components/molecules/card/card.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import NextImage from 'next/image'; +import { Card } from './card'; +import { CardFooter } from './card-footer'; +import { CardHeader } from './card-header'; +import { CardMeta } from './card-meta'; + +describe('Card', () => { + it('renders its children', () => { + const body = 'eveniet error voluptas'; + + render(<Card>{body}</Card>); + + expect(rtlScreen.getByText(body)).toBeInTheDocument(); + }); + + it('can render a cover in the card header', () => { + const altTxt = 'quo expedita eveniet'; + + render( + <Card + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + > + <CardHeader /> + </Card> + ); + + expect(rtlScreen.getByRole('img', { name: altTxt })).toBeInTheDocument(); + }); + + it('does not render a cover without card header', () => { + const body = 'necessitatibus maiores sed'; + const altTxt = 'quo expedita eveniet'; + + render( + <Card + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + > + {body} + </Card> + ); + + expect( + rtlScreen.queryByRole('img', { name: altTxt }) + ).not.toBeInTheDocument(); + }); + + it('can render some meta in the card footer', () => { + const term = 'ut'; + const desc = 'repudiandae'; + + render( + <Card + meta={<CardMeta items={[{ id: 'any', label: term, value: desc }]} />} + > + <CardFooter /> + </Card> + ); + + expect(rtlScreen.getByRole('term')).toHaveTextContent(term); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(desc); + }); + + it('does not render the meta without card footer', () => { + const body = 'rerum dolore et'; + const term = 'ut'; + const desc = 'repudiandae'; + + render( + <Card + meta={<CardMeta items={[{ id: 'any', label: term, value: desc }]} />} + > + {body} + </Card> + ); + + expect( + rtlScreen.queryByRole('term', { name: term }) + ).not.toBeInTheDocument(); + expect( + rtlScreen.queryByRole('definition', { name: desc }) + ).not.toBeInTheDocument(); + }); + + it('can render a card as link to another page', () => { + const body = 'Et qui harum voluptas est quos qui.'; + const cta = 'asperiores optio incidunt'; + const target = '#molestiae'; + + render( + <Card aria-label={cta} linkTo={target}> + {body} + </Card> + ); + + expect(rtlScreen.getByRole('link', { name: cta })).toHaveAttribute( + 'href', + target + ); + }); + + it('can render a card with centered text', () => { + const body = 'Et qui harum voluptas est quos qui.'; + const label = 'asperiores optio incidunt'; + + render( + <Card aria-label={label} isCentered> + {body} + </Card> + ); + + expect(rtlScreen.getByLabelText(label)).toHaveClass('wrapper--centered'); + }); +}); diff --git a/src/components/molecules/card/card.tsx b/src/components/molecules/card/card.tsx new file mode 100644 index 0000000..788b040 --- /dev/null +++ b/src/components/molecules/card/card.tsx @@ -0,0 +1,112 @@ +import { + forwardRef, + type HTMLAttributes, + type ForwardedRef, + type ReactNode, + type ReactElement, +} from 'react'; +import { Article, ButtonLink, type ButtonLinkProps } from '../../atoms'; +import { CardCoverProvider, CardFooterMetaProvider } from './card-provider'; +import styles from './card.module.scss'; + +type CardBaseProps<T extends string | undefined> = T extends string + ? Omit<ButtonLinkProps, 'children' | 'isExternal' | 'kind' | 'shape' | 'to'> + : Omit<HTMLAttributes<HTMLDivElement>, 'children'>; + +export type CardProps<T extends string | undefined> = CardBaseProps<T> & { + /** + * The card contents. + */ + children: ReactNode; + /** + * The card cover. You need to add a `<CardHeader />` as children to use it. + */ + cover?: ReactElement; + /** + * Should the contents be centered? + * + * @default false + */ + isCentered?: boolean; + /** + * Link the card to another page. + * + * @default undefined + */ + linkTo?: T; + /** + * The meta to place in the card footer. You need to add a `<CardFooter />` + * as children to use it. + */ + meta?: ReactElement; + /** + * The card variant. + * + * @default 1 + */ + variant?: 1 | 2; +}; + +const CardWrapper = <T extends string | undefined>( + { + children, + className = '', + cover, + isCentered = false, + linkTo, + meta, + variant = 1, + ...props + }: CardProps<T>, + ref: ForwardedRef<T extends string ? HTMLAnchorElement : HTMLDivElement> +) => { + const wrapperClass = [ + styles.wrapper, + styles[isCentered ? 'wrapper--centered' : 'wrapper--not-centered'], + styles[linkTo ? 'wrapper--is-link' : 'wrapper--is-block'], + className, + ].join(' '); + const cardClass = [ + styles.card, + styles[cover ? 'card--has-cover' : 'card--no-cover'], + styles[meta ? 'card--has-footer-meta' : 'card--no-footer-meta'], + styles[`card--variant-${variant}`], + ].join(' '); + + return ( + <CardCoverProvider cover={cover}> + <CardFooterMetaProvider meta={meta}> + {linkTo ? ( + <ButtonLink + {...(props as CardBaseProps<typeof linkTo>)} + className={wrapperClass} + ref={ref} + // eslint-disable-next-line react/jsx-no-literals -- Shape allowed + shape="auto" + to={linkTo} + > + <Article className={cardClass}>{children}</Article> + </ButtonLink> + ) : ( + <div + {...(props as CardBaseProps<undefined>)} + className={wrapperClass} + ref={ref as ForwardedRef<HTMLDivElement>} + > + <Article className={cardClass}>{children}</Article> + </div> + )} + </CardFooterMetaProvider> + </CardCoverProvider> + ); +}; + +/** + * Card component + * + * Render a card with title and some other optional data. + * + * TODO: remove `cover` and `meta` props (and adapt CSS) when support for CSS + * `:has()` pseudo-class will be good enough. + */ +export const Card = forwardRef(CardWrapper); diff --git a/src/components/molecules/card/index.ts b/src/components/molecules/card/index.ts new file mode 100644 index 0000000..2b55191 --- /dev/null +++ b/src/components/molecules/card/index.ts @@ -0,0 +1,8 @@ +export * from './card'; +export * from './card-actions'; +export * from './card-body'; +export * from './card-cover'; +export * from './card-footer'; +export * from './card-header'; +export * from './card-meta'; +export * from './card-title'; |
