From 47e35fcd7c2c346f4799630bf6521d6a4bf49e85 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 13 Apr 2022 19:28:16 +0200 Subject: chore: add a Card component --- .../atoms/lists/description-list.module.scss | 42 +++++--- .../atoms/lists/description-list.stories.tsx | 18 ++++ src/components/atoms/lists/description-list.tsx | 45 +++++++- .../molecules/images/responsive-image.tsx | 5 +- src/components/molecules/layout/card.module.scss | 77 ++++++++++++++ src/components/molecules/layout/card.stories.tsx | 102 ++++++++++++++++++ src/components/molecules/layout/card.test.tsx | 52 ++++++++++ src/components/molecules/layout/card.tsx | 114 +++++++++++++++++++++ 8 files changed, 435 insertions(+), 20 deletions(-) create mode 100644 src/components/molecules/layout/card.module.scss create mode 100644 src/components/molecules/layout/card.stories.tsx create mode 100644 src/components/molecules/layout/card.test.tsx create mode 100644 src/components/molecules/layout/card.tsx (limited to 'src') 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; 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 @@ -21,10 +21,30 @@ export type DescriptionListProps = { * Set additional classnames to the list wrapper. */ 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 = ({ 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 = ({ const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => { return listItems.map(({ id, term, value }) => { return ( -
-
{term}
+
+
{term}
{value.map((currentValue, index) => ( -
+
{currentValue}
))} @@ -57,7 +88,13 @@ const DescriptionList: VFC = ({ }); }; - return
{getItems(items)}
; + return ( +
+ {getItems(items)} +
+ ); }; 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 & { +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; + +const Template: ComponentStory = (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(); + expect( + screen.getByRole('heading', { level: 2, name: title }) + ).toBeInTheDocument(); + }); + + it('renders a link to another page', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', url); + }); + + it('renders a cover', () => { + render(); + expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + }); + + it('renders a tagline', () => { + render(); + expect(screen.getByText(tagline)).toBeInTheDocument(); + }); + + it('renders some meta', () => { + render(); + 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 = ({ + className = '', + cover, + coverFit = 'cover', + meta, + tagline, + title, + titleLevel, + url, +}) => { + return ( + +
+
+ {cover && ( + + )} + + {title} + +
+
{tagline}
+ {meta && ( + + )} +
+
+ ); +}; + +export default Card; -- cgit v1.2.3