From edea15c845b33848b7b4f63616841e675b74d572 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Fri, 15 Apr 2022 22:20:09 +0200 Subject: chore: add an Overview component --- src/components/organisms/layout/overview.test.tsx | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/components/organisms/layout/overview.test.tsx (limited to 'src/components/organisms/layout/overview.test.tsx') diff --git a/src/components/organisms/layout/overview.test.tsx b/src/components/organisms/layout/overview.test.tsx new file mode 100644 index 0000000..0738d3f --- /dev/null +++ b/src/components/organisms/layout/overview.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@test-utils'; +import Overview from './overview'; + +const cover = { + alt: 'Incidunt unde quam', + height: 480, + src: 'http://placeimg.com/640/480/cats', + width: 640, +}; + +const meta = { + publication: { name: 'Illo ut odio:', value: 'Sequi et excepturi' }, + update: { + name: 'Perspiciatis vel laudantium:', + value: 'Dignissimos ratione veritatis', + }, +}; + +describe('Overview', () => { + it('renders some meta', () => { + render(); + expect(screen.getByText(meta['publication'].name)).toBeInTheDocument(); + }); + + it('renders a cover', () => { + render(); + expect(screen.getByRole('img', { name: cover.alt })).toBeInTheDocument(); + }); +}); -- cgit v1.2.3 From 0d59a6d2995b4119865271ed1908ede0bb96497c Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 9 May 2022 18:19:38 +0200 Subject: refactor: rewrite DescriptionList and Meta components The meta can have different layout. The previous implementation was not enough to easily change the layout. Also, I prefer to restrict the meta types and it prevents me to repeat myself for the labels. --- .storybook/preview.js | 9 + .../atoms/lists/description-list-item.module.scss | 38 +++ .../atoms/lists/description-list-item.stories.tsx | 132 +++++++++ .../atoms/lists/description-list-item.test.tsx | 17 ++ .../atoms/lists/description-list-item.tsx | 73 +++++ .../atoms/lists/description-list.module.scss | 49 +--- .../atoms/lists/description-list.stories.tsx | 80 +++++- .../atoms/lists/description-list.test.tsx | 8 +- src/components/atoms/lists/description-list.tsx | 71 ++--- src/components/molecules/layout/card.module.scss | 39 ++- src/components/molecules/layout/card.stories.tsx | 38 ++- src/components/molecules/layout/card.test.tsx | 13 +- src/components/molecules/layout/card.tsx | 16 +- src/components/molecules/layout/meta.module.scss | 18 -- src/components/molecules/layout/meta.stories.tsx | 57 ++-- src/components/molecules/layout/meta.test.tsx | 22 +- src/components/molecules/layout/meta.tsx | 304 +++++++++++++++++++-- .../molecules/layout/page-footer.stories.tsx | 12 +- src/components/molecules/layout/page-footer.tsx | 4 +- .../molecules/layout/page-header.stories.tsx | 34 ++- src/components/molecules/layout/page-header.tsx | 25 +- .../organisms/layout/cards-list.stories.tsx | 16 +- .../organisms/layout/cards-list.test.tsx | 26 +- .../organisms/layout/comment.module.scss | 12 +- src/components/organisms/layout/comment.test.tsx | 2 +- src/components/organisms/layout/comment.tsx | 83 +++--- .../organisms/layout/overview.module.scss | 34 ++- .../organisms/layout/overview.stories.tsx | 29 +- src/components/organisms/layout/overview.test.tsx | 19 +- src/components/organisms/layout/overview.tsx | 41 ++- .../organisms/layout/posts-list.stories.tsx | 80 ++---- .../organisms/layout/posts-list.test.tsx | 76 ++---- src/components/organisms/layout/posts-list.tsx | 2 +- .../organisms/layout/summary.module.scss | 13 + .../organisms/layout/summary.stories.tsx | 33 +-- src/components/organisms/layout/summary.test.tsx | 27 +- src/components/organisms/layout/summary.tsx | 33 +-- .../templates/page/page-layout.stories.tsx | 107 +++----- src/components/templates/page/page-layout.tsx | 14 +- src/pages/contact.tsx | 4 +- src/pages/cv.tsx | 26 +- src/pages/index.tsx | 12 +- src/pages/mentions-legales.tsx | 26 +- src/utils/helpers/dates.ts | 15 - 44 files changed, 1182 insertions(+), 607 deletions(-) create mode 100644 src/components/atoms/lists/description-list-item.module.scss create mode 100644 src/components/atoms/lists/description-list-item.stories.tsx create mode 100644 src/components/atoms/lists/description-list-item.test.tsx create mode 100644 src/components/atoms/lists/description-list-item.tsx (limited to 'src/components/organisms/layout/overview.test.tsx') diff --git a/.storybook/preview.js b/.storybook/preview.js index 4bec022..344df2a 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,4 +1,5 @@ import '@styles/globals.scss'; +import { IntlProvider } from 'react-intl'; export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, @@ -9,3 +10,11 @@ export const parameters = { }, }, }; + +export const decorators = [ + (Story) => ( + + + + ), +]; diff --git a/src/components/atoms/lists/description-list-item.module.scss b/src/components/atoms/lists/description-list-item.module.scss new file mode 100644 index 0000000..60cad57 --- /dev/null +++ b/src/components/atoms/lists/description-list-item.module.scss @@ -0,0 +1,38 @@ +.term { + color: var(--color-fg-light); + font-weight: 600; +} + +.description { + margin: 0; + word-break: break-all; +} + +.wrapper { + display: flex; + width: fit-content; + + &--has-separator { + .description:not(:first-of-type) { + &::before { + content: "/\0000a0"; + } + } + } + + &--inline, + &--inline-values { + flex-flow: row wrap; + column-gap: var(--spacing-2xs); + } + + &--inline-values { + .term { + flex: 1 1 100%; + } + } + + &--stacked { + flex-flow: column wrap; + } +} diff --git a/src/components/atoms/lists/description-list-item.stories.tsx b/src/components/atoms/lists/description-list-item.stories.tsx new file mode 100644 index 0000000..e05493c --- /dev/null +++ b/src/components/atoms/lists/description-list-item.stories.tsx @@ -0,0 +1,132 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import DescriptionListItemComponent from './description-list-item'; + +export default { + title: 'Atoms/Typography/Lists/DescriptionList/Item', + component: DescriptionListItemComponent, + args: { + layout: 'stacked', + withSeparator: false, + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list item wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + descriptionClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list item description.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The item label.', + type: { + name: 'string', + required: true, + }, + }, + layout: { + control: { + type: 'select', + }, + description: 'The item layout.', + options: ['inline', 'inline-values', 'stacked'], + table: { + category: 'Styles', + defaultValue: { summary: 'stacked' }, + }, + type: { + name: 'string', + required: false, + }, + }, + termClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list item term.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + value: { + description: 'The item value.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + withSeparator: { + control: { + type: 'boolean', + }, + description: 'Add a slash as separator between multiple values.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = ( + args +) => ; + +export const SingleValueStacked = Template.bind({}); +SingleValueStacked.args = { + label: 'Recusandae vitae tenetur', + value: ['praesentium'], + layout: 'stacked', +}; + +export const SingleValueInlined = Template.bind({}); +SingleValueInlined.args = { + label: 'Recusandae vitae tenetur', + value: ['praesentium'], + layout: 'inline', +}; + +export const MultipleValuesStacked = Template.bind({}); +MultipleValuesStacked.args = { + label: 'Recusandae vitae tenetur', + value: ['praesentium', 'voluptate', 'tempore'], + layout: 'stacked', +}; + +export const MultipleValuesInlined = Template.bind({}); +MultipleValuesInlined.args = { + label: 'Recusandae vitae tenetur', + value: ['praesentium', 'voluptate', 'tempore'], + layout: 'inline-values', + withSeparator: true, +}; diff --git a/src/components/atoms/lists/description-list-item.test.tsx b/src/components/atoms/lists/description-list-item.test.tsx new file mode 100644 index 0000000..730a52f --- /dev/null +++ b/src/components/atoms/lists/description-list-item.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '@test-utils'; +import DescriptionListItem from './description-list-item'; + +const itemLabel = 'Repellendus corporis facilis'; +const itemValue = ['quos', 'eum']; + +describe('DescriptionListItem', () => { + it('renders a couple of label', () => { + render(); + expect(screen.getByRole('term')).toHaveTextContent(itemLabel); + }); + + it('renders the right number of values', () => { + render(); + expect(screen.getAllByRole('definition')).toHaveLength(itemValue.length); + }); +}); diff --git a/src/components/atoms/lists/description-list-item.tsx b/src/components/atoms/lists/description-list-item.tsx new file mode 100644 index 0000000..9505d01 --- /dev/null +++ b/src/components/atoms/lists/description-list-item.tsx @@ -0,0 +1,73 @@ +import { FC, ReactNode, useId } from 'react'; +import styles from './description-list-item.module.scss'; + +export type ItemLayout = 'inline' | 'inline-values' | 'stacked'; + +export type DescriptionListItemProps = { + /** + * Set additional classnames to the list item wrapper. + */ + className?: string; + /** + * Set additional classnames to the list item description. + */ + descriptionClassName?: string; + /** + * The item label. + */ + label: string; + /** + * The item layout. + */ + layout?: ItemLayout; + /** + * Set additional classnames to the list item term. + */ + termClassName?: string; + /** + * The item value. + */ + value: ReactNode | ReactNode[]; + /** + * If true, use a slash to delimitate multiple values. + */ + withSeparator?: boolean; +}; + +/** + * DescriptionListItem component + * + * Render a couple of dt/dd wrapped in a div. + */ +const DescriptionListItem: FC = ({ + className = '', + descriptionClassName = '', + label, + termClassName = '', + value, + layout = 'stacked', + withSeparator = false, +}) => { + const id = useId(); + const layoutStyles = styles[`wrapper--${layout}`]; + const separatorStyles = withSeparator ? styles['wrapper--has-separator'] : ''; + const itemValues = Array.isArray(value) ? value : [value]; + + return ( +
+
{label}
+ {itemValues.map((currentValue, index) => ( +
+ {currentValue} +
+ ))} +
+ ); +}; + +export default DescriptionListItem; diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss index caa2711..9e913d4 100644 --- a/src/components/atoms/lists/description-list.module.scss +++ b/src/components/atoms/lists/description-list.module.scss @@ -2,53 +2,16 @@ .list { display: flex; - flex-flow: column wrap; - gap: var(--spacing-2xs); + column-gap: var(--spacing-md); + row-gap: var(--spacing-2xs); margin: 0; - &__term { - flex: 0 0 max-content; - color: var(--color-fg-light); - font-weight: 600; + &--inline { + flex-flow: row wrap; + align-items: baseline; } - &__description { - flex: 0 0 auto; - margin: 0; - } - - &__item { - display: flex; - } - - &--inline &__item { - flex-flow: column wrap; - - @include mix.media("screen") { - @include mix.dimensions("xs") { - flex-flow: row wrap; - gap: var(--spacing-2xs); - - .list__description:not(:first-of-type) { - &::before { - content: "/"; - 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 { + &--column { 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 43ee66e..347fd78 100644 --- a/src/components/atoms/lists/description-list.stories.tsx +++ b/src/components/atoms/lists/description-list.stories.tsx @@ -1,16 +1,15 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; -import DescriptionListComponent, { - DescriptionListItem, -} from './description-list'; +import DescriptionList, { DescriptionListItem } from './description-list'; /** * DescriptionList - Storybook Meta */ export default { - title: 'Atoms/Typography/Lists', - component: DescriptionListComponent, + title: 'Atoms/Typography/Lists/DescriptionList', + component: DescriptionList, args: { layout: 'column', + withSeparator: false, }, argTypes: { className: { @@ -26,6 +25,19 @@ export default { required: false, }, }, + groupClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the item wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, items: { control: { type: null, @@ -37,6 +49,19 @@ export default { value: {}, }, }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, layout: { control: { type: 'select', @@ -52,28 +77,55 @@ export default { required: false, }, }, + valueClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the value wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + withSeparator: { + control: { + type: 'boolean', + }, + description: 'Add a slash as separator between multiple values.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, }, -} as ComponentMeta; +} as ComponentMeta; -const Template: ComponentStory = (args) => ( - +const Template: ComponentStory = (args) => ( + ); const items: DescriptionListItem[] = [ - { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] }, - { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] }, + { id: 'term-1', label: 'Term 1:', value: ['Value for term 1'] }, + { id: 'term-2', label: 'Term 2:', value: ['Value for term 2'] }, { id: 'term-3', - term: 'Term 3:', + label: 'Term 3:', value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'], }, - { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] }, + { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] }, ]; /** * List Stories - Description list */ -export const DescriptionList = Template.bind({}); -DescriptionList.args = { +export const List = Template.bind({}); +List.args = { items, }; diff --git a/src/components/atoms/lists/description-list.test.tsx b/src/components/atoms/lists/description-list.test.tsx index d3f7045..83e405f 100644 --- a/src/components/atoms/lists/description-list.test.tsx +++ b/src/components/atoms/lists/description-list.test.tsx @@ -2,14 +2,14 @@ import { render } from '@test-utils'; import DescriptionList, { DescriptionListItem } from './description-list'; const items: DescriptionListItem[] = [ - { id: 'term-1', term: 'Term 1:', value: ['Value for term 1'] }, - { id: 'term-2', term: 'Term 2:', value: ['Value for term 2'] }, + { id: 'term-1', label: 'Term 1:', value: ['Value for term 1'] }, + { id: 'term-2', label: 'Term 2:', value: ['Value for term 2'] }, { id: 'term-3', - term: 'Term 3:', + label: 'Term 3:', value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'], }, - { id: 'term-4', term: 'Term 4:', value: ['Value for term 4'] }, + { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] }, ]; describe('DescriptionList', () => { diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx index a60a6a1..a8e2d53 100644 --- a/src/components/atoms/lists/description-list.tsx +++ b/src/components/atoms/lists/description-list.tsx @@ -1,4 +1,7 @@ import { FC } from 'react'; +import DescriptionListItem, { + type DescriptionListItemProps, +} from './description-list-item'; import styles from './description-list.module.scss'; export type DescriptionListItem = { @@ -7,13 +10,17 @@ export type DescriptionListItem = { */ id: string; /** - * A list term. + * The list item layout. */ - term: string; + layout?: DescriptionListItemProps['layout']; /** - * An array of values for the list term. + * A list label. */ - value: any[]; + label: DescriptionListItemProps['label']; + /** + * An array of values for the list item. + */ + value: DescriptionListItemProps['value']; }; export type DescriptionListProps = { @@ -21,10 +28,6 @@ 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. */ @@ -34,17 +37,21 @@ export type DescriptionListProps = { */ items: DescriptionListItem[]; /** - * The list items layout. Default: column. + * Set additional classnames to the `dt` element. + */ + labelClassName?: string; + /** + * The list layout. Default: column. */ layout?: 'inline' | 'column'; /** - * Define if the layout should automatically create rows/columns. + * Set additional classnames to the `dd` element. */ - responsiveLayout?: boolean; + valueClassName?: string; /** - * Set additional classnames to the `dt` element. + * If true, use a slash to delimitate multiple values. */ - termClassName?: string; + withSeparator?: DescriptionListItemProps['withSeparator']; }; /** @@ -54,44 +61,40 @@ export type DescriptionListProps = { */ const DescriptionList: FC = ({ className = '', - descriptionClassName = '', groupClassName = '', items, + labelClassName = '', layout = 'column', - responsiveLayout = false, - termClassName = '', + valueClassName = '', + withSeparator, }) => { const layoutModifier = `list--${layout}`; - const responsiveModifier = responsiveLayout ? 'list--responsive' : ''; /** - * Retrieve the description list items wrapped in a div element. + * Retrieve the description list items. * - * @param {DescriptionListItem[]} listItems - An array of term and description couples. + * @param {DescriptionListItem[]} listItems - An array of items. * @returns {JSX.Element[]} The description list items. */ const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => { - return listItems.map(({ id, term, value }) => { + return listItems.map(({ id, layout: itemLayout, label, value }) => { return ( -
-
{term}
- {value.map((currentValue, index) => ( -
- {currentValue} -
- ))} -
+ ); }); }; return ( -
+
{getItems(items)}
); diff --git a/src/components/molecules/layout/card.module.scss b/src/components/molecules/layout/card.module.scss index d5b9836..3af8f24 100644 --- a/src/components/molecules/layout/card.module.scss +++ b/src/components/molecules/layout/card.module.scss @@ -52,24 +52,35 @@ margin-bottom: var(--spacing-md); } - .items { - flex-flow: row wrap; - place-content: center; - gap: var(--spacing-2xs); - } + .meta { + &__item { + flex-flow: row wrap; + place-content: center; + gap: var(--spacing-2xs); + margin: auto; + } + + &__label { + flex: 0 0 100%; + } - .term { - flex: 0 0 100%; + &__value { + 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; + } + } } - .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; + &:not(:disabled):focus { + text-decoration: none; - &::before { - display: none; + .title { + text-decoration: underline solid var(--color-primary) 0.3ex; } } } diff --git a/src/components/molecules/layout/card.stories.tsx b/src/components/molecules/layout/card.stories.tsx index ed78d00..2e99bbb 100644 --- a/src/components/molecules/layout/card.stories.tsx +++ b/src/components/molecules/layout/card.stories.tsx @@ -8,6 +8,19 @@ export default { title: 'Molecules/Layout/Card', component: Card, argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the card wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, cover: { description: 'The card cover data (src, dimensions, alternative text).', table: { @@ -19,6 +32,21 @@ export default { value: {}, }, }, + coverFit: { + control: { + type: 'select', + }, + description: 'The cover fit.', + options: ['contain', 'cover', 'fill', 'scale-down'], + table: { + category: 'Options', + defaultValue: { summary: 'cover' }, + }, + type: { + name: 'string', + required: false, + }, + }, meta: { description: 'The card metadata (a publication date for example).', table: { @@ -88,13 +116,9 @@ const cover = { unoptimized: true, }; -const meta = [ - { - id: 'an-id', - term: 'Voluptates', - value: ['Autem', 'Eos'], - }, -]; +const meta = { + thematics: ['Autem', 'Eos'], +}; /** * Card Stories - Default diff --git a/src/components/molecules/layout/card.test.tsx b/src/components/molecules/layout/card.test.tsx index 404bc7a..07c01e9 100644 --- a/src/components/molecules/layout/card.test.tsx +++ b/src/components/molecules/layout/card.test.tsx @@ -8,13 +8,10 @@ const cover = { width: 640, }; -const meta = [ - { - id: 'an-id', - term: 'Voluptates', - value: ['Autem', 'Eos'], - }, -]; +const meta = { + author: 'Possimus', + thematics: ['Autem', 'Eos'], +}; const tagline = 'Ut rerum incidunt'; @@ -47,6 +44,6 @@ describe('Card', () => { it('renders some meta', () => { render(); - expect(screen.getByText(meta[0].term)).toBeInTheDocument(); + expect(screen.getByText(meta.author)).toBeInTheDocument(); }); }); diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index 89f100e..e416bd5 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -1,13 +1,11 @@ import ButtonLink from '@components/atoms/buttons/button-link'; import Heading, { type HeadingLevel } from '@components/atoms/headings/heading'; -import DescriptionList, { - type DescriptionListItem, -} from '@components/atoms/lists/description-list'; import { FC } from 'react'; import ResponsiveImage, { type ResponsiveImageProps, } from '../images/responsive-image'; import styles from './card.module.scss'; +import Meta, { type MetaData } from './meta'; export type Cover = { /** @@ -44,7 +42,7 @@ export type CardProps = { /** * The card meta. */ - meta?: DescriptionListItem[]; + meta?: MetaData; /** * The card tagline. */ @@ -96,13 +94,13 @@ const Card: FC = ({
{tagline}
{meta && (
-
)} diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss index 0485545..4194a6e 100644 --- a/src/components/molecules/layout/meta.module.scss +++ b/src/components/molecules/layout/meta.module.scss @@ -1,23 +1,5 @@ @use "@styles/abstracts/mixins" as mix; -.list { - display: grid; - grid-template-columns: repeat(1, minmax(0, 1fr)); - gap: var(--spacing-sm); - - @include mix.media("screen") { - @include mix.dimensions("2xs") { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - @include mix.dimensions("sm") { - display: flex; - flex-flow: column nowrap; - gap: var(--spacing-2xs); - } - } -} - .value { word-break: break-all; } diff --git a/src/components/molecules/layout/meta.stories.tsx b/src/components/molecules/layout/meta.stories.tsx index 0323f90..a1755a0 100644 --- a/src/components/molecules/layout/meta.stories.tsx +++ b/src/components/molecules/layout/meta.stories.tsx @@ -1,5 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; -import MetaComponent from './meta'; +import MetaComponent, { MetaData } from './meta'; /** * Meta - Storybook Meta @@ -8,25 +8,41 @@ export default { title: 'Molecules/Layout', component: MetaComponent, argTypes: { - className: { + data: { + description: 'The page metadata.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + itemsLayout: { control: { - type: 'text', + type: 'select', }, - description: 'Set additional classnames to the meta wrapper.', + description: 'The items layout.', + options: ['inline', 'inline-values', 'stacked'], table: { - category: 'Styles', + category: 'Options', + defaultValue: { summary: 'inline-values' }, }, type: { name: 'string', required: false, }, }, - data: { - description: 'The page metadata.', + withSeparator: { + control: { + type: 'boolean', + }, + description: 'Add a slash as separator between multiple values.', + table: { + category: 'Options', + defaultValue: { summary: true }, + }, type: { - name: 'object', + name: 'boolean', required: true, - value: {}, }, }, }, @@ -36,19 +52,16 @@ const Template: ComponentStory = (args) => ( ); -const data = { - publication: { name: 'Published on:', value: 'April 9th 2022' }, - categories: { - name: 'Categories:', - value: [ - - Category 1 - , - - Category 2 - , - ], - }, +const data: MetaData = { + publication: { date: '2022-04-09', time: '01:04:00' }, + thematics: [ + + Category 1 + , + + Category 2 + , + ], }; /** diff --git a/src/components/molecules/layout/meta.test.tsx b/src/components/molecules/layout/meta.test.tsx index a738bdb..fe66d97 100644 --- a/src/components/molecules/layout/meta.test.tsx +++ b/src/components/molecules/layout/meta.test.tsx @@ -1,8 +1,24 @@ -import { render } from '@test-utils'; +import { render, screen } from '@test-utils'; +import { getFormattedDate } from '@utils/helpers/dates'; import Meta from './meta'; +const data = { + publication: { date: '2022-04-09' }, + thematics: [ + + Category 1 + , + + Category 2 + , + ], +}; + describe('Meta', () => { - it('renders a Meta component', () => { - render(); + it('format a date string', () => { + render(); + expect( + screen.getByText(getFormattedDate(data.publication.date)) + ).toBeInTheDocument(); }); }); diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx index d05396e..1401ac4 100644 --- a/src/components/molecules/layout/meta.tsx +++ b/src/components/molecules/layout/meta.tsx @@ -1,67 +1,312 @@ +import Link from '@components/atoms/links/link'; import DescriptionList, { type DescriptionListProps, type DescriptionListItem, } from '@components/atoms/lists/description-list'; +import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates'; import { FC, ReactNode } from 'react'; -import styles from './meta.module.scss'; +import { useIntl } from 'react-intl'; -export type MetaItem = { +export type CustomMeta = { + label: string; + value: ReactNode | ReactNode[]; +}; + +export type MetaDate = { /** - * The meta name. + * A date string. Ex: `2022-04-30`. */ - name: string; + date: string; /** - * The meta value. + * A time string. Ex: `10:25:59`. */ - value: ReactNode | ReactNode[]; -}; - -export type MetaMap = { - [key: string]: MetaItem | undefined; + time?: string; + /** + * Wrap the date with a link to the given target. + */ + target?: string; }; -export type MetaProps = { +export type MetaData = { + /** + * The author name. + */ + author?: string; + /** + * The comments count. + */ + commentsCount?: string | JSX.Element; + /** + * 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[]; /** - * Set additional classnames to the meta wrapper. + * An array of thematics. */ - className?: DescriptionListProps['className']; + thematics?: string[] | JSX.Element[]; + /** + * An array of thematics. + */ + topics?: string[] | JSX.Element[]; + /** + * A total. + */ + total?: string; + /** + * The update date. + */ + update?: MetaDate; +}; + +export type MetaProps = Omit< + DescriptionListProps, + 'items' | 'withSeparator' +> & { /** * The meta data. */ - data: MetaMap; + data: MetaData; /** - * The meta layout. + * The items layout. */ - layout?: DescriptionListProps['layout']; + itemsLayout?: DescriptionListItem['layout']; /** - * Determine if the layout should be responsive. + * If true, use a slash to delimitate multiple values. Default: true. */ - responsiveLayout?: DescriptionListProps['responsiveLayout']; + withSeparator?: DescriptionListProps['withSeparator']; }; /** * Meta component * - * Renders the page metadata. + * Renders the given metadata. */ -const Meta: FC = ({ className, data, ...props }) => { +const Meta: FC = ({ + data, + itemsLayout = 'inline-values', + withSeparator = true, + ...props +}) => { + 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:', + id: 'OI0N37', + description: 'Meta: author label', + }); + case 'commentsCount': + return intl.formatMessage({ + defaultMessage: 'Comments:', + id: 'jTVIh8', + description: 'Meta: comments label', + }); + case 'creation': + return intl.formatMessage({ + defaultMessage: 'Created on:', + id: 'b4fdYE', + description: 'Meta: creation date label', + }); + case 'license': + return intl.formatMessage({ + defaultMessage: 'License:', + id: 'AuGklx', + description: 'Meta: license label', + }); + case 'popularity': + return intl.formatMessage({ + defaultMessage: 'Popularity:', + id: 'pWTj2W', + description: 'Meta: popularity label', + }); + case 'publication': + return intl.formatMessage({ + defaultMessage: 'Published on:', + id: 'QGi5uD', + description: 'Meta: publication date label', + }); + case 'readingTime': + return intl.formatMessage({ + defaultMessage: 'Reading time:', + id: 'EbFvsM', + description: 'Meta: reading time label', + }); + case 'repositories': + return intl.formatMessage({ + defaultMessage: 'Repositories:', + id: 'DssFG1', + description: 'Meta: repositories label', + }); + case 'technologies': + return intl.formatMessage({ + defaultMessage: 'Technologies:', + id: 'ADQmDF', + description: 'Meta: technologies label', + }); + case 'thematics': + return intl.formatMessage({ + defaultMessage: 'Thematics:', + id: 'bz53Us', + description: 'Meta: thematics label', + }); + case 'topics': + return intl.formatMessage({ + defaultMessage: 'Topics:', + id: 'gJNaBD', + description: 'Meta: topics label', + }); + case 'total': + return intl.formatMessage({ + defaultMessage: 'Total:', + id: '92zgdp', + description: 'Meta: total label', + }); + case 'update': + return intl.formatMessage({ + defaultMessage: 'Updated on:', + id: 'tLC7bh', + description: 'Meta: update date label', + }); + 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(); + + return target ? ( + + + + ) : ( + + ); + }; + + /** + * 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[] => { + if (key === 'creation' || key === 'publication' || key === 'update') { + return getDate(value as MetaDate); + } + return value as string | ReactNode | ReactNode[]; + }; + /** * Transform the metadata to description list item format. * - * @param {MetaMap} items - The meta. + * @param {MetaData} items - The meta. * @returns {DescriptionListItem[]} The formatted description list items. */ - const getItems = (items: MetaMap): DescriptionListItem[] => { + const getItems = (items: MetaData): DescriptionListItem[] => { const listItems: DescriptionListItem[] = Object.entries(items) - .map(([key, item]) => { - if (!item) return; + .map(([key, value]) => { + if (!key || !value) return; - const { name, value } = item; + const metaKey = key as keyof MetaData; return { - id: key, - term: name, - value: Array.isArray(value) ? value : [value], + id: metaKey, + label: + metaKey === 'custom' + ? (value as CustomMeta).label + : getLabel(metaKey), + layout: itemsLayout, + value: + metaKey === 'custom' + ? (value as CustomMeta).value + : getValue( + metaKey, + value as string | string[] | JSX.Element | JSX.Element[] + ), } as DescriptionListItem; }) .filter((item): item is DescriptionListItem => !!item); @@ -72,8 +317,7 @@ const Meta: FC = ({ className, data, ...props }) => { return ( ); diff --git a/src/components/molecules/layout/page-footer.stories.tsx b/src/components/molecules/layout/page-footer.stories.tsx index da0a3fa..31b7a49 100644 --- a/src/components/molecules/layout/page-footer.stories.tsx +++ b/src/components/molecules/layout/page-footer.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { MetaData } from './meta'; import PageFooterComponent from './page-footer'; /** @@ -39,8 +40,15 @@ const Template: ComponentStory = (args) => ( ); -const meta = { - topics: { name: 'More posts about:', value: Topic name }, +const meta: MetaData = { + custom: { + label: 'More posts about:', + value: [ + + Topic name + , + ], + }, }; /** diff --git a/src/components/molecules/layout/page-footer.tsx b/src/components/molecules/layout/page-footer.tsx index f522482..e998b1e 100644 --- a/src/components/molecules/layout/page-footer.tsx +++ b/src/components/molecules/layout/page-footer.tsx @@ -1,5 +1,5 @@ import { FC } from 'react'; -import Meta, { type MetaMap } from './meta'; +import Meta, { MetaData } from './meta'; export type PageFooterProps = { /** @@ -9,7 +9,7 @@ export type PageFooterProps = { /** * The footer metadata. */ - meta?: MetaMap; + meta?: MetaData; }; /** diff --git a/src/components/molecules/layout/page-header.stories.tsx b/src/components/molecules/layout/page-header.stories.tsx index 6054845..d58f8b5 100644 --- a/src/components/molecules/layout/page-header.stories.tsx +++ b/src/components/molecules/layout/page-header.stories.tsx @@ -8,6 +8,19 @@ export default { title: 'Molecules/Layout/PageHeader', component: PageHeader, argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the header element.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, intro: { control: { type: 'text', @@ -50,18 +63,15 @@ const Template: ComponentStory = (args) => ( ); const meta = { - publication: { name: 'Published on:', value: 'April 9th 2022' }, - categories: { - name: 'Categories:', - value: [ - - Category 1 - , - - Category 2 - , - ], - }, + publication: { date: '2022-04-09' }, + thematics: [ + + Category 1 + , + + Category 2 + , + ], }; /** diff --git a/src/components/molecules/layout/page-header.tsx b/src/components/molecules/layout/page-header.tsx index 1663085..9abe9af 100644 --- a/src/components/molecules/layout/page-header.tsx +++ b/src/components/molecules/layout/page-header.tsx @@ -1,7 +1,7 @@ import Heading from '@components/atoms/headings/heading'; -import styles from './page-header.module.scss'; -import Meta, { type MetaMap } from './meta'; import { FC } from 'react'; +import Meta, { type MetaData } from './meta'; +import styles from './page-header.module.scss'; export type PageHeaderProps = { /** @@ -15,7 +15,7 @@ export type PageHeaderProps = { /** * The page metadata. */ - meta?: MetaMap; + meta?: MetaData; /** * The page title. */ @@ -33,14 +33,29 @@ const PageHeader: FC = ({ meta, title, }) => { + const getIntro = () => { + return typeof intro === 'string' ? ( +
+ ) : ( +
{intro}
+ ); + }; + return (
{title} - {meta && } - {intro &&
{intro}
} + {meta && ( + + )} + {intro && getIntro()}
); diff --git a/src/components/organisms/layout/cards-list.stories.tsx b/src/components/organisms/layout/cards-list.stories.tsx index fe0ebfd..522d068 100644 --- a/src/components/organisms/layout/cards-list.stories.tsx +++ b/src/components/organisms/layout/cards-list.stories.tsx @@ -93,9 +93,7 @@ const items: CardsListItem[] = [ // @ts-ignore - Needed because of the placeholder image. unoptimized: true, }, - meta: [ - { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] }, - ], + meta: { thematics: ['Velit', 'Ex', 'Alias'] }, tagline: 'Molestias ut error', title: 'Et alias omnis', url: '#', @@ -110,7 +108,7 @@ const items: CardsListItem[] = [ // @ts-ignore - Needed because of the placeholder image. unoptimized: true, }, - meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }], + meta: { thematics: ['Voluptas'] }, tagline: 'Quod vel accusamus', title: 'Laboriosam doloremque mollitia', url: '#', @@ -125,13 +123,9 @@ const items: CardsListItem[] = [ // @ts-ignore - Needed because of the placeholder image. unoptimized: true, }, - meta: [ - { - id: 'meta-1', - term: 'Omnis', - value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], - }, - ], + meta: { + thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], + }, tagline: 'Quo error eum', title: 'Magni rem nulla', url: '#', diff --git a/src/components/organisms/layout/cards-list.test.tsx b/src/components/organisms/layout/cards-list.test.tsx index 2df3f59..7d98844 100644 --- a/src/components/organisms/layout/cards-list.test.tsx +++ b/src/components/organisms/layout/cards-list.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@test-utils'; -import CardsList from './cards-list'; +import CardsList, { type CardsListItem } from './cards-list'; -const items = [ +const items: CardsListItem[] = [ { id: 'card-1', cover: { @@ -9,10 +9,10 @@ const items = [ src: 'http://placeimg.com/640/480', width: 640, height: 480, + // @ts-ignore - Needed because of the placeholder image. + unoptimized: true, }, - meta: [ - { id: 'meta-1', term: 'Quibusdam', value: ['Velit', 'Ex', 'Alias'] }, - ], + meta: { thematics: ['Velit', 'Ex', 'Alias'] }, tagline: 'Molestias ut error', title: 'Et alias omnis', url: '#', @@ -24,8 +24,10 @@ const items = [ src: 'http://placeimg.com/640/480', width: 640, height: 480, + // @ts-ignore - Needed because of the placeholder image. + unoptimized: true, }, - meta: [{ id: 'meta-1', term: 'Est', value: ['Voluptas'] }], + meta: { thematics: ['Voluptas'] }, tagline: 'Quod vel accusamus', title: 'Laboriosam doloremque mollitia', url: '#', @@ -37,14 +39,12 @@ const items = [ src: 'http://placeimg.com/640/480', width: 640, height: 480, + // @ts-ignore - Needed because of the placeholder image. + unoptimized: true, + }, + meta: { + thematics: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], }, - meta: [ - { - id: 'meta-1', - term: 'Omnis', - value: ['Quisquam', 'Quia', 'Sapiente', 'Perspiciatis'], - }, - ], tagline: 'Quo error eum', title: 'Magni rem nulla', url: '#', diff --git a/src/components/organisms/layout/comment.module.scss b/src/components/organisms/layout/comment.module.scss index 54201de..d2b68e1 100644 --- a/src/components/organisms/layout/comment.module.scss +++ b/src/components/organisms/layout/comment.module.scss @@ -60,16 +60,20 @@ } .date { - flex-flow: row wrap; - justify-content: center; margin: var(--spacing-sm) 0; font-size: var(--font-size-sm); - text-align: center; + + &__item { + justify-content: center; + } @include mix.media("screen") { @include mix.dimensions("sm") { - justify-content: left; margin: 0 0 var(--spacing-sm); + + &__item { + justify-content: left; + } } } } diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx index 9e537e5..4961722 100644 --- a/src/components/organisms/layout/comment.test.tsx +++ b/src/components/organisms/layout/comment.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@test-utils'; -import { getFormattedDate, getFormattedTime } from '@utils/helpers/format'; +import { getFormattedDate, getFormattedTime } from '@utils/helpers/dates'; import Comment from './comment'; const author = { diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index 6d41c00..248efc2 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -1,10 +1,12 @@ import Button from '@components/atoms/buttons/button'; import Link from '@components/atoms/links/link'; -import DescriptionList from '@components/atoms/lists/description-list'; -import { getFormattedDate, getFormattedTime } from '@utils/helpers/format'; +import Meta from '@components/molecules/layout/meta'; +import useSettings from '@utils/hooks/use-settings'; import Image from 'next/image'; +import Script from 'next/script'; import { FC, useState } from 'react'; import { useIntl } from 'react-intl'; +import { type Comment as CommentSchema, type WithContext } from 'schema-dts'; import CommentForm, { type CommentFormProps } from '../forms/comment-form'; import styles from './comment.module.scss'; @@ -41,7 +43,11 @@ export type CommentProps = { */ id: number | string; /** - * The comment date. + * The comment parent id. + */ + parentId?: number | string; + /** + * The comment date and time separated with a space. */ publication: string; /** @@ -60,15 +66,14 @@ const Comment: FC = ({ canReply = true, content, id, + parentId, publication, saveComment, ...props }) => { const intl = useIntl(); const [isReplying, setIsReplying] = useState(false); - const commentDate = getFormattedDate(publication); - const commentTime = getFormattedTime(publication); - const commentDateTime = new Date(publication).toISOString(); + const [publicationDate, publicationTime] = publication.split(' '); const avatarAltText = intl.formatMessage( { @@ -89,37 +94,46 @@ const Comment: FC = ({ description: 'Comment: reply button', id: 'hzHuCc', }); - const dateLabel = intl.formatMessage({ - defaultMessage: 'Published on:', - description: 'Comment: publication date label', - id: 'soj7do', - }); - const dateValue = intl.formatMessage( - { - defaultMessage: '{date} at {time}', - description: 'Comment: publication date and time', - id: 'Ld6yMP', - }, - { - date: commentDate, - time: commentTime, - } - ); const formTitle = intl.formatMessage({ defaultMessage: 'Leave a reply', description: 'Comment: comment form title', id: '2fD5CI', }); - const dateLink = ( - - - {dateValue} - - ); + const { website } = useSettings(); + + const commentSchema: WithContext = { + '@context': 'https://schema.org', + '@id': `${website.url}/#comment-${id}`, + '@type': 'Comment', + parentItem: parentId + ? { '@id': `${website.url}/#comment-${parentId}` } + : undefined, + about: { '@type': 'Article', '@id': `${website.url}/#article` }, + author: { + '@type': 'Person', + name: author.name, + image: author.avatar, + url: author.url, + }, + creator: { + '@type': 'Person', + name: author.name, + image: author.avatar, + url: author.url, + }, + dateCreated: publication, + datePublished: publication, + text: content, + }; return ( <> +