diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-10-02 17:01:57 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:14:41 +0100 |
| commit | 36890cfafeba6e30782df1260d7f9e678c7da4bf (patch) | |
| tree | 1abe20cf36d60e048b75828dd5516529e504ddd8 | |
| parent | 4f768afe543bbf9e1857c41d03804f8e37ab3512 (diff) | |
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.
34 files changed, 607 insertions, 710 deletions
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<typeof DescriptionListGroup>; - -const Template: ComponentStory<typeof DescriptionListGroup> = (args) => ( - <DescriptionListGroup {...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(<DescriptionListGroup label={itemLabel} value={itemValue} />); - expect(screen.getByRole('term')).toHaveTextContent(itemLabel); - }); - - it('renders the right number of values', () => { - render(<DescriptionListGroup label={itemLabel} value={itemValue} />); - 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<DescriptionListGroupProps> = ({ - 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 ( - <div className={groupClass}> - <dt className={`${styles.term} ${termClassName}`}>{label}</dt> - {itemValues.map((currentValue, index) => ( - <dd - className={`${styles.description} ${descriptionClassName}`} - key={`${id}-${index}`} - > - {currentValue} - </dd> - ))} - </div> - ); -}; 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<typeof DescriptionList>; - -const Template: ComponentStory<typeof DescriptionList> = (args) => ( - <DescriptionList {...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(<DescriptionList items={items} />); - 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<HTMLDListElement>, - '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<DescriptionListProps> = ({ - 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 ( - <DescriptionListGroup - key={id} - label={label} - value={value} - layout={itemLayout} - className={groupClassName} - descriptionClassName={valueClassName} - termClassName={labelClassName} - withSeparator={withSeparator} - /> - ); - }); - }; - - return ( - <dl {...props} className={listClass}> - {getItems(items)} - </dl> - ); -}; 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<typeof DescriptionList>; + +const Template: ComponentStory<typeof DescriptionList> = (args) => ( + <DescriptionList {...args} /> +); + +/** + * Description List Stories - Single term, single description + */ +export const SingleTermSingleDescription = Template.bind({}); +SingleTermSingleDescription.args = { + children: ( + <> + <Term>A term</Term> + <Description>A description of the term.</Description> + </> + ), +}; + +/** + * Description List Stories - Multiple terms, single description + */ +export const MultipleTermsSingleDescription = Template.bind({}); +MultipleTermsSingleDescription.args = { + children: ( + <> + <Term>A first term</Term> + <Term>A second term</Term> + <Term>A third term</Term> + <Description>A description of the term.</Description> + </> + ), +}; + +/** + * Description List Stories - Single term, multiple descriptions + */ +export const SingleTermMultipleDescriptions = Template.bind({}); +SingleTermMultipleDescriptions.args = { + children: ( + <> + <Term>A term</Term> + <Description>A first description of the term.</Description> + <Description>A second description of the term.</Description> + <Description>A third description of the term.</Description> + </> + ), +}; + +/** + * Description List Stories - Multiple terms, multiple descriptions + */ +export const MultipleTermsMultipleDescriptions = Template.bind({}); +MultipleTermsMultipleDescriptions.args = { + children: ( + <> + <Term>A first term</Term> + <Term>A second term</Term> + <Term>A third term</Term> + <Description>A first description of the term.</Description> + <Description>A second description of the term.</Description> + <Description>A third description of the term.</Description> + </> + ), +}; + +/** + * Description List Stories - Group of terms & descriptions + */ +export const GroupOfTermsDescriptions = Template.bind({}); +GroupOfTermsDescriptions.args = { + children: ( + <> + <Group> + <Term>A term</Term> + <Description>A description of the term.</Description> + </Group> + <Group> + <Term>Another term</Term> + <Description>A description of the other term.</Description> + </Group> + </> + ), +}; + +/** + * Description List Stories - Inlined list of term and descriptions + */ +export const InlinedList = Template.bind({}); +InlinedList.args = { + children: ( + <> + <Term>A term:</Term> + <Description>A first description of the term.</Description> + <Description>A second description of the term.</Description> + <Description>A third description of the term.</Description> + </> + ), + isInline: true, + spacing: 'xs', +}; + +/** + * Description List Stories - Inlined group of terms & descriptions + */ +export const InlinedGroupOfTermsDescriptions = Template.bind({}); +InlinedGroupOfTermsDescriptions.args = { + children: ( + <> + <Group isInline spacing="2xs"> + <Term>A term:</Term> + <Description>A description of the term.</Description> + </Group> + <Group isInline spacing="2xs"> + <Term>Another term:</Term> + <Description>A description of the other term.</Description> + </Group> + </> + ), +}; 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( + <DescriptionList> + <Term>{term}</Term> + <Description>{desc}</Description> + </DescriptionList> + ); + + 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( + <DescriptionList> + <Group> + <Term>{term}</Term> + <Description>{desc}</Description> + </Group> + </DescriptionList> + ); + + 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( + <DescriptionList isInline> + <Term>{term}</Term> + <Description>{desc}</Description> + </DescriptionList> + ); + + 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( + <DescriptionList> + <Term>{term}</Term> + <Description>{desc}</Description> + </DescriptionList> + ); + + 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<HTMLDListElement>, + '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 ( + <dl {...props} className={listClass} ref={ref} style={listStyles}> + {children} + </dl> + ); +}; + +/** + * 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<HTMLElement>; + +const DescriptionWithRef: ForwardRefRenderFunction< + HTMLElement, + DescriptionProps +> = ({ children, className = '', ...props }, ref) => { + const descriptionClass = `${styles.description} ${className}`; + + return ( + <dd {...props} className={descriptionClass} ref={ref}> + {children} + </dd> + ); +}; + +/** + * 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<HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement, GroupProps> = ( + { + 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 ( + <div {...props} className={groupClass} ref={ref} style={groupStyles}> + {children} + </div> + ); +}; + +/** + * 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<HTMLElement>; + +const TermWithRef: ForwardRefRenderFunction<HTMLElement, TermProps> = ( + { children, className = '', ...props }, + ref +) => { + const termClass = `${styles.term} ${className}`; + + return ( + <dt {...props} className={termClass} ref={ref}> + {children} + </dt> + ); +}; + +/** + * 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<CardProps> = ({ {tagline ? <div className={styles.tagline}>{tagline}</div> : null} {meta ? ( <footer className={styles.footer}> - <Meta - data={meta} - // eslint-disable-next-line react/jsx-no-literals -- Hardcoded config - layout="inline" - className={styles.list} - groupClassName={styles.meta__item} - labelClassName={styles.meta__label} - valueClassName={styles.meta__value} - /> + <Meta className={styles.list} data={meta} spacing="sm" /> </footer> ) : null} </article> 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<typeof MetaComponent>; @@ -51,10 +27,10 @@ const Template: ComponentStory<typeof MetaComponent> = (args) => ( const data: MetaData = { publication: { date: '2022-04-09', time: '01:04:00' }, thematics: [ - <a key="category1" href="#"> + <a key="category1" href="#a"> Category 1 </a>, - <a key="category2" href="#"> + <a key="category2" href="#b"> Category 2 </a>, ], 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: [ - <a key="category1" href="#"> + <a key="category1" href="#a"> Category 1 </a>, - <a key="category2" href="#"> + <a key="category2" href="#b"> Category 2 </a>, ], @@ -19,7 +19,7 @@ describe('Meta', () => { it('format a date string', () => { render(<Meta data={data} />); 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<DescriptionListProps, 'children'> & { /** * 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<MetaProps> = ({ + 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<MetaProps> = ({ * @param {ValueOf<MetaData>} value - The meta value. * @returns {string|ReactNode|ReactNode[]} - The formatted value. */ - const getValue = <T extends MetaKey>( + const getValue = <T extends keyof MetaData>( key: T, value: MetaData[T] ): string | ReactNode | ReactNode[] => { @@ -338,12 +335,11 @@ export const Meta: FC<MetaProps> = ({ { postsCount: value as number } ); case 'website': - const url = value as string; - return ( - <Link href={url} external={true}> - {url} + return typeof value === 'string' ? ( + <Link href={value} external={true}> + {value} </Link> - ); + ) : null; default: return value as string | ReactNode | ReactNode[]; } @@ -355,36 +351,45 @@ export const Meta: FC<MetaProps> = ({ * @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 ( + <Group isInline key={key} spacing="2xs"> + <Term className={styles.term}> + {isCustomMeta(key, meta) ? meta.label : getLabel(key)} + </Term> + {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. */ + <Description className={styles.description} key={index}> + {isCustomMeta(key, singleMeta) + ? singleMeta + : getValue(key, singleMeta)} + </Description> + )) + ) : ( + <Description className={styles.description}> + {isCustomMeta(key, meta) ? meta.value : getValue(key, meta)} + </Description> + )} + </Group> + ); + }); return listItems; }; return ( - <DescriptionList - {...props} - items={getItems(data)} - withSeparator={withSeparator} - /> + <DescriptionList {...props} className={listClass} isInline={isInline}> + {getItems(data)} + </DescriptionList> ); }; 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<FooterProps, 'children'> & { * Render a footer to display page meta. */ export const PageFooter: FC<PageFooterProps> = ({ meta, ...props }) => ( - <Footer {...props}> - {meta ? <Meta data={meta} withSeparator={false} /> : null} - </Footer> + <Footer {...props}>{meta ? <Meta data={meta} /> : null}</Footer> ); 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<PageHeaderProps> = ({ {title} </Heading> {meta ? ( - <Meta - className={styles.meta} - data={meta} - // eslint-disable-next-line react/jsx-no-literals -- Layout allowed - itemsLayout="inline" - // eslint-disable-next-line react/jsx-no-literals -- Layout allowed - layout="column" - /> + <Meta className={styles.meta} data={meta} isInline spacing="xs" /> ) : null} {intro ? getIntro() : null} </div> diff --git a/src/components/organisms/layout/comment.fixture.tsx b/src/components/organisms/layout/comment.fixture.ts index eee7981..f626be9 100644 --- a/src/components/organisms/layout/comment.fixture.tsx +++ b/src/components/organisms/layout/comment.fixture.ts @@ -1,5 +1,5 @@ import { getFormattedDate, getFormattedTime } from '../../../utils/helpers'; -import { CommentProps } from './comment'; +import type { UserCommentProps } from './comment'; export const author = { avatar: { @@ -28,7 +28,7 @@ export const saveComment = async () => { /** Do nothing. */ }; -export const data: CommentProps = { +export const data: UserCommentProps = { approved: true, content, id, 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<typeof Comment>; +} as ComponentMeta<typeof UserComment>; -const Template: ComponentStory<typeof Comment> = (args) => ( - <Comment {...args} /> +const Template: ComponentStory<typeof UserComment> = (args) => ( + <UserComment {...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(<Comment canReply={true} {...data} />); + render(<UserComment canReply={true} {...data} />); expect( - screen.getByRole('img', { name: author.avatar.alt }) + rtlScreen.getByRole('img', { name: author.avatar.alt }) ).toBeInTheDocument(); }); it('renders the author website url', () => { - render(<Comment canReply={true} {...data} />); - expect(screen.getByRole('link', { name: author.name })).toHaveAttribute( + render(<UserComment canReply={true} {...data} />); + expect(rtlScreen.getByRole('link', { name: author.name })).toHaveAttribute( 'href', author.website ); }); it('renders a permalink to the comment', () => { - render(<Comment canReply={true} {...data} />); + render(<UserComment canReply={true} {...data} />); expect( - screen.getByRole('link', { + rtlScreen.getByRole('link', { name: `${formattedDate} at ${formattedTime}`, }) ).toHaveAttribute('href', `#comment-${id}`); }); it('renders a reply button', () => { - render(<Comment canReply={true} {...data} />); - expect(screen.getByRole('button', { name: 'Reply' })).toBeInTheDocument(); + render(<UserComment canReply={true} {...data} />); + expect( + rtlScreen.getByRole('button', { name: 'Reply' }) + ).toBeInTheDocument(); }); it('does not render a reply button', () => { - render(<Comment canReply={false} {...data} />); + render(<UserComment canReply={false} {...data} />); 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<CommentProps> = ({ +export const UserComment: FC<UserCommentProps> = ({ approved, canReply = true, content, @@ -103,6 +104,9 @@ export const Comment: FC<CommentProps> = ({ text: content, }; + const commentWrapperClass = `${styles.wrapper} ${styles['wrapper--comment']}`; + const formWrapperClass = `${styles.wrapper} ${styles['wrapper--form']}`; + return ( <> <Script @@ -110,14 +114,11 @@ export const Comment: FC<CommentProps> = ({ id="schema-comments" type="application/ld+json" /> - <article - className={`${styles.wrapper} ${styles['wrapper--comment']}`} - id={`comment-${id}`} - > + <article className={commentWrapperClass} id={`comment-${id}`}> <header className={styles.header}> - {author.avatar && ( + {author.avatar ? ( <div className={styles.avatar}> - <Image + <NextImage {...props} alt={author.avatar.alt} fill @@ -125,7 +126,7 @@ export const Comment: FC<CommentProps> = ({ style={{ objectFit: 'cover' }} /> </div> - )} + ) : null} {author.website ? ( <Link href={author.website} className={styles.author}> {author.name} @@ -143,31 +144,29 @@ export const Comment: FC<CommentProps> = ({ target: `#comment-${id}`, }, }} - groupClassName={styles.date__item} - itemsLayout="inline" - layout="inline" + isInline /> <div className={styles.body} dangerouslySetInnerHTML={{ __html: content }} /> <footer className={styles.footer}> - {canReply && ( + {canReply ? ( <Button kind="tertiary" onClick={handleReply}> {buttonLabel} </Button> - )} + ) : null} </footer> </article> - {isReplying && ( + {isReplying ? ( <CommentForm - className={`${styles.wrapper} ${styles['wrapper--form']}`} + className={formWrapperClass} Notice={Notice} parentId={id} saveComment={saveComment} title={formTitle} /> - )} + ) : null} </> ); }; diff --git a/src/components/organisms/layout/comments-list.tsx b/src/components/organisms/layout/comments-list.tsx index 103bfb4..af0152a 100644 --- a/src/components/organisms/layout/comments-list.tsx +++ b/src/components/organisms/layout/comments-list.tsx @@ -1,14 +1,15 @@ import type { FC } from 'react'; import type { SingleComment } from '../../../types'; import { List, ListItem } from '../../atoms'; - -// eslint-disable-next-line @typescript-eslint/no-shadow -import { Comment, type CommentProps } from './comment'; +import { UserComment, type UserCommentProps } from './comment'; // eslint-disable-next-line @typescript-eslint/no-magic-numbers export type CommentsListDepth = 0 | 1 | 2 | 3 | 4; -export type CommentsListProps = Pick<CommentProps, 'Notice' | 'saveComment'> & { +export type CommentsListProps = Pick< + UserCommentProps, + 'Notice' | 'saveComment' +> & { /** * An array of comments. */ @@ -44,7 +45,7 @@ export const CommentsList: FC<CommentsListProps> = ({ return commentsList.map(({ replies, ...comment }) => ( <ListItem key={comment.id}> - <Comment + <UserComment canReply={!isLastLevel} Notice={Notice} saveComment={saveComment} diff --git a/src/components/organisms/layout/overview.module.scss b/src/components/organisms/layout/overview.module.scss index e4f6a6a..59ce167 100644 --- a/src/components/organisms/layout/overview.module.scss +++ b/src/components/organisms/layout/overview.module.scss @@ -29,6 +29,10 @@ dd { padding: 0 var(--spacing-2xs); border: fun.convert-px(1) solid var(--color-border-dark); + + &::before { + display: none; + } } } } diff --git a/src/components/organisms/layout/overview.tsx b/src/components/organisms/layout/overview.tsx index 51920f6..bb319c4 100644 --- a/src/components/organisms/layout/overview.tsx +++ b/src/components/organisms/layout/overview.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import type { FC } from 'react'; import { Meta, type MetaData, @@ -47,12 +47,10 @@ export const Overview: FC<OverviewProps> = ({ return ( <div className={`${styles.wrapper} ${className}`}> - {cover && <ResponsiveImage className={styles.cover} {...cover} />} + {cover ? <ResponsiveImage className={styles.cover} {...cover} /> : null} <Meta className={`${styles.meta} ${metaModifier}`} data={{ ...remainingMeta, technologies }} - layout="inline" - withSeparator={false} /> </div> ); diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx index 99f6c85..d66af75 100644 --- a/src/components/organisms/layout/summary.tsx +++ b/src/components/organisms/layout/summary.tsx @@ -135,16 +135,7 @@ export const Summary: FC<SummaryProps> = ({ </ButtonLink> </div> <footer className={styles.footer}> - <Meta - className={styles.meta} - data={getMeta()} - groupClassName={styles.meta__item} - // eslint-disable-next-line react/jsx-no-literals -- Layout allowed - itemsLayout="stacked" - // eslint-disable-next-line react/jsx-no-literals -- Layout allowed - layout="column" - withSeparator={false} - /> + <Meta className={styles.meta} data={getMeta()} spacing="xs" /> </footer> </article> ); diff --git a/src/styles/abstracts/placeholders/_lists.scss b/src/styles/abstracts/placeholders/_lists.scss index 8a6b1d9..780fd21 100644 --- a/src/styles/abstracts/placeholders/_lists.scss +++ b/src/styles/abstracts/placeholders/_lists.scss @@ -37,12 +37,16 @@ } } +%flex-list { + display: flex; + gap: var(--itemSpacing, 0); +} + %inline-list { @extend %reset-list; + @extend %flex-list; - display: flex; flex-flow: row wrap; - gap: var(--itemSpacing, 0); list-style-position: inside; } @@ -51,3 +55,25 @@ margin-block-end: var(--itemSpacing); } } + +%inline-description-list { + @extend %flex-list; + + flex-flow: row wrap; +} + +%stack-description-list { + @extend %flex-list; + + flex-flow: column wrap; +} + +%term { + color: var(--color-fg-light); + font-weight: 600; +} + +%description { + margin: 0; + word-break: break-all; +} diff --git a/src/styles/pages/partials/_article-lists.scss b/src/styles/pages/partials/_article-lists.scss index e872d3c..562ee70 100644 --- a/src/styles/pages/partials/_article-lists.scss +++ b/src/styles/pages/partials/_article-lists.scss @@ -13,24 +13,17 @@ } dl { - display: flex; - flex-flow: row wrap; - gap: var(--spacing-2xs); + @extend %inline-description-list; + width: fit-content; margin: var(--spacing-sm) 0; - - & & { - margin: var(--spacing-2xs) 0 0; - } } dt { - color: var(--color-fg-light); - font-weight: 600; + @extend %term; } dd { - margin: 0; - word-break: break-all; + @extend %description; } } |
