diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-04-13 19:28:16 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-04-13 19:28:16 +0200 |
| commit | 47e35fcd7c2c346f4799630bf6521d6a4bf49e85 (patch) | |
| tree | 99228c523b6ced1d9c1e83a03a4dd9fc2468e4b0 /src/components | |
| parent | 017d01680a933897df6ddd11d2e081730756250b (diff) | |
chore: add a Card component
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/atoms/lists/description-list.module.scss | 42 | ||||
| -rw-r--r-- | src/components/atoms/lists/description-list.stories.tsx | 18 | ||||
| -rw-r--r-- | src/components/atoms/lists/description-list.tsx | 45 | ||||
| -rw-r--r-- | src/components/molecules/images/responsive-image.tsx | 5 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.module.scss | 77 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.stories.tsx | 102 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.test.tsx | 52 | ||||
| -rw-r--r-- | src/components/molecules/layout/card.tsx | 114 |
8 files changed, 435 insertions, 20 deletions
diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss index 4758816..caa2711 100644 --- a/src/components/atoms/lists/description-list.module.scss +++ b/src/components/atoms/lists/description-list.module.scss @@ -6,18 +6,6 @@ gap: var(--spacing-2xs); margin: 0; - &__item { - display: flex; - flex-flow: column wrap; - gap: var(--spacing-2xs); - - @include mix.media("screen") { - @include mix.dimensions("sm") { - flex-flow: row wrap; - } - } - } - &__term { flex: 0 0 max-content; color: var(--color-fg-light); @@ -27,16 +15,40 @@ &__description { flex: 0 0 auto; margin: 0; + } + + &__item { + display: flex; + } + + &--inline &__item { + flex-flow: column wrap; @include mix.media("screen") { - @include mix.dimensions("sm") { - &:not(:first-of-type) { + @include mix.dimensions("xs") { + flex-flow: row wrap; + gap: var(--spacing-2xs); + + .list__description:not(:first-of-type) { &::before { content: "/"; - margin: 0 var(--spacing-2xs); + margin-right: var(--spacing-2xs); } } } } } + + &--column#{&}--responsive { + @include mix.media("screen") { + @include mix.dimensions("xs") { + flex-flow: row wrap; + gap: var(--spacing-lg); + } + } + } + + &--column &__item { + flex-flow: column wrap; + } } diff --git a/src/components/atoms/lists/description-list.stories.tsx b/src/components/atoms/lists/description-list.stories.tsx index c65241d..66d94af 100644 --- a/src/components/atoms/lists/description-list.stories.tsx +++ b/src/components/atoms/lists/description-list.stories.tsx @@ -6,6 +6,9 @@ import DescriptionListComponent, { export default { title: 'Atoms/Lists', component: DescriptionListComponent, + args: { + layout: 'column', + }, argTypes: { className: { control: { @@ -31,6 +34,21 @@ export default { value: {}, }, }, + layout: { + control: { + type: 'select', + }, + description: 'The list layout.', + options: ['column', 'inline'], + table: { + category: 'Options', + defaultValue: { summary: 'column' }, + }, + type: { + name: 'string', + required: false, + }, + }, }, } as ComponentMeta<typeof DescriptionListComponent>; diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx index a5ab1d5..0a92465 100644 --- a/src/components/atoms/lists/description-list.tsx +++ b/src/components/atoms/lists/description-list.tsx @@ -22,9 +22,29 @@ export type DescriptionListProps = { */ className?: string; /** + * Set additional classnames to the `dd` element. + */ + descriptionClassName?: string; + /** + * Set additional classnames to the `dt`/`dd` couple wrapper. + */ + groupClassName?: string; + /** * The list items. */ items: DescriptionListItem[]; + /** + * The list items layout. Default: column. + */ + layout?: 'inline' | 'column'; + /** + * Define if the layout should automatically create rows/columns. + */ + responsiveLayout?: boolean; + /** + * Set additional classnames to the `dt` element. + */ + termClassName?: string; }; /** @@ -34,8 +54,16 @@ export type DescriptionListProps = { */ const DescriptionList: VFC<DescriptionListProps> = ({ className = '', + descriptionClassName = '', + groupClassName = '', items, + layout = 'column', + responsiveLayout = false, + termClassName = '', }) => { + const layoutModifier = `list--${layout}`; + const responsiveModifier = responsiveLayout ? 'list--responsive' : ''; + /** * Retrieve the description list items wrapped in a div element. * @@ -45,10 +73,13 @@ const DescriptionList: VFC<DescriptionListProps> = ({ const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => { return listItems.map(({ id, term, value }) => { return ( - <div key={id} className={styles.list__item}> - <dt className={styles.list__term}>{term}</dt> + <div key={id} className={`${styles.list__item} ${groupClassName}`}> + <dt className={`${styles.list__term} ${termClassName}`}>{term}</dt> {value.map((currentValue, index) => ( - <dd key={`${id}-${index}`} className={styles.list__description}> + <dd + key={`${id}-${index}`} + className={`${styles.list__description} ${descriptionClassName}`} + > {currentValue} </dd> ))} @@ -57,7 +88,13 @@ const DescriptionList: VFC<DescriptionListProps> = ({ }); }; - return <dl className={`${styles.list} ${className}`}>{getItems(items)}</dl>; + return ( + <dl + className={`${styles.list} ${styles[layoutModifier]} ${styles[responsiveModifier]} ${className}`} + > + {getItems(items)} + </dl> + ); }; export default DescriptionList; diff --git a/src/components/molecules/images/responsive-image.tsx b/src/components/molecules/images/responsive-image.tsx index 3d54e95..1d8787e 100644 --- a/src/components/molecules/images/responsive-image.tsx +++ b/src/components/molecules/images/responsive-image.tsx @@ -3,7 +3,10 @@ import Image, { ImageProps } from 'next/image'; import { VFC } from 'react'; import styles from './responsive-image.module.scss'; -type ResponsiveImageProps = Omit<ImageProps, 'alt' | 'width' | 'height'> & { +export type ResponsiveImageProps = Omit< + ImageProps, + 'alt' | 'width' | 'height' +> & { /** * An alternative text. */ diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss new file mode 100644 index 0000000..2b1b7dc --- /dev/null +++ b/src/components/molecules/layout/card.module.scss @@ -0,0 +1,77 @@ +@use "@styles/abstracts/functions" as fun; + +.wrapper { + --scale-up: 1.05; + --scale-down: 0.95; + + display: flex; + flex-flow: column wrap; + max-width: var(--card-width, 40ch); + padding: 0; + text-align: center; + + .article { + flex: 1; + display: flex; + flex-flow: column nowrap; + justify-content: flex-start; + } + + .footer { + margin-top: var(--spacing-md); + } + + .cover { + align-self: flex-start; + max-height: fun.convert-px(150); + margin: auto; + border-bottom: fun.convert-px(1) solid var(--color-border); + } + + .title, + .tagline, + .footer { + padding: 0 var(--spacing-md); + } + + .title { + flex: 1; + margin: var(--spacing-sm) 0; + } + + h2.title { + background: none; + text-shadow: none; + } + + .tagline { + flex: 1; + color: var(--color-fg); + font-weight: 400; + } + + .list { + margin-bottom: var(--spacing-md); + } + + .items { + flex-flow: row wrap; + place-content: center; + gap: var(--spacing-2xs); + } + + .term { + flex: 0 0 100%; + } + + .description { + padding: fun.convert-px(2) var(--spacing-xs); + border: fun.convert-px(1) solid var(--color-primary-darker); + color: var(--color-fg); + font-weight: 400; + + &::before { + display: none; + } + } +} diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx new file mode 100644 index 0000000..a07f8dc --- /dev/null +++ b/src/components/molecules/layout/card.stories.tsx @@ -0,0 +1,102 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import CardComponent from './card'; + +export default { + title: 'Molecules/Layout', + component: CardComponent, + argTypes: { + cover: { + description: 'The card cover data (src, dimensions, alternative text).', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + meta: { + description: 'The card metadata (a publication date for example).', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + tagline: { + control: { + type: 'text', + }, + description: 'A few words about the card.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The card title.', + type: { + name: 'string', + required: true, + }, + }, + titleLevel: { + control: { + type: 'number', + }, + description: 'The title level.', + type: { + name: 'number', + required: true, + }, + }, + url: { + control: { + type: 'text', + }, + description: 'The card target.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof CardComponent>; + +const Template: ComponentStory<typeof CardComponent> = (args) => ( + <CardComponent {...args} /> +); + +const cover = { + alt: 'A picture', + height: 480, + src: 'http://placeimg.com/640/480', + width: 640, +}; + +const meta = [ + { + id: 'an-id', + term: 'Voluptates', + value: ['Autem', 'Eos'], + }, +]; + +export const Card = Template.bind({}); +Card.args = { + cover, + meta, + title: 'Veritatis dicta quod', + titleLevel: 2, + url: '#', +}; diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx new file mode 100644 index 0000000..404bc7a --- /dev/null +++ b/src/components/molecules/layout/card.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@test-utils'; +import Card from './card'; + +const cover = { + alt: 'A picture', + height: 480, + src: 'http://placeimg.com/640/480', + width: 640, +}; + +const meta = [ + { + id: 'an-id', + term: 'Voluptates', + value: ['Autem', 'Eos'], + }, +]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; + +describe('Card', () => { + it('renders a title wrapped in h2 element', () => { + render(<Card title={title} titleLevel={2} url={url} />); + expect( + screen.getByRole('heading', { level: 2, name: title }) + ).toBeInTheDocument(); + }); + + it('renders a link to another page', () => { + render(<Card title={title} titleLevel={2} url={url} />); + expect(screen.getByRole('link')).toHaveAttribute('href', url); + }); + + it('renders a cover', () => { + render(<Card title={title} titleLevel={2} url={url} cover={cover} />); + expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + }); + + it('renders a tagline', () => { + render(<Card title={title} titleLevel={2} url={url} tagline={tagline} />); + expect(screen.getByText(tagline)).toBeInTheDocument(); + }); + + it('renders some meta', () => { + render(<Card title={title} titleLevel={2} url={url} meta={meta} />); + expect(screen.getByText(meta[0].term)).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx new file mode 100644 index 0000000..23a0e54 --- /dev/null +++ b/src/components/molecules/layout/card.tsx @@ -0,0 +1,114 @@ +import ButtonLink from '@components/atoms/buttons/button-link'; +import Heading, { type HeadingLevel } from '@components/atoms/headings/heading'; +import DescriptionList, { + DescriptionListItem, +} from '@components/atoms/lists/description-list'; +import { VFC } from 'react'; +import ResponsiveImage, { + ResponsiveImageProps, +} from '../images/responsive-image'; +import styles from './card.module.scss'; + +export type Cover = { + /** + * The cover alternative text. + */ + alt: string; + /** + * The cover height. + */ + height: number; + /** + * The cover source. + */ + src: string; + /** + * The cover width. + */ + width: number; +}; + +export type CardProps = { + /** + * Set additional classnames to the card wrapper. + */ + className?: string; + /** + * The card cover. + */ + cover?: Cover; + /** + * The cover fit. Default: cover. + */ + coverFit?: ResponsiveImageProps['objectFit']; + /** + * The card meta. + */ + meta?: DescriptionListItem[]; + /** + * The card tagline. + */ + tagline?: string; + /** + * The card title. + */ + title: string; + /** + * The title level (hn). + */ + titleLevel: HeadingLevel; + /** + * The card target. + */ + url: string; +}; + +/** + * Card component + * + * Render a link with minimal information about its content. + */ +const Card: VFC<CardProps> = ({ + className = '', + cover, + coverFit = 'cover', + meta, + tagline, + title, + titleLevel, + url, +}) => { + return ( + <ButtonLink target={url} className={`${styles.wrapper} ${className}`}> + <article className={styles.article}> + <header className={styles.header}> + {cover && ( + <ResponsiveImage + {...cover} + objectFit={coverFit} + className={styles.cover} + /> + )} + <Heading level={titleLevel} className={styles.title}> + {title} + </Heading> + </header> + <div className={styles.tagline}>{tagline}</div> + {meta && ( + <footer className={styles.footer}> + <DescriptionList + items={meta} + layout="inline" + className={styles.list} + groupClassName={styles.items} + termClassName={styles.term} + descriptionClassName={styles.description} + /> + </footer> + )} + </article> + </ButtonLink> + ); +}; + +export default Card; |
