From c87c615b5866b8a8f361eeb0764bfdea85740e90 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 10 Oct 2023 19:37:51 +0200 Subject: refactor(components): replace Meta component with MetaList It removes items complexity by allowing consumers to use any label/value association. Translations should also be defined by the consumer. Each item can now be configured separately (borders, layout...). --- src/components/molecules/index.ts | 1 + src/components/molecules/layout/card.fixture.ts | 19 - src/components/molecules/layout/card.module.scss | 4 + src/components/molecules/layout/card.stories.tsx | 31 +- src/components/molecules/layout/card.test.tsx | 46 ++- src/components/molecules/layout/card.tsx | 12 +- src/components/molecules/layout/index.ts | 1 - src/components/molecules/layout/meta.module.scss | 16 - src/components/molecules/layout/meta.stories.tsx | 45 --- src/components/molecules/layout/meta.test.tsx | 25 -- src/components/molecules/layout/meta.tsx | 395 --------------------- .../molecules/layout/page-footer.stories.tsx | 18 +- src/components/molecules/layout/page-footer.tsx | 8 +- .../molecules/layout/page-header.stories.tsx | 38 +- src/components/molecules/layout/page-header.tsx | 6 +- src/components/molecules/meta-list/index.ts | 2 + .../molecules/meta-list/meta-item/index.ts | 1 + .../meta-list/meta-item/meta-item.module.scss | 62 ++++ .../meta-list/meta-item/meta-item.stories.tsx | 108 ++++++ .../meta-list/meta-item/meta-item.test.tsx | 97 +++++ .../molecules/meta-list/meta-item/meta-item.tsx | 90 +++++ .../molecules/meta-list/meta-list.module.scss | 24 ++ .../molecules/meta-list/meta-list.stories.tsx | 70 ++++ .../molecules/meta-list/meta-list.test.tsx | 79 +++++ src/components/molecules/meta-list/meta-list.tsx | 78 ++++ 25 files changed, 736 insertions(+), 540 deletions(-) delete mode 100644 src/components/molecules/layout/card.fixture.ts delete mode 100644 src/components/molecules/layout/meta.module.scss delete mode 100644 src/components/molecules/layout/meta.stories.tsx delete mode 100644 src/components/molecules/layout/meta.test.tsx delete mode 100644 src/components/molecules/layout/meta.tsx create mode 100644 src/components/molecules/meta-list/index.ts create mode 100644 src/components/molecules/meta-list/meta-item/index.ts create mode 100644 src/components/molecules/meta-list/meta-item/meta-item.module.scss create mode 100644 src/components/molecules/meta-list/meta-item/meta-item.stories.tsx create mode 100644 src/components/molecules/meta-list/meta-item/meta-item.test.tsx create mode 100644 src/components/molecules/meta-list/meta-item/meta-item.tsx create mode 100644 src/components/molecules/meta-list/meta-list.module.scss create mode 100644 src/components/molecules/meta-list/meta-list.stories.tsx create mode 100644 src/components/molecules/meta-list/meta-list.test.tsx create mode 100644 src/components/molecules/meta-list/meta-list.tsx (limited to 'src/components/molecules') diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index 70ac3c9..cb0b7eb 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -4,5 +4,6 @@ export * from './collapsible'; export * from './forms'; export * from './images'; export * from './layout'; +export * from './meta-list'; export * from './nav'; export * from './tooltip'; diff --git a/src/components/molecules/layout/card.fixture.ts b/src/components/molecules/layout/card.fixture.ts deleted file mode 100644 index 01fe2e9..0000000 --- a/src/components/molecules/layout/card.fixture.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const cover = { - alt: 'A picture', - height: 480, - src: 'https://picsum.photos/640/480', - width: 640, -}; - -export const id = 'nam'; - -export const meta = { - author: 'Possimus', - thematics: ['Autem', 'Eos'], -}; - -export const tagline = 'Ut rerum incidunt'; - -export const title = 'Alias qui porro'; - -export const url = '/an-existing-url'; diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss index 7a06508..14a5baf 100644 --- a/src/components/molecules/layout/card.module.scss +++ b/src/components/molecules/layout/card.module.scss @@ -1,5 +1,9 @@ @use "../../../styles/abstracts/functions" as fun; +.footer { + margin-top: auto; +} + .wrapper { --scale-up: 1.05; --scale-down: 0.95; diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx index a9545d1..070c978 100644 --- a/src/components/molecules/layout/card.stories.tsx +++ b/src/components/molecules/layout/card.stories.tsx @@ -1,6 +1,6 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { MetaItemData } from '../meta-list'; import { Card } from './card'; -import { cover, id, meta, tagline, title, url } from './card.fixture'; /** * Card - Storybook Meta @@ -119,6 +119,33 @@ export default { const Template: ComponentStory = (args) => ; +const cover = { + alt: 'A picture', + height: 480, + src: 'https://picsum.photos/640/480', + width: 640, +}; + +const id = 'nam'; + +const meta = [ + { id: 'author', label: 'Author', value: 'Possimus' }, + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'autem', value: 'Autem' }, + { id: 'eos', value: 'Eos' }, + ], + }, +] satisfies MetaItemData[]; + +const tagline = 'Ut rerum incidunt'; + +const title = 'Alias qui porro'; + +const url = '/an-existing-url'; + /** * Card Stories - Default */ diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx index c6498b8..b690d4c 100644 --- a/src/components/molecules/layout/card.test.tsx +++ b/src/components/molecules/layout/card.test.tsx @@ -1,37 +1,69 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import type { MetaItemData } from '../meta-list'; import { Card } from './card'; -import { cover, id, meta, tagline, title, url } from './card.fixture'; + +const cover = { + alt: 'A picture', + height: 480, + src: 'https://picsum.photos/640/480', + width: 640, +}; + +const id = 'nam'; + +const meta = [ + { id: 'author', label: 'Author', value: 'Possimus' }, + { + id: 'categories', + label: 'Categories', + value: [ + { id: 'autem', value: 'Autem' }, + { id: 'eos', value: 'Eos' }, + ], + }, +] satisfies MetaItemData[]; + +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 }) + rtlScreen.getByRole('heading', { level: 2, name: title }) ).toBeInTheDocument(); }); it('renders a link to another page', () => { render(); - expect(screen.getByRole('link')).toHaveAttribute('href', url); + expect(rtlScreen.getByRole('link')).toHaveAttribute('href', url); }); it('renders a cover', () => { render( ); - expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + expect(rtlScreen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); }); it('renders a tagline', () => { render( ); - expect(screen.getByText(tagline)).toBeInTheDocument(); + expect(rtlScreen.getByText(tagline)).toBeInTheDocument(); }); it('renders some meta', () => { render(); - expect(screen.getByText(meta.author)).toBeInTheDocument(); + + const metaLabels = meta.map((item) => item.label); + + for (const label of metaLabels) { + expect(rtlScreen.getByText(label)).toBeInTheDocument(); + } }); }); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index c316100..d90cba2 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -1,8 +1,8 @@ import NextImage, { type ImageProps as NextImageProps } from 'next/image'; import type { FC } from 'react'; import { ButtonLink, Figure, Heading, type HeadingLevel } from '../../atoms'; +import { MetaList, type MetaItemData } from '../meta-list'; import styles from './card.module.scss'; -import { Meta, type MetaData } from './meta'; export type CardProps = { /** @@ -20,7 +20,7 @@ export type CardProps = { /** * The card meta. */ - meta?: MetaData; + meta?: MetaItemData[]; /** * The card tagline. */ @@ -73,7 +73,13 @@ export const Card: FC = ({ {tagline ?
{tagline}
: null} {meta ? (
- +
) : null} diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts index e43e664..58d5442 100644 --- a/src/components/molecules/layout/index.ts +++ b/src/components/molecules/layout/index.ts @@ -1,6 +1,5 @@ export * from './card'; export * from './code'; export * from './columns'; -export * from './meta'; export * from './page-footer'; export * from './page-header'; diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss deleted file mode 100644 index 26faac3..0000000 --- a/src/components/molecules/layout/meta.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.list { - .description:not(:first-of-type) { - &::before { - display: inline; - float: left; - content: "/"; - margin-right: var(--itemSpacing); - } - } - - &--stack { - .term { - flex: 0 0 100%; - } - } -} diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx deleted file mode 100644 index 6faa265..0000000 --- a/src/components/molecules/layout/meta.stories.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Meta as MetaComponent, type MetaData } from './meta'; - -/** - * Meta - Storybook Meta - */ -export default { - title: 'Molecules/Layout', - component: MetaComponent, - args: {}, - argTypes: { - data: { - description: 'The page metadata.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - }, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ( - -); - -const data: MetaData = { - publication: { date: '2022-04-09', time: '01:04:00' }, - thematics: [ - - Category 1 - , - - Category 2 - , - ], -}; - -/** - * Layout Stories - Meta - */ -export const Meta = Template.bind({}); -Meta.args = { - data, -}; diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx deleted file mode 100644 index 0635fc3..0000000 --- a/src/components/molecules/layout/meta.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { getFormattedDate } from '../../../utils/helpers'; -import { Meta } from './meta'; - -const data = { - publication: { date: '2022-04-09' }, - thematics: [ - - Category 1 - , - - Category 2 - , - ], -}; - -describe('Meta', () => { - it('format a date string', () => { - render(); - expect( - rtlScreen.getByText(getFormattedDate(data.publication.date)) - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx deleted file mode 100644 index 63909a4..0000000 --- a/src/components/molecules/layout/meta.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import type { FC, ReactNode } from 'react'; -import { useIntl } from 'react-intl'; -import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; -import { - DescriptionList, - type DescriptionListProps, - Link, - Group, - Term, - Description, -} from '../../atoms'; -import styles from './meta.module.scss'; - -export type CustomMeta = { - label: string; - value: ReactNode; -}; - -export type MetaComments = { - /** - * A page title. - */ - about: string; - /** - * The comments count. - */ - count: number; - /** - * Wrap the comments count with a link to the given target. - */ - target?: string; -}; - -export type MetaDate = { - /** - * A date string. Ex: `2022-04-30`. - */ - date: string; - /** - * A time string. Ex: `10:25:59`. - */ - time?: string; - /** - * Wrap the date with a link to the given target. - */ - target?: string; -}; - -export type MetaData = { - /** - * The author name. - */ - author?: string; - /** - * The comments count. - */ - comments?: MetaComments; - /** - * The creation date. - */ - creation?: MetaDate; - /** - * A custom label/value metadata. - */ - custom?: CustomMeta; - /** - * The license name. - */ - license?: string; - /** - * The popularity. - */ - popularity?: string | JSX.Element; - /** - * The publication date. - */ - publication?: MetaDate; - /** - * The estimated reading time. - */ - readingTime?: string | JSX.Element; - /** - * An array of repositories. - */ - repositories?: string[] | JSX.Element[]; - /** - * An array of technologies. - */ - technologies?: string[]; - /** - * An array of thematics. - */ - thematics?: string[] | JSX.Element[]; - /** - * An array of thematics. - */ - topics?: string[] | JSX.Element[]; - /** - * A total number of posts. - */ - total?: number; - /** - * The update date. - */ - update?: MetaDate; - /** - * An url. - */ - website?: string; -}; - -const isCustomMeta = ( - key: keyof MetaData, - _value: unknown -): _value is MetaData['custom'] => key === 'custom'; - -export type MetaProps = Omit & { - /** - * The meta data. - */ - data: MetaData; -}; - -/** - * Meta component - * - * Renders the given metadata. - */ -export const Meta: FC = ({ - className = '', - data, - isInline = false, - ...props -}) => { - const layoutClass = styles[isInline ? 'list--inline' : 'list--stack']; - const listClass = `${styles.list} ${layoutClass} ${className}`; - const intl = useIntl(); - - /** - * Retrieve the item label based on its key. - * - * @param {keyof MetaData} key - The meta key. - * @returns {string} The item label. - */ - const getLabel = (key: keyof MetaData): string => { - switch (key) { - case 'author': - return intl.formatMessage({ - defaultMessage: 'Written by:', - description: 'Meta: author label', - id: 'OI0N37', - }); - case 'comments': - return intl.formatMessage({ - defaultMessage: 'Comments:', - description: 'Meta: comments label', - id: 'jTVIh8', - }); - case 'creation': - return intl.formatMessage({ - defaultMessage: 'Created on:', - description: 'Meta: creation date label', - id: 'b4fdYE', - }); - case 'license': - return intl.formatMessage({ - defaultMessage: 'License:', - description: 'Meta: license label', - id: 'AuGklx', - }); - case 'popularity': - return intl.formatMessage({ - defaultMessage: 'Popularity:', - description: 'Meta: popularity label', - id: 'pWTj2W', - }); - case 'publication': - return intl.formatMessage({ - defaultMessage: 'Published on:', - description: 'Meta: publication date label', - id: 'QGi5uD', - }); - case 'readingTime': - return intl.formatMessage({ - defaultMessage: 'Reading time:', - description: 'Meta: reading time label', - id: 'EbFvsM', - }); - case 'repositories': - return intl.formatMessage({ - defaultMessage: 'Repositories:', - description: 'Meta: repositories label', - id: 'DssFG1', - }); - case 'technologies': - return intl.formatMessage({ - defaultMessage: 'Technologies:', - description: 'Meta: technologies label', - id: 'ADQmDF', - }); - case 'thematics': - return intl.formatMessage({ - defaultMessage: 'Thematics:', - description: 'Meta: thematics label', - id: 'bz53Us', - }); - case 'topics': - return intl.formatMessage({ - defaultMessage: 'Topics:', - description: 'Meta: topics label', - id: 'gJNaBD', - }); - case 'total': - return intl.formatMessage({ - defaultMessage: 'Total:', - description: 'Meta: total label', - id: '92zgdp', - }); - case 'update': - return intl.formatMessage({ - defaultMessage: 'Updated on:', - description: 'Meta: update date label', - id: 'tLC7bh', - }); - case 'website': - return intl.formatMessage({ - defaultMessage: 'Official website:', - description: 'Meta: official website label', - id: 'GRyyfy', - }); - default: - return ''; - } - }; - - /** - * Retrieve a formatted date (and time). - * - * @param {MetaDate} dateTime - A date object. - * @returns {JSX.Element} The formatted date wrapped in a time element. - */ - const getDate = (dateTime: MetaDate): JSX.Element => { - const { date, time, target } = dateTime; - - if (!dateTime.time) { - const isoDate = new Date(`${date}`).toISOString(); - return target ? ( - - - - ) : ( - - ); - } - - const isoDateTime = new Date(`${date}T${time}`).toISOString(); - const dateString = intl.formatMessage( - { - defaultMessage: '{date} at {time}', - description: 'Meta: publication date and time', - id: 'fcHeyC', - }, - { - date: getFormattedDate(dateTime.date), - time: getFormattedTime(`${dateTime.date}T${dateTime.time}`), - } - ); - - return target ? ( - - - - ) : ( - - ); - }; - - /** - * Retrieve the formatted comments count. - * - * @param comments - The comments object. - * @returns {string | JSX.Element} - The comments count. - */ - const getCommentsCount = (comments: MetaComments): string | JSX.Element => { - const { about, count, target } = comments; - const commentsCount = intl.formatMessage( - { - defaultMessage: - '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}} about {title}', - description: 'Meta: comments count', - id: '02rgLO', - }, - { - a11y: (chunks: ReactNode) => ( - {chunks} - ), - commentsCount: count, - title: about, - } - ); - - return target ? ( - {commentsCount as JSX.Element} - ) : ( - (commentsCount as JSX.Element) - ); - }; - - /** - * Retrieve the formatted item value. - * - * @param {keyof MetaData} key - The meta key. - * @param {ValueOf} value - The meta value. - * @returns {string|ReactNode|ReactNode[]} - The formatted value. - */ - const getValue = ( - key: T, - value: MetaData[T] - ): string | ReactNode | ReactNode[] => { - switch (key) { - case 'comments': - return getCommentsCount(value as MetaComments); - case 'creation': - case 'publication': - case 'update': - return getDate(value as MetaDate); - case 'total': - return intl.formatMessage( - { - defaultMessage: - '{postsCount, plural, =0 {No articles} one {# article} other {# articles}}', - description: 'BlogPage: posts count meta', - id: 'OF5cPz', - }, - { postsCount: value as number } - ); - case 'website': - return typeof value === 'string' ? ( - - {value} - - ) : null; - default: - return value as string | ReactNode | ReactNode[]; - } - }; - - /** - * Transform the metadata to description list item format. - * - * @param {MetaData} items - The meta. - * @returns {DescriptionListItem[]} The formatted description list items. - */ - const getItems = (items: MetaData) => { - const entries = Object.entries(items) as [ - keyof MetaData, - MetaData[keyof MetaData], - ][]; - const listItems = entries.map(([key, meta]) => { - if (!meta) return null; - - return ( - - - {isCustomMeta(key, meta) ? meta.label : getLabel(key)} - - {Array.isArray(meta) ? ( - meta.map((singleMeta, index) => ( - /* eslint-disable-next-line react/no-array-index-key -- Unsafe, - * but also temporary. This component should be removed or - * refactored. */ - - {isCustomMeta(key, singleMeta) - ? singleMeta - : getValue(key, singleMeta)} - - )) - ) : ( - - {isCustomMeta(key, meta) ? meta.value : getValue(key, meta)} - - )} - - ); - }); - - return listItems; - }; - - return ( - - {getItems(data)} - - ); -}; diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx index 8e991a4..48c8c17 100644 --- a/src/components/molecules/layout/page-footer.stories.tsx +++ b/src/components/molecules/layout/page-footer.stories.tsx @@ -1,5 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { MetaData } from './meta'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { PageFooter as PageFooterComponent } from './page-footer'; /** @@ -40,16 +39,17 @@ const Template: ComponentStory = (args) => ( ); -const meta: MetaData = { - custom: { +const meta = [ + { + id: 'more-about', label: 'More posts about:', - value: [ - + value: ( + Topic name - , - ], + + ), }, -}; +]; /** * Page Footer Stories - With meta diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx index 375cbc4..a93fced 100644 --- a/src/components/molecules/layout/page-footer.tsx +++ b/src/components/molecules/layout/page-footer.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react'; import { Footer, type FooterProps } from '../../atoms'; -import { Meta, type MetaData } from './meta'; +import { MetaList, type MetaItemData } from '../meta-list'; export type PageFooterProps = Omit & { /** * The footer metadata. */ - meta?: MetaData; + meta?: MetaItemData[]; }; /** @@ -15,5 +15,7 @@ export type PageFooterProps = Omit & { * Render a footer to display page meta. */ export const PageFooter: FC = ({ meta, ...props }) => ( -
{meta ? : null}
+
+ {meta ? : null} +
); diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx index ea943bf..54d5fe8 100644 --- a/src/components/molecules/layout/page-header.stories.tsx +++ b/src/components/molecules/layout/page-header.stories.tsx @@ -1,4 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { PageHeader } from './page-header'; /** @@ -62,17 +62,31 @@ const Template: ComponentStory = (args) => ( ); -const meta = { - publication: { date: '2022-04-09' }, - thematics: [ - - Category 1 - , - - Category 2 - , - ], -}; +const meta = [ + { id: 'publication-date', label: 'Published on:', value: '2022-04-09' }, + { + id: 'thematics', + label: 'Thematics:', + value: [ + { + id: 'cat-1', + value: ( + + Category 1 + + ), + }, + { + id: 'cat-2', + value: ( + + Category 2 + + ), + }, + ], + }, +]; /** * Page Header Stories - Default diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx index b727cc1..ea0dd2c 100644 --- a/src/components/molecules/layout/page-header.tsx +++ b/src/components/molecules/layout/page-header.tsx @@ -1,6 +1,6 @@ import type { FC, ReactNode } from 'react'; import { Header, Heading } from '../../atoms'; -import { Meta, type MetaData } from './meta'; +import { MetaList, type MetaItemData } from '../meta-list'; import styles from './page-header.module.scss'; export type PageHeaderProps = { @@ -15,7 +15,7 @@ export type PageHeaderProps = { /** * The page metadata. */ - meta?: MetaData; + meta?: MetaItemData[]; /** * The page title. */ @@ -56,7 +56,7 @@ export const PageHeader: FC = ({ {title} {meta ? ( - + ) : null} {intro ? getIntro() : null} diff --git a/src/components/molecules/meta-list/index.ts b/src/components/molecules/meta-list/index.ts new file mode 100644 index 0000000..93f437d --- /dev/null +++ b/src/components/molecules/meta-list/index.ts @@ -0,0 +1,2 @@ +export * from './meta-item'; +export * from './meta-list'; diff --git a/src/components/molecules/meta-list/meta-item/index.ts b/src/components/molecules/meta-list/meta-item/index.ts new file mode 100644 index 0000000..47795de --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/index.ts @@ -0,0 +1 @@ +export * from './meta-item'; diff --git a/src/components/molecules/meta-list/meta-item/meta-item.module.scss b/src/components/molecules/meta-list/meta-item/meta-item.module.scss new file mode 100644 index 0000000..a1c2d47 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.module.scss @@ -0,0 +1,62 @@ +@use "../../../../styles/abstracts/functions" as fun; + +.item { + column-gap: var(--spacing-2xs); + align-content: baseline; + + &--bordered-values { + row-gap: var(--spacing-2xs); + } + + &--centered { + margin-inline: auto; + text-align: center; + place-items: center; + justify-content: center; + } + + &--inlined { + align-items: first baseline; + } + + &--inlined-values { + flex-flow: row wrap; + } + + &:not(#{&}--bordered-values) { + row-gap: fun.convert-px(3); + } +} + +.value { + width: fit-content; + height: fit-content; + color: var(--color-fg); + font-weight: 400; +} + +:where(.item--bordered-values) { + .value { + padding: fun.convert-px(2) var(--spacing-2xs); + border: fun.convert-px(1) solid var(--color-primary-darker); + } +} + +:where(.item--inlined-values) { + .label { + flex: 1 0 100%; + } +} + +/* It's an arbitrary choice. When there is only one meta item (like on small + * cards) removing the width can mess up the layout. However, must of the times + * when there are multiples items, we need to remove the width especially if we + * want to use `isCentered` prop. */ +:where(.item--inlined-values:not(:only-of-type)) { + .label { + /* We need to remove its width to avoid an extra space and make the + * container width fit its contents. However the label should be smaller + * than the values to avoid unexpected behavior with layout. */ + width: 0; + } +} diff --git a/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx new file mode 100644 index 0000000..3ddb8f1 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx @@ -0,0 +1,108 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Link } from '../../../atoms'; +import { MetaItem } from './meta-item'; + +/** + * MetaItem - Storybook Meta + */ +export default { + title: 'Molecules/MetaList/Item', + component: MetaItem, + argTypes: { + label: { + control: { + type: 'text', + }, + description: 'The item label.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +/** + * MetaItem Stories - SingleValue + */ +export const SingleValue = Template.bind({}); +SingleValue.args = { + label: 'Comments', + value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValues + */ +export const MultipleValues = Template.bind({}); +MultipleValues.args = { + label: 'Tags', + value: [ + { id: 'tag1', value: Tag 1 }, + { id: 'tag2', value: Tag 2 }, + ], +}; + +/** + * MetaItem Stories - SingleValueBordered + */ +export const SingleValueBordered = Template.bind({}); +SingleValueBordered.args = { + hasBorderedValues: true, + label: 'Comments', + value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValuesBordered + */ +export const MultipleValuesBordered = Template.bind({}); +MultipleValuesBordered.args = { + hasBorderedValues: true, + label: 'Tags', + value: [ + { id: 'tag1', value: Tag 1 }, + { id: 'tag2', value: Tag 2 }, + ], +}; + +/** + * MetaItem Stories - SingleValueInlined + */ +export const SingleValueInlined = Template.bind({}); +SingleValueInlined.args = { + isInline: true, + label: 'Comments', + value: 'No comments', +}; + +/** + * MetaItem Stories - MultipleValuesInlined + */ +export const MultipleValuesInlined = Template.bind({}); +MultipleValuesInlined.args = { + isInline: true, + label: 'Tags', + value: [ + { id: 'tag1', value: Tag 1 }, + { id: 'tag2', value: Tag 2 }, + ], +}; + +/** + * MetaItem Stories - InlinedValues + */ +export const InlinedValues = Template.bind({}); +InlinedValues.args = { + hasInlinedValues: true, + label: 'Tags', + value: [ + { id: 'tag1', value: Tag 1 }, + { id: 'tag2', value: A long tag 2 }, + { id: 'tag3', value: Tag 3 }, + ], +}; diff --git a/src/components/molecules/meta-list/meta-item/meta-item.test.tsx b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx new file mode 100644 index 0000000..629c4b2 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { MetaItem } from './meta-item'; + +describe('MetaItem', () => { + it('renders a label and a value', () => { + const label = 'iusto'; + const value = 'autem'; + + render( +
+ +
+ ); + + expect(rtlScreen.getByRole('term')).toHaveTextContent(label); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(value); + }); + + it('can render a label with multiple values', () => { + const label = 'iusto'; + const values = [ + { id: 'autem', value: 'autem' }, + { id: 'quisquam', value: 'aut' }, + { id: 'molestias', value: 'voluptatem' }, + ]; + + render( +
+ +
+ ); + + expect(rtlScreen.getByRole('term')).toHaveTextContent(label); + expect(rtlScreen.getAllByRole('definition')).toHaveLength(values.length); + }); + + it('can render a centered group of label and values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( +
+ +
+ ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--centered' + ); + }); + + it('can render an inlined group of label and values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( +
+ +
+ ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--inlined' + ); + }); + + it('can render a group of label and bordered values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( +
+ +
+ ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--bordered-values' + ); + }); + + it('can render a group of label and inlined values', () => { + const label = 'iusto'; + const value = 'autem'; + + render( +
+ +
+ ); + + expect(rtlScreen.getByRole('term').parentElement).toHaveClass( + 'item--inlined-values' + ); + }); +}); diff --git a/src/components/molecules/meta-list/meta-item/meta-item.tsx b/src/components/molecules/meta-list/meta-item/meta-item.tsx new file mode 100644 index 0000000..c5223c2 --- /dev/null +++ b/src/components/molecules/meta-list/meta-item/meta-item.tsx @@ -0,0 +1,90 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + type ReactNode, + forwardRef, +} from 'react'; +import { Description, Group, type GroupProps, Term } from '../../../atoms'; +import styles from './meta-item.module.scss'; + +export type MetaValue = string | ReactElement; + +export type MetaValues = { + id: string; + value: MetaValue; +}; + +export type MetaItemProps = Omit & { + /** + * Should the values be bordered? + * + * @default false + */ + hasBorderedValues?: boolean; + /** + * Should the values be inlined? + * + * @warning If you use it make sure the value is larger than the label. It + * could mess up your design since we are removing the label width. + * + * @default false + */ + hasInlinedValues?: boolean; + /** + * Should the label and values be centered? + * + * @default false + */ + isCentered?: boolean; + /** + * The item label. + */ + label: ReactNode; + /** + * The item value or values. + */ + value: MetaValue | MetaValues[]; +}; + +const MetaItemWithRef: ForwardRefRenderFunction< + HTMLDivElement, + MetaItemProps +> = ( + { + className = '', + hasBorderedValues = false, + hasInlinedValues = false, + isCentered = false, + isInline = false, + label, + value, + ...props + }, + ref +) => { + const itemClass = [ + styles.item, + styles[hasBorderedValues ? 'item--bordered-values' : ''], + styles[hasInlinedValues ? 'item--inlined-values' : ''], + styles[isCentered ? 'item--centered' : ''], + styles[isInline ? 'item--inlined' : 'item--stacked'], + className, + ].join(' '); + + return ( + + {label} + {Array.isArray(value) ? ( + value.map((item) => ( + + {item.value} + + )) + ) : ( + {value} + )} + + ); +}; + +export const MetaItem = forwardRef(MetaItemWithRef); diff --git a/src/components/molecules/meta-list/meta-list.module.scss b/src/components/molecules/meta-list/meta-list.module.scss new file mode 100644 index 0000000..5570f4c --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.module.scss @@ -0,0 +1,24 @@ +.list { + display: grid; + width: fit-content; + height: fit-content; + + &--centered { + margin-inline: auto; + justify-items: center; + } + + &--inlined { + grid-auto-flow: column; + grid-template-columns: repeat( + auto-fit, + min(calc(100vw - (var(--spacing-md) * 2)), 1fr) + ); + column-gap: clamp(var(--spacing-lg), 3vw, var(--spacing-3xl)); + row-gap: clamp(var(--spacing-sm), 3vw, var(--spacing-md)); + } + + &--stacked { + gap: var(--spacing-2xs); + } +} diff --git a/src/components/molecules/meta-list/meta-list.stories.tsx b/src/components/molecules/meta-list/meta-list.stories.tsx new file mode 100644 index 0000000..463ec96 --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.stories.tsx @@ -0,0 +1,70 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Link } from '../../atoms'; +import { type MetaItemData, MetaList } from './meta-list'; + +/** + * MetaList - Storybook Meta + */ +export default { + title: 'Molecules/MetaList', + component: MetaList, + argTypes: { + items: { + description: 'The meta items.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +const items: MetaItemData[] = [ + { id: 'comments', label: 'Comments', value: 'No comments.' }, + { + id: 'category', + label: 'Category', + value: Cat 1, + }, + { + id: 'tags', + label: 'Tags', + value: [ + { id: 'tag1', value: Tag 1 }, + { id: 'tag2', value: Tag 2 }, + ], + }, + { + hasBorderedValues: true, + hasInlinedValues: true, + id: 'technologies', + label: 'Technologies', + value: [ + { id: 'techno1', value: 'HTML' }, + { id: 'techno2', value: 'CSS' }, + { id: 'techno3', value: 'Javascript' }, + ], + }, +]; + +/** + * MetaList Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + items, +}; + +/** + * MetaList Stories - Inlined + */ +export const Inlined = Template.bind({}); +Inlined.args = { + isInline: true, + items, +}; diff --git a/src/components/molecules/meta-list/meta-list.test.tsx b/src/components/molecules/meta-list/meta-list.test.tsx new file mode 100644 index 0000000..cc4d2fa --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.test.tsx @@ -0,0 +1,79 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { type MetaItemData, MetaList } from './meta-list'; + +describe('MetaList', () => { + it('renders a list of meta items', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(); + + expect(rtlScreen.getAllByRole('term')).toHaveLength(items.length); + expect(rtlScreen.getAllByRole('definition')).toHaveLength(items.length); + }); + + it('can render a centered list of meta items', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement?.parentElement).toHaveClass('list--centered'); + }); + + it('can render an inlined list of meta items', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement?.parentElement).toHaveClass('list--inlined'); + }); + + it('can render a list of meta items with bordered values', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement).toHaveClass('item--bordered-values'); + }); + + it('can render a list of meta items with inlined values', () => { + const items: MetaItemData[] = [ + { id: 'item1', label: 'Item 1', value: 'Value 1' }, + { id: 'item2', label: 'Item 2', value: 'Value 2' }, + { id: 'item3', label: 'Item 3', value: 'Value 3' }, + { id: 'item4', label: 'Item 4', value: 'Value 4' }, + ]; + + render(); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms[0].parentElement).toHaveClass('item--inlined-values'); + }); +}); diff --git a/src/components/molecules/meta-list/meta-list.tsx b/src/components/molecules/meta-list/meta-list.tsx new file mode 100644 index 0000000..288fd9a --- /dev/null +++ b/src/components/molecules/meta-list/meta-list.tsx @@ -0,0 +1,78 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { DescriptionList, type DescriptionListProps } from '../../atoms'; +import { MetaItem, type MetaItemProps } from './meta-item'; +import styles from './meta-list.module.scss'; + +export type MetaItemData = Pick< + MetaItemProps, + | 'hasBorderedValues' + | 'hasInlinedValues' + | 'isCentered' + | 'isInline' + | 'label' + | 'value' +> & { + id: string; +}; + +export type MetaListProps = Omit & + Pick & { + /** + * Should the items be inlined? + * + * @default false + */ + hasInlinedItems?: boolean; + /** + * Should the meta be centered? + * + * @default false + */ + isCentered?: boolean; + /** + * The meta items. + */ + items: MetaItemData[]; + }; + +const MetaListWithRef: ForwardRefRenderFunction< + HTMLDListElement, + MetaListProps +> = ( + { + className = '', + hasBorderedValues = false, + hasInlinedItems = false, + hasInlinedValues = false, + isCentered = false, + isInline = false, + items, + ...props + }, + ref +) => { + const listClass = [ + styles.list, + styles[isCentered ? 'list--centered' : ''], + styles[isInline ? 'list--inlined' : 'list--stacked'], + className, + ].join(' '); + + return ( + + {items.map(({ id, ...item }) => ( + + ))} + + ); +}; + +export const MetaList = forwardRef(MetaListWithRef); -- cgit v1.2.3