diff options
Diffstat (limited to 'src/components/molecules')
| -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 |
5 files changed, 349 insertions, 1 deletions
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; |
