From 36890cfafeba6e30782df1260d7f9e678c7da4bf Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 2 Oct 2023 17:01:57 +0200 Subject: refactor(components): rewrite DescriptionList component * add a `spacing` prop * replace `layout` prop with `isInline` prop * remove `items` prop (and classNames props) in favor of new components: Description, Group, Term * remove `withSeparator` prop (CSS content is announced by screen readers and Firefox/Safari have no support for alternative text so the consumer should add itself an element with `aria-hidden` if it need a separator) Be aware, Meta component and its consumers can be visually broken, they should be refactored before using them in production. --- .../atoms/lists/description-list-group.module.scss | 40 ------ .../atoms/lists/description-list-group.stories.tsx | 132 ------------------ .../atoms/lists/description-list-group.test.tsx | 18 --- .../atoms/lists/description-list-group.tsx | 70 ---------- .../atoms/lists/description-list.module.scss | 15 --- .../atoms/lists/description-list.stories.tsx | 131 ------------------ .../atoms/lists/description-list.test.tsx | 21 --- src/components/atoms/lists/description-list.tsx | 103 -------------- .../description-list/description-list.module.scss | 28 ++++ .../description-list/description-list.stories.tsx | 150 +++++++++++++++++++++ .../description-list/description-list.test.tsx | 70 ++++++++++ .../lists/description-list/description-list.tsx | 67 +++++++++ .../atoms/lists/description-list/description.tsx | 28 ++++ .../atoms/lists/description-list/group.tsx | 62 +++++++++ .../atoms/lists/description-list/index.ts | 4 + .../atoms/lists/description-list/term.tsx | 28 ++++ src/components/atoms/lists/index.ts | 1 - src/components/molecules/layout/card.tsx | 10 +- src/components/molecules/layout/meta.module.scss | 17 ++- src/components/molecules/layout/meta.stories.tsx | 34 +---- src/components/molecules/layout/meta.test.tsx | 8 +- src/components/molecules/layout/meta.tsx | 103 +++++++------- src/components/molecules/layout/page-footer.tsx | 4 +- src/components/molecules/layout/page-header.tsx | 9 +- src/components/organisms/layout/comment.fixture.ts | 41 ++++++ .../organisms/layout/comment.fixture.tsx | 41 ------ .../organisms/layout/comment.stories.tsx | 12 +- src/components/organisms/layout/comment.test.tsx | 28 ++-- src/components/organisms/layout/comment.tsx | 43 +++--- src/components/organisms/layout/comments-list.tsx | 11 +- .../organisms/layout/overview.module.scss | 4 + src/components/organisms/layout/overview.tsx | 6 +- src/components/organisms/layout/summary.tsx | 11 +- src/styles/abstracts/placeholders/_lists.scss | 30 ++++- src/styles/pages/partials/_article-lists.scss | 15 +-- 35 files changed, 646 insertions(+), 749 deletions(-) delete mode 100644 src/components/atoms/lists/description-list-group.module.scss delete mode 100644 src/components/atoms/lists/description-list-group.stories.tsx delete mode 100644 src/components/atoms/lists/description-list-group.test.tsx delete mode 100644 src/components/atoms/lists/description-list-group.tsx delete mode 100644 src/components/atoms/lists/description-list.module.scss delete mode 100644 src/components/atoms/lists/description-list.stories.tsx delete mode 100644 src/components/atoms/lists/description-list.test.tsx delete mode 100644 src/components/atoms/lists/description-list.tsx create mode 100644 src/components/atoms/lists/description-list/description-list.module.scss create mode 100644 src/components/atoms/lists/description-list/description-list.stories.tsx create mode 100644 src/components/atoms/lists/description-list/description-list.test.tsx create mode 100644 src/components/atoms/lists/description-list/description-list.tsx create mode 100644 src/components/atoms/lists/description-list/description.tsx create mode 100644 src/components/atoms/lists/description-list/group.tsx create mode 100644 src/components/atoms/lists/description-list/index.ts create mode 100644 src/components/atoms/lists/description-list/term.tsx create mode 100644 src/components/organisms/layout/comment.fixture.ts delete mode 100644 src/components/organisms/layout/comment.fixture.tsx (limited to 'src') diff --git a/src/components/atoms/lists/description-list-group.module.scss b/src/components/atoms/lists/description-list-group.module.scss deleted file mode 100644 index aba90ce..0000000 --- a/src/components/atoms/lists/description-list-group.module.scss +++ /dev/null @@ -1,40 +0,0 @@ -.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 { - row-gap: var(--spacing-2xs); - - .term { - flex: 1 1 100%; - } - } - - &--stacked { - flex-flow: column wrap; - } -} diff --git a/src/components/atoms/lists/description-list-group.stories.tsx b/src/components/atoms/lists/description-list-group.stories.tsx deleted file mode 100644 index e6766a3..0000000 --- a/src/components/atoms/lists/description-list-group.stories.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { DescriptionListGroup } from './description-list-group'; - -export default { - title: 'Atoms/Typography/Lists/DescriptionList/Item', - component: DescriptionListGroup, - 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: 'Options', - 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-group.test.tsx b/src/components/atoms/lists/description-list-group.test.tsx deleted file mode 100644 index 205dad5..0000000 --- a/src/components/atoms/lists/description-list-group.test.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { DescriptionListGroup } from './description-list-group'; - -const itemLabel = 'Repellendus corporis facilis'; -const itemValue = ['quos', 'eum']; - -describe('DescriptionListGroup', () => { - 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-group.tsx b/src/components/atoms/lists/description-list-group.tsx deleted file mode 100644 index 63ae541..0000000 --- a/src/components/atoms/lists/description-list-group.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { FC, ReactNode, useId } from 'react'; -import styles from './description-list-group.module.scss'; - -export type ItemLayout = 'inline' | 'inline-values' | 'stacked'; - -export type DescriptionListGroupProps = { - /** - * 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. - */ -export const DescriptionListGroup: 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]; - const groupClass = `${styles.wrapper} ${layoutStyles} ${separatorStyles} ${className}`; - - return ( -
-
{label}
- {itemValues.map((currentValue, index) => ( -
- {currentValue} -
- ))} -
- ); -}; diff --git a/src/components/atoms/lists/description-list.module.scss b/src/components/atoms/lists/description-list.module.scss deleted file mode 100644 index d31c88a..0000000 --- a/src/components/atoms/lists/description-list.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -.list { - display: flex; - column-gap: var(--spacing-md); - row-gap: var(--spacing-2xs); - margin: 0; - - &--inline { - flex-flow: row wrap; - align-items: baseline; - } - - &--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 deleted file mode 100644 index 0194817..0000000 --- a/src/components/atoms/lists/description-list.stories.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { DescriptionList, DescriptionListItem } from './description-list'; - -/** - * DescriptionList - Storybook Meta - */ -export default { - title: 'Atoms/Typography/Lists/DescriptionList', - component: DescriptionList, - args: { - layout: 'column', - withSeparator: false, - }, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the list wrapper', - table: { - category: 'Styles', - }, - type: { - name: 'string', - 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, - }, - description: 'The list items.', - type: { - name: 'object', - required: true, - 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', - }, - description: 'The list layout.', - options: ['column', 'inline'], - table: { - category: 'Options', - defaultValue: { summary: 'column' }, - }, - type: { - name: 'string', - 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; - -const Template: ComponentStory = (args) => ( - -); - -const items: DescriptionListItem[] = [ - { 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', - label: 'Term 3:', - value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'], - }, - { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] }, -]; - -/** - * List Stories - Description list - */ -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 deleted file mode 100644 index 2af92e2..0000000 --- a/src/components/atoms/lists/description-list.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render } from '../../../../tests/utils'; -import { DescriptionList, DescriptionListItem } from './description-list'; - -const items: DescriptionListItem[] = [ - { 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', - label: 'Term 3:', - value: ['Value 1 for term 3', 'Value 2 for term 3', 'Value 3 for term 3'], - }, - { id: 'term-4', label: 'Term 4:', value: ['Value for term 4'] }, -]; - -describe('DescriptionList', () => { - it('renders a list of terms and description', () => { - const { container } = render(); - expect(container).toBeDefined(); - }); -}); diff --git a/src/components/atoms/lists/description-list.tsx b/src/components/atoms/lists/description-list.tsx deleted file mode 100644 index d97e505..0000000 --- a/src/components/atoms/lists/description-list.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { FC, HTMLAttributes } from 'react'; -import { - DescriptionListGroup, - type DescriptionListGroupProps, -} from './description-list-group'; -import styles from './description-list.module.scss'; - -export type DescriptionListItem = { - /** - * The item id. - */ - id: string; - /** - * The list item layout. - */ - layout?: DescriptionListGroupProps['layout']; - /** - * A list label. - */ - label: DescriptionListGroupProps['label']; - /** - * An array of values for the list item. - */ - value: DescriptionListGroupProps['value']; -}; - -export type DescriptionListProps = Omit< - HTMLAttributes, - 'children' -> & { - /** - * Set additional classnames to the `dt`/`dd` couple wrapper. - */ - groupClassName?: string; - /** - * The list items. - */ - items: DescriptionListItem[]; - /** - * Set additional classnames to the `dt` element. - */ - labelClassName?: string; - /** - * The list layout. Default: column. - */ - layout?: 'inline' | 'column'; - /** - * Set additional classnames to the `dd` element. - */ - valueClassName?: string; - /** - * If true, use a slash to delimitate multiple values. - */ - withSeparator?: DescriptionListGroupProps['withSeparator']; -}; - -/** - * DescriptionList component - * - * Render a description list. - */ -export const DescriptionList: FC = ({ - className = '', - groupClassName = '', - items, - labelClassName = '', - layout = 'column', - valueClassName = '', - withSeparator, - ...props -}) => { - const layoutModifier = `list--${layout}`; - const listClass = `${styles.list} ${styles[layoutModifier]} ${className}`; - - /** - * Retrieve the description list items. - * - * @param {DescriptionListGroup[]} listItems - An array of items. - * @returns {JSX.Element[]} The description list items. - */ - const getItems = (listItems: DescriptionListItem[]): JSX.Element[] => { - return listItems.map(({ id, layout: itemLayout, label, value }) => { - return ( - - ); - }); - }; - - return ( -
- {getItems(items)} -
- ); -}; diff --git a/src/components/atoms/lists/description-list/description-list.module.scss b/src/components/atoms/lists/description-list/description-list.module.scss new file mode 100644 index 0000000..951e1ee --- /dev/null +++ b/src/components/atoms/lists/description-list/description-list.module.scss @@ -0,0 +1,28 @@ +@use "../../../../styles/abstracts/placeholders"; + +.term { + @extend %term; +} + +.description { + @extend %description; +} + +.group { + width: fit-content; +} + +.list { + margin: 0; +} + +.group, +.list { + &--inline { + @extend %inline-description-list; + } + + &--stack { + @extend %stack-description-list; + } +} diff --git a/src/components/atoms/lists/description-list/description-list.stories.tsx b/src/components/atoms/lists/description-list/description-list.stories.tsx new file mode 100644 index 0000000..d051fcd --- /dev/null +++ b/src/components/atoms/lists/description-list/description-list.stories.tsx @@ -0,0 +1,150 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Description } from './description'; +import { DescriptionList } from './description-list'; +import { Group } from './group'; +import { Term } from './term'; + +/** + * DescriptionList - Storybook Meta + */ +export default { + title: 'Atoms/Lists/DescriptionList', + component: DescriptionList, + args: { + isInline: false, + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list wrapper', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +/** + * Description List Stories - Single term, single description + */ +export const SingleTermSingleDescription = Template.bind({}); +SingleTermSingleDescription.args = { + children: ( + <> + A term + A description of the term. + + ), +}; + +/** + * Description List Stories - Multiple terms, single description + */ +export const MultipleTermsSingleDescription = Template.bind({}); +MultipleTermsSingleDescription.args = { + children: ( + <> + A first term + A second term + A third term + A description of the term. + + ), +}; + +/** + * Description List Stories - Single term, multiple descriptions + */ +export const SingleTermMultipleDescriptions = Template.bind({}); +SingleTermMultipleDescriptions.args = { + children: ( + <> + A term + A first description of the term. + A second description of the term. + A third description of the term. + + ), +}; + +/** + * Description List Stories - Multiple terms, multiple descriptions + */ +export const MultipleTermsMultipleDescriptions = Template.bind({}); +MultipleTermsMultipleDescriptions.args = { + children: ( + <> + A first term + A second term + A third term + A first description of the term. + A second description of the term. + A third description of the term. + + ), +}; + +/** + * Description List Stories - Group of terms & descriptions + */ +export const GroupOfTermsDescriptions = Template.bind({}); +GroupOfTermsDescriptions.args = { + children: ( + <> + + A term + A description of the term. + + + Another term + A description of the other term. + + + ), +}; + +/** + * Description List Stories - Inlined list of term and descriptions + */ +export const InlinedList = Template.bind({}); +InlinedList.args = { + children: ( + <> + A term: + A first description of the term. + A second description of the term. + A third description of the term. + + ), + isInline: true, + spacing: 'xs', +}; + +/** + * Description List Stories - Inlined group of terms & descriptions + */ +export const InlinedGroupOfTermsDescriptions = Template.bind({}); +InlinedGroupOfTermsDescriptions.args = { + children: ( + <> + + A term: + A description of the term. + + + Another term: + A description of the other term. + + + ), +}; diff --git a/src/components/atoms/lists/description-list/description-list.test.tsx b/src/components/atoms/lists/description-list/description-list.test.tsx new file mode 100644 index 0000000..3f9a1b5 --- /dev/null +++ b/src/components/atoms/lists/description-list/description-list.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Description } from './description'; +import { DescriptionList } from './description-list'; +import { Group } from './group'; +import { Term } from './term'; + +describe('DescriptionList', () => { + it('renders a list of terms and description', () => { + const term = 'A term'; + const desc = 'A description of the term.'; + + render( + + {term} + {desc} + + ); + + expect(rtlScreen.getByRole('definition')).toHaveTextContent(desc); + expect(rtlScreen.getByRole('term')).toHaveTextContent(term); + }); + + it('can renders a list of terms and description wrapped in a div', () => { + const term = 'A term'; + const desc = 'A description of the term.'; + + render( + + + {term} + {desc} + + + ); + + expect(rtlScreen.getByRole('definition')).toHaveTextContent(desc); + expect(rtlScreen.getByRole('term')).toHaveTextContent(term); + }); + + it('can render terms and description inlined', () => { + const term = 'A term'; + const desc = 'A description of the term.'; + + render( + + {term} + {desc} + + ); + + const list = rtlScreen.getByRole('term').parentElement; + expect(list).toHaveClass('list--inline'); + }); + + it('can render terms and description stacked', () => { + const term = 'A term'; + const desc = 'A description of the term.'; + + render( + + {term} + {desc} + + ); + + const list = rtlScreen.getByRole('term').parentElement; + expect(list).toHaveClass('list--stack'); + }); +}); diff --git a/src/components/atoms/lists/description-list/description-list.tsx b/src/components/atoms/lists/description-list/description-list.tsx new file mode 100644 index 0000000..cc225fe --- /dev/null +++ b/src/components/atoms/lists/description-list/description-list.tsx @@ -0,0 +1,67 @@ +import { + forwardRef, + type CSSProperties, + type HTMLAttributes, + type ReactNode, + type ForwardRefRenderFunction, +} from 'react'; +import type { Spacing } from '../../../../types'; +import styles from './description-list.module.scss'; + +export type DescriptionListProps = Omit< + HTMLAttributes, + 'children' +> & { + /** + * The list items or groups. + */ + children: ReactNode; + /** + * Should the list be inlined? + * + * @default false + */ + isInline?: boolean; + /** + * Define the spacing between list items. + * + * @default null + */ + spacing?: Spacing | null; +}; + +const DescriptionListWithRef: ForwardRefRenderFunction< + HTMLDListElement, + DescriptionListProps +> = ( + { + children, + className = '', + isInline = false, + spacing = null, + style, + ...props + }, + ref +) => { + const itemSpacing = spacing === null ? 0 : `var(--spacing-${spacing})`; + const layoutClass = styles[isInline ? 'list--inline' : 'list--stack']; + const listClass = `${styles.list} ${layoutClass} ${className}`; + const listStyles = { + ...style, + '--itemSpacing': itemSpacing, + } as CSSProperties; + + return ( +
+ {children} +
+ ); +}; + +/** + * DescriptionList component + * + * Render a description list. + */ +export const DescriptionList = forwardRef(DescriptionListWithRef); diff --git a/src/components/atoms/lists/description-list/description.tsx b/src/components/atoms/lists/description-list/description.tsx new file mode 100644 index 0000000..9fa7ecd --- /dev/null +++ b/src/components/atoms/lists/description-list/description.tsx @@ -0,0 +1,28 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type HTMLAttributes, +} from 'react'; +import styles from './description-list.module.scss'; + +export type DescriptionProps = HTMLAttributes; + +const DescriptionWithRef: ForwardRefRenderFunction< + HTMLElement, + DescriptionProps +> = ({ children, className = '', ...props }, ref) => { + const descriptionClass = `${styles.description} ${className}`; + + return ( +
+ {children} +
+ ); +}; + +/** + * Description component. + * + * Use it inside a `DescriptionList` or a `Group` component. + */ +export const Description = forwardRef(DescriptionWithRef); diff --git a/src/components/atoms/lists/description-list/group.tsx b/src/components/atoms/lists/description-list/group.tsx new file mode 100644 index 0000000..2d1fb4b --- /dev/null +++ b/src/components/atoms/lists/description-list/group.tsx @@ -0,0 +1,62 @@ +import { + forwardRef, + type CSSProperties, + type HTMLAttributes, + type ReactNode, + type ForwardRefRenderFunction, +} from 'react'; +import type { Spacing } from '../../../../types'; +import styles from './description-list.module.scss'; + +export type GroupProps = Omit, 'children'> & { + /** + * The term(s) and description(s) of a description list. + */ + children: ReactNode; + /** + * Should the term & description in the group be inlined? + * + * @default false + */ + isInline?: boolean; + /** + * Define the spacing between list items. + * + * @default null + */ + spacing?: Spacing | null; +}; + +const GroupWithRef: ForwardRefRenderFunction = ( + { + children, + className = '', + isInline = false, + spacing = null, + style, + ...props + }, + ref +) => { + const itemSpacing = spacing === null ? 0 : `var(--spacing-${spacing})`; + const layoutClass = styles[isInline ? 'group--inline' : 'group--stack']; + const groupClass = `${styles.group} ${layoutClass} ${className}`; + const groupStyles = { + ...style, + '--itemSpacing': itemSpacing, + } as CSSProperties; + + return ( +
+ {children} +
+ ); +}; + +/** + * Group component. + * + * Use it to wrap `Description` and `Term` components in a `DescriptionList` + * component. + */ +export const Group = forwardRef(GroupWithRef); diff --git a/src/components/atoms/lists/description-list/index.ts b/src/components/atoms/lists/description-list/index.ts new file mode 100644 index 0000000..7f67579 --- /dev/null +++ b/src/components/atoms/lists/description-list/index.ts @@ -0,0 +1,4 @@ +export * from './description'; +export * from './description-list'; +export * from './group'; +export * from './term'; diff --git a/src/components/atoms/lists/description-list/term.tsx b/src/components/atoms/lists/description-list/term.tsx new file mode 100644 index 0000000..0d21f96 --- /dev/null +++ b/src/components/atoms/lists/description-list/term.tsx @@ -0,0 +1,28 @@ +import { + forwardRef, + type ForwardRefRenderFunction, + type HTMLAttributes, +} from 'react'; +import styles from './description-list.module.scss'; + +export type TermProps = HTMLAttributes; + +const TermWithRef: ForwardRefRenderFunction = ( + { children, className = '', ...props }, + ref +) => { + const termClass = `${styles.term} ${className}`; + + return ( +
+ {children} +
+ ); +}; + +/** + * Term component. + * + * Use it inside a `DescriptionList` or a `Group` component. + */ +export const Term = forwardRef(TermWithRef); diff --git a/src/components/atoms/lists/index.ts b/src/components/atoms/lists/index.ts index d16fb34..59079a5 100644 --- a/src/components/atoms/lists/index.ts +++ b/src/components/atoms/lists/index.ts @@ -1,3 +1,2 @@ export * from './description-list'; -export * from './description-list-group'; export * from './list'; diff --git a/src/components/molecules/layout/card.tsx b/src/components/molecules/layout/card.tsx index 722e5a5..c9e7a90 100644 --- a/src/components/molecules/layout/card.tsx +++ b/src/components/molecules/layout/card.tsx @@ -72,15 +72,7 @@ export const Card: FC = ({ {tagline ?
{tagline}
: null} {meta ? (
- +
) : null} diff --git a/src/components/molecules/layout/meta.module.scss b/src/components/molecules/layout/meta.module.scss index f572b65..26faac3 100644 --- a/src/components/molecules/layout/meta.module.scss +++ b/src/components/molecules/layout/meta.module.scss @@ -1,3 +1,16 @@ -.value { - word-break: break-all; +.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 index 50ed252..6faa265 100644 --- a/src/components/molecules/layout/meta.stories.tsx +++ b/src/components/molecules/layout/meta.stories.tsx @@ -1,7 +1,5 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import descriptionListItemStories from '../../atoms/lists/description-list-group.stories'; -import descriptionListStories from '../../atoms/lists/description-list.stories'; -import { Meta as MetaComponent, MetaData } from './meta'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Meta as MetaComponent, type MetaData } from './meta'; /** * Meta - Storybook Meta @@ -9,12 +7,8 @@ import { Meta as MetaComponent, MetaData } from './meta'; export default { title: 'Molecules/Layout', component: MetaComponent, - args: { - itemsLayout: 'inline-values', - withSeparator: false, - }, + args: {}, argTypes: { - className: descriptionListStories.argTypes?.className, data: { description: 'The page metadata.', type: { @@ -23,24 +17,6 @@ export default { value: {}, }, }, - groupClassName: descriptionListStories.argTypes?.groupClassName, - itemsLayout: { - ...descriptionListItemStories.argTypes?.layout, - table: { - ...descriptionListItemStories.argTypes?.layout?.table, - defaultValue: { summary: 'inline-values' }, - }, - }, - labelClassName: descriptionListStories.argTypes?.labelClassName, - layout: descriptionListStories.argTypes?.layout, - valueClassName: descriptionListStories.argTypes?.valueClassName, - withSeparator: { - ...descriptionListStories.argTypes?.withSeparator, - table: { - ...descriptionListStories.argTypes?.withSeparator?.table, - defaultValue: { summary: true }, - }, - }, }, } as ComponentMeta; @@ -51,10 +27,10 @@ const Template: ComponentStory = (args) => ( 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 f19c408..0635fc3 100644 --- a/src/components/molecules/layout/meta.test.tsx +++ b/src/components/molecules/layout/meta.test.tsx @@ -1,15 +1,15 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; +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 , ], @@ -19,7 +19,7 @@ describe('Meta', () => { it('format a date string', () => { render(); expect( - screen.getByText(getFormattedDate(data.publication.date)) + rtlScreen.getByText(getFormattedDate(data.publication.date)) ).toBeInTheDocument(); }); }); diff --git a/src/components/molecules/layout/meta.tsx b/src/components/molecules/layout/meta.tsx index 53128a7..094c420 100644 --- a/src/components/molecules/layout/meta.tsx +++ b/src/components/molecules/layout/meta.tsx @@ -1,16 +1,19 @@ -import { FC, ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; import { useIntl } from 'react-intl'; import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; import { DescriptionList, type DescriptionListProps, - type DescriptionListItem, Link, + Group, + Term, + Description, } from '../../atoms'; +import styles from './meta.module.scss'; export type CustomMeta = { label: string; - value: ReactNode | ReactNode[]; + value: ReactNode; }; export type MetaComments = { @@ -106,24 +109,16 @@ export type MetaData = { website?: string; }; -export type MetaKey = keyof MetaData; +const isCustomMeta = ( + key: keyof MetaData, + _value: unknown +): _value is MetaData['custom'] => key === 'custom'; -export type MetaProps = Omit< - DescriptionListProps, - 'items' | 'withSeparator' -> & { +export type MetaProps = Omit & { /** * The meta data. */ data: MetaData; - /** - * The items layout. - */ - itemsLayout?: DescriptionListItem['layout']; - /** - * If true, use a slash to delimitate multiple values. Default: true. - */ - withSeparator?: DescriptionListProps['withSeparator']; }; /** @@ -132,11 +127,13 @@ export type MetaProps = Omit< * Renders the given metadata. */ export const Meta: FC = ({ + className = '', data, - itemsLayout = 'inline-values', - withSeparator = true, + isInline = false, ...props }) => { + const layoutClass = styles[isInline ? 'list--inline' : 'list--stack']; + const listClass = `${styles.list} ${layoutClass} ${className}`; const intl = useIntl(); /** @@ -316,7 +313,7 @@ export const Meta: FC = ({ * @param {ValueOf} value - The meta value. * @returns {string|ReactNode|ReactNode[]} - The formatted value. */ - const getValue = ( + const getValue = ( key: T, value: MetaData[T] ): string | ReactNode | ReactNode[] => { @@ -338,12 +335,11 @@ export const Meta: FC = ({ { postsCount: value as number } ); case 'website': - const url = value as string; - return ( - - {url} + return typeof value === 'string' ? ( + + {value} - ); + ) : null; default: return value as string | ReactNode | ReactNode[]; } @@ -355,36 +351,45 @@ export const Meta: FC = ({ * @param {MetaData} items - The meta. * @returns {DescriptionListItem[]} The formatted description list items. */ - const getItems = (items: MetaData): DescriptionListItem[] => { - const listItems: DescriptionListItem[] = Object.entries(items) - .map(([key, value]) => { - if (!key || !value) return; - - const metaKey = key as MetaKey; + 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 { - id: metaKey, - label: - metaKey === 'custom' - ? (value as CustomMeta).label - : getLabel(metaKey), - layout: itemsLayout, - value: - metaKey === 'custom' && (value as CustomMeta) - ? (value as CustomMeta).value - : getValue(metaKey, value), - } as DescriptionListItem; - }) - .filter((item): item is DescriptionListItem => !!item); + 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.tsx b/src/components/molecules/layout/page-footer.tsx index 5f3b176..375cbc4 100644 --- a/src/components/molecules/layout/page-footer.tsx +++ b/src/components/molecules/layout/page-footer.tsx @@ -15,7 +15,5 @@ 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.tsx b/src/components/molecules/layout/page-header.tsx index 92650c5..b727cc1 100644 --- a/src/components/molecules/layout/page-header.tsx +++ b/src/components/molecules/layout/page-header.tsx @@ -56,14 +56,7 @@ export const PageHeader: FC = ({ {title} {meta ? ( - + ) : null} {intro ? getIntro() : null} diff --git a/src/components/organisms/layout/comment.fixture.ts b/src/components/organisms/layout/comment.fixture.ts new file mode 100644 index 0000000..f626be9 --- /dev/null +++ b/src/components/organisms/layout/comment.fixture.ts @@ -0,0 +1,41 @@ +import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; +import type { UserCommentProps } from './comment'; + +export const author = { + avatar: { + alt: 'Author avatar', + height: 480, + src: 'http://placeimg.com/640/480', + width: 640, + }, + name: 'Armand', + website: 'https://www.armandphilippot.com/', +}; + +export const content = + 'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.'; + +export const date = '2021-04-03 23:04:24'; + +export const meta = { + author, + date, +}; + +export const id = 5; + +export const saveComment = async () => { + /** Do nothing. */ +}; + +export const data: UserCommentProps = { + approved: true, + content, + id, + meta, + parentId: 0, + saveComment, +}; + +export const formattedDate = getFormattedDate(date); +export const formattedTime = getFormattedTime(date); diff --git a/src/components/organisms/layout/comment.fixture.tsx b/src/components/organisms/layout/comment.fixture.tsx deleted file mode 100644 index eee7981..0000000 --- a/src/components/organisms/layout/comment.fixture.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; -import { CommentProps } from './comment'; - -export const author = { - avatar: { - alt: 'Author avatar', - height: 480, - src: 'http://placeimg.com/640/480', - width: 640, - }, - name: 'Armand', - website: 'https://www.armandphilippot.com/', -}; - -export const content = - 'Harum aut cumque iure fugit neque sequi cupiditate repudiandae laudantium. Ratione aut assumenda qui illum voluptas accusamus quis officiis exercitationem. Consectetur est harum eius perspiciatis officiis nihil. Aut corporis minima debitis adipisci possimus debitis et.'; - -export const date = '2021-04-03 23:04:24'; - -export const meta = { - author, - date, -}; - -export const id = 5; - -export const saveComment = async () => { - /** Do nothing. */ -}; - -export const data: CommentProps = { - approved: true, - content, - id, - meta, - parentId: 0, - saveComment, -}; - -export const formattedDate = getFormattedDate(date); -export const formattedTime = getFormattedTime(date); diff --git a/src/components/organisms/layout/comment.stories.tsx b/src/components/organisms/layout/comment.stories.tsx index a73ba23..9c33ba3 100644 --- a/src/components/organisms/layout/comment.stories.tsx +++ b/src/components/organisms/layout/comment.stories.tsx @@ -1,5 +1,5 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Comment } from './comment'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { UserComment } from './comment'; import { data } from './comment.fixture'; const saveComment = async () => { @@ -11,7 +11,7 @@ const saveComment = async () => { */ export default { title: 'Organisms/Layout/Comment', - component: Comment, + component: UserComment, args: { canReply: true, saveComment, @@ -104,10 +104,10 @@ export default { }, }, }, -} as ComponentMeta; +} as ComponentMeta; -const Template: ComponentStory = (args) => ( - +const Template: ComponentStory = (args) => ( + ); /** diff --git a/src/components/organisms/layout/comment.test.tsx b/src/components/organisms/layout/comment.test.tsx index 1aa9e4a..b64f84a 100644 --- a/src/components/organisms/layout/comment.test.tsx +++ b/src/components/organisms/layout/comment.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from '@jest/globals'; -import { render, screen } from '../../../../tests/utils'; -import { Comment } from './comment'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { UserComment } from './comment'; import { author, data, @@ -9,40 +9,42 @@ import { id, } from './comment.fixture'; -describe('Comment', () => { +describe('UserComment', () => { it('renders an avatar', () => { - render(); + render(); expect( - screen.getByRole('img', { name: author.avatar.alt }) + rtlScreen.getByRole('img', { name: author.avatar.alt }) ).toBeInTheDocument(); }); it('renders the author website url', () => { - render(); - expect(screen.getByRole('link', { name: author.name })).toHaveAttribute( + render(); + expect(rtlScreen.getByRole('link', { name: author.name })).toHaveAttribute( 'href', author.website ); }); it('renders a permalink to the comment', () => { - render(); + render(); expect( - screen.getByRole('link', { + rtlScreen.getByRole('link', { name: `${formattedDate} at ${formattedTime}`, }) ).toHaveAttribute('href', `#comment-${id}`); }); it('renders a reply button', () => { - render(); - expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument(); + render(); + expect( + rtlScreen.getByRole('button', { name: 'Reply' }) + ).toBeInTheDocument(); }); it('does not render a reply button', () => { - render(); + render(); expect( - screen.queryByRole('button', { name: 'Reply' }) + rtlScreen.queryByRole('button', { name: 'Reply' }) ).not.toBeInTheDocument(); }); }); diff --git a/src/components/organisms/layout/comment.tsx b/src/components/organisms/layout/comment.tsx index e2a42bf..ca209f5 100644 --- a/src/components/organisms/layout/comment.tsx +++ b/src/components/organisms/layout/comment.tsx @@ -1,16 +1,17 @@ -import Image from 'next/image'; +/* eslint-disable max-statements */ +import NextImage from 'next/image'; import Script from 'next/script'; -import { FC, useCallback, useState } from 'react'; +import { type FC, useCallback, useState } from 'react'; import { useIntl } from 'react-intl'; -import { type Comment as CommentSchema, type WithContext } from 'schema-dts'; -import { type SingleComment } from '../../../types'; +import type { Comment as CommentSchema, WithContext } from 'schema-dts'; +import type { SingleComment } from '../../../types'; import { useSettings } from '../../../utils/hooks'; import { Button, Link } from '../../atoms'; import { Meta } from '../../molecules'; import { CommentForm, type CommentFormProps } from '../forms'; import styles from './comment.module.scss'; -export type CommentProps = Pick< +export type UserCommentProps = Pick< SingleComment, 'approved' | 'content' | 'id' | 'meta' | 'parentId' > & @@ -22,11 +23,11 @@ export type CommentProps = Pick< }; /** - * Comment component + * UserComment component * * Render a single comment. */ -export const Comment: FC = ({ +export const UserComment: FC = ({ approved, canReply = true, content, @@ -103,6 +104,9 @@ export const Comment: FC = ({ text: content, }; + const commentWrapperClass = `${styles.wrapper} ${styles['wrapper--comment']}`; + const formWrapperClass = `${styles.wrapper} ${styles['wrapper--form']}`; + return ( <>