diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-14 15:11:22 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-14 19:06:42 +0100 |
| commit | a3a4c50f26b8750ae1c87f1f1103b84b7d2e6315 (patch) | |
| tree | ae286c7c6b3ab4f556f20adf5e42b24641351296 /src/components/organisms | |
| parent | 50f1c501a87ef5f5650750dbeca797e833ec7c3a (diff) | |
refactor(components): replace LinksListWidget with LinksWidget
* avoid List component repeat
* rewrite tests and CSS
* add an id to LinksWidgetItemData (previously LinksListItems) type
because the label could be duplicated
Diffstat (limited to 'src/components/organisms')
11 files changed, 314 insertions, 311 deletions
diff --git a/src/components/organisms/widgets/index.ts b/src/components/organisms/widgets/index.ts index 2286898..972561e 100644 --- a/src/components/organisms/widgets/index.ts +++ b/src/components/organisms/widgets/index.ts @@ -1,5 +1,5 @@ export * from './image-widget'; -export * from './links-list-widget'; +export * from './links-widget'; export * from './sharing-widget'; export * from './social-media-widget'; export * from './table-of-contents'; diff --git a/src/components/organisms/widgets/links-list-widget.module.scss b/src/components/organisms/widgets/links-list-widget.module.scss deleted file mode 100644 index 4efc2d4..0000000 --- a/src/components/organisms/widgets/links-list-widget.module.scss +++ /dev/null @@ -1,71 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/placeholders"; - -.widget { - .list { - .list { - padding: 0; - - > *:first-child { - border-top: fun.convert-px(1) solid var(--color-primary); - } - } - - &__link { - display: block; - padding: var(--spacing-2xs) var(--spacing-xs); - background: none; - text-decoration: underline solid transparent 0; - - &:hover, - &:focus { - background: var(--color-bg-secondary); - font-weight: 600; - } - - &:focus { - color: var(--color-primary); - text-decoration-color: var(--color-primary-light); - text-decoration-thickness: 0.25ex; - } - - &:active { - background: var(--color-bg-tertiary); - text-decoration-color: transparent; - text-decoration-thickness: 0; - } - } - - &--ordered { - counter-reset: link; - - .list__link { - counter-increment: link; - - &::before { - padding-right: var(--spacing-2xs); - content: counters(link, ".") ". "; - color: var(--color-secondary); - } - } - } - - &__item { - &:not(:last-child) { - border-bottom: fun.convert-px(1) solid var(--color-primary); - } - - > .list { - .list__link { - padding-left: var(--spacing-md); - } - - .list__item > .list { - .list__link { - padding-left: var(--spacing-xl); - } - } - } - } - } -} diff --git a/src/components/organisms/widgets/links-list-widget.stories.tsx b/src/components/organisms/widgets/links-list-widget.stories.tsx deleted file mode 100644 index 6e5f170..0000000 --- a/src/components/organisms/widgets/links-list-widget.stories.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Heading } from '../../atoms'; -import { LinksListWidget } from './links-list-widget'; - -/** - * LinksListWidget - Storybook Meta - */ -export default { - title: 'Organisms/Widgets/LinksList', - component: LinksListWidget, - args: { - isOrdered: false, - }, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the list wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - items: { - description: 'The widget data.', - type: { - name: 'object', - required: true, - value: {}, - }, - }, - }, -} as ComponentMeta<typeof LinksListWidget>; - -const Template: ComponentStory<typeof LinksListWidget> = (args) => ( - <LinksListWidget {...args} /> -); - -const items = [ - { name: 'Level 1: Item 1', url: '#' }, - { - name: 'Level 1: Item 2', - url: '#', - child: [ - { name: 'Level 2: Item 1', url: '#' }, - { name: 'Level 2: Item 2', url: '#' }, - { - name: 'Level 2: Item 3', - url: '#', - child: [ - { name: 'Level 3: Item 1', url: '#' }, - { name: 'Level 3: Item 2', url: '#' }, - ], - }, - { name: 'Level 2: Item 4', url: '#' }, - ], - }, - { name: 'Level 1: Item 3', url: '#' }, - { name: 'Level 1: Item 4', url: '#' }, -]; - -/** - * Links List Widget Stories - Unordered - */ -export const Unordered = Template.bind({}); -Unordered.args = { - heading: ( - <Heading isFake level={3}> - Quo et totam - </Heading> - ), - items, -}; - -/** - * Links List Widget Stories - Ordered - */ -export const Ordered = Template.bind({}); -Ordered.args = { - heading: ( - <Heading isFake level={3}> - Quo et totam - </Heading> - ), - isOrdered: true, - items, -}; diff --git a/src/components/organisms/widgets/links-list-widget.test.tsx b/src/components/organisms/widgets/links-list-widget.test.tsx deleted file mode 100644 index 2a914e7..0000000 --- a/src/components/organisms/widgets/links-list-widget.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '@testing-library/react'; -import { Heading } from '../../atoms'; -import { LinksListWidget } from './links-list-widget'; - -const title = 'Voluptatem minus autem'; - -const items = [ - { name: 'Item 1', url: '/item-1' }, - { name: 'Item 2', url: '/item-2' }, - { name: 'Item 3', url: '/item-3' }, -]; - -describe('LinksListWidget', () => { - it('renders a widget title', () => { - render( - <LinksListWidget - heading={<Heading level={3}>{title}</Heading>} - items={items} - /> - ); - expect( - rtlScreen.getByRole('heading', { level: 3, name: new RegExp(title, 'i') }) - ).toBeInTheDocument(); - }); - - it('renders the correct number of items', () => { - render( - <LinksListWidget - heading={ - <Heading isFake level={3}> - {title} - </Heading> - } - items={items} - /> - ); - expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length); - }); - - it('renders some links', () => { - render( - <LinksListWidget - heading={ - <Heading isFake level={3}> - {title} - </Heading> - } - items={items} - /> - ); - expect( - rtlScreen.getByRole('link', { name: items[0].name }) - ).toHaveAttribute('href', items[0].url); - }); -}); diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx deleted file mode 100644 index 17a5884..0000000 --- a/src/components/organisms/widgets/links-list-widget.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { FC } from 'react'; -import { slugify } from '../../../utils/helpers'; -import { Link, List, ListItem } from '../../atoms'; -import { Collapsible, type CollapsibleProps } from '../../molecules'; -import styles from './links-list-widget.module.scss'; - -export type LinksListItems = { - /** - * An array of name/url couple child of this list item. - */ - child?: LinksListItems[]; - /** - * The item name. - */ - name: string; - /** - * The item url. - */ - url: string; -}; - -export type LinksListWidgetProps = Omit< - CollapsibleProps, - 'children' | 'disablePadding' | 'hasBorders' -> & { - className?: string; - /** - * Should the links be ordered? - * - * @default false - */ - isOrdered?: boolean; - /** - * An array of name/url couple. - */ - items: LinksListItems[]; -}; - -/** - * LinksListWidget component - * - * Render a list of links inside a widget. - */ -export const LinksListWidget: FC<LinksListWidgetProps> = ({ - className = '', - isOrdered = false, - items, - ...props -}) => { - const listKindClass = `list--${isOrdered ? 'ordered' : 'unordered'}`; - - /** - * Format the widget data to be used as List items. - * - * @param {LinksListItems[]} data - The widget data. - * @returns {ListItem[]} The list items data. - */ - const getListItems = (data: LinksListItems[]) => - data.map((item) => ( - <ListItem className={styles.list__item} key={slugify(item.name)}> - <Link className={styles.list__link} href={item.url}> - {item.name} - </Link> - {item.child?.length ? ( - <List - className={`${styles.list} ${styles[listKindClass]} ${className}`} - hideMarker - isOrdered={isOrdered} - > - {getListItems(item.child)} - </List> - ) : null} - </ListItem> - )); - - return ( - <Collapsible {...props} className={styles.widget} disablePadding hasBorders> - <List - className={`${styles.list} ${styles[listKindClass]} ${className}`} - hideMarker - isOrdered={isOrdered} - > - {getListItems(items)} - </List> - </Collapsible> - ); -}; diff --git a/src/components/organisms/widgets/links-widget/index.ts b/src/components/organisms/widgets/links-widget/index.ts new file mode 100644 index 0000000..cf1de6d --- /dev/null +++ b/src/components/organisms/widgets/links-widget/index.ts @@ -0,0 +1 @@ +export * from './links-widget'; diff --git a/src/components/organisms/widgets/links-widget/links-widget.module.scss b/src/components/organisms/widgets/links-widget/links-widget.module.scss new file mode 100644 index 0000000..296d8e4 --- /dev/null +++ b/src/components/organisms/widgets/links-widget/links-widget.module.scss @@ -0,0 +1,65 @@ +@use "../../../../styles/abstracts/functions" as fun; +@use "../../../../styles/abstracts/placeholders"; + +.item { + &:not(:last-child) { + border-bottom: fun.convert-px(1) solid var(--color-primary); + } +} + +.link { + display: block; + padding: var(--spacing-2xs) var(--spacing-sm); + background: none; + text-decoration: underline solid transparent 0; + + &:hover, + &:focus { + background: var(--color-bg-secondary); + } + + &:focus { + color: var(--color-primary); + text-decoration-color: var(--color-primary-light); + text-decoration-thickness: 0.25ex; + } + + &:active { + background: var(--color-bg-tertiary); + text-decoration-color: transparent; + text-decoration-thickness: 0; + } +} + +.list { + padding: 0; + + &--ordered { + counter-reset: link; + + .link { + counter-increment: link; + + &::before { + content: counters(link, ".") ". "; + display: inline-block; + padding-inline-end: var(--spacing-2xs); + color: var(--color-secondary); + } + } + } + + & & { + border-top: fun.convert-px(1) solid var(--color-primary); + + .link { + padding-left: var(--spacing-lg); + } + } + + & & & { + .link { + padding-left: var(--spacing-2xl); + } + } +} diff --git a/src/components/organisms/widgets/links-widget/links-widget.stories.tsx b/src/components/organisms/widgets/links-widget/links-widget.stories.tsx new file mode 100644 index 0000000..3a0b027 --- /dev/null +++ b/src/components/organisms/widgets/links-widget/links-widget.stories.tsx @@ -0,0 +1,80 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Heading } from '../../../atoms'; +import { LinksWidget, type LinksWidgetItemData } from './links-widget'; + +/** + * LinksWidget - Storybook Meta + */ +export default { + title: 'Organisms/Widgets/Links', + component: LinksWidget, + args: { + isOrdered: false, + }, + argTypes: { + items: { + description: 'The links data.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + }, +} as ComponentMeta<typeof LinksWidget>; + +const Template: ComponentStory<typeof LinksWidget> = (args) => ( + <LinksWidget {...args} /> +); + +const items = [ + { id: 'item11', label: 'Level 1: Item 1', url: '#' }, + { + id: 'item12', + label: 'Level 1: Item 2', + url: '#', + child: [ + { id: 'item21', label: 'Level 2: Item 1', url: '#' }, + { id: 'item22', label: 'Level 2: Item 2', url: '#' }, + { + id: 'item23', + label: 'Level 2: Item 3', + url: '#', + child: [ + { id: 'item31', label: 'Level 3: Item 1', url: '#' }, + { id: 'item32', label: 'Level 3: Item 2', url: '#' }, + ], + }, + { id: 'item24', label: 'Level 2: Item 4', url: '#' }, + ], + }, + { id: 'item13', label: 'Level 1: Item 3', url: '#' }, + { id: 'item14', label: 'Level 1: Item 4', url: '#' }, +] satisfies LinksWidgetItemData[]; + +/** + * Links List Widget Stories - Unordered + */ +export const Unordered = Template.bind({}); +Unordered.args = { + heading: ( + <Heading isFake level={3}> + Quo et totam + </Heading> + ), + items, +}; + +/** + * Links List Widget Stories - Ordered + */ +export const Ordered = Template.bind({}); +Ordered.args = { + heading: ( + <Heading isFake level={3}> + Quo et totam + </Heading> + ), + isOrdered: true, + items, +}; diff --git a/src/components/organisms/widgets/links-widget/links-widget.test.tsx b/src/components/organisms/widgets/links-widget/links-widget.test.tsx new file mode 100644 index 0000000..f0edecc --- /dev/null +++ b/src/components/organisms/widgets/links-widget/links-widget.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { Heading } from '../../../atoms'; +import { LinksWidget, type LinksWidgetItemData } from './links-widget'; + +describe('LinksWidget', () => { + it('renders the widget heading and a list of links', () => { + const heading = 'modi sit fugiat'; + const headingLvl = 3; + const items = [ + { id: 'item1', label: 'Link 1', url: '#link1' }, + { id: 'item2', label: 'Link 2', url: '#link2' }, + { id: 'item3', label: 'Link 3', url: '#link3' }, + ] satisfies LinksWidgetItemData[]; + + render( + <LinksWidget + heading={<Heading level={headingLvl}>{heading}</Heading>} + items={items} + /> + ); + + expect( + rtlScreen.getByRole('heading', { level: headingLvl }) + ).toHaveTextContent(heading); + expect(rtlScreen.getAllByRole('link').length).toBe(items.length); + }); + + it('can render a nested list of links', () => { + const heading = 'modi sit fugiat'; + const headingLvl = 3; + const items = [ + { id: 'item1', label: 'Link 1', url: '#link1' }, + { + id: 'item2', + label: 'Link 2', + url: '#link2', + child: [ + { id: 'subitem1', label: 'Nested link 1', url: '#nested-link1' }, + { id: 'subitem2', label: 'Nested link 2', url: '#nested-link2' }, + ], + }, + { id: 'item3', label: 'Link 3', url: '#link3' }, + ] satisfies LinksWidgetItemData[]; + + render( + <LinksWidget + heading={<Heading level={headingLvl}>{heading}</Heading>} + items={items} + /> + ); + + const totalLinks = + items.length + + items.reduce( + (accumulator, currentValue) => + accumulator + (currentValue.child?.length ?? 0), + 0 + ); + + expect(rtlScreen.getAllByRole('link').length).toBe(totalLinks); + }); + + it('can render an ordered list of links', () => { + const heading = 'modi sit fugiat'; + const headingLvl = 3; + const items = [ + { id: 'item1', label: 'Link 1', url: '#link1' }, + { id: 'item2', label: 'Link 2', url: '#link2' }, + { id: 'item3', label: 'Link 3', url: '#link3' }, + ] satisfies LinksWidgetItemData[]; + + render( + <LinksWidget + heading={<Heading level={headingLvl}>{heading}</Heading>} + isOrdered + items={items} + /> + ); + + expect(rtlScreen.getByRole('list')).toHaveClass(`list--ordered`); + }); +}); diff --git a/src/components/organisms/widgets/links-widget/links-widget.tsx b/src/components/organisms/widgets/links-widget/links-widget.tsx new file mode 100644 index 0000000..e28e15f --- /dev/null +++ b/src/components/organisms/widgets/links-widget/links-widget.tsx @@ -0,0 +1,78 @@ +import { type ForwardRefRenderFunction, forwardRef, useCallback } from 'react'; +import { Link, List, ListItem } from '../../../atoms'; +import { Collapsible, type CollapsibleProps } from '../../../molecules'; +import styles from './links-widget.module.scss'; + +export type LinksWidgetItemData = { + /** + * An array of name/url couple child of this list item. + */ + child?: LinksWidgetItemData[]; + /** + * The item id. + */ + id: string; + /** + * The item name. + */ + label: string; + /** + * The item url. + */ + url: string; +}; + +export type LinksWidgetProps = Omit< + CollapsibleProps, + 'children' | 'disablePadding' | 'hasBorders' +> & { + /** + * Should the links be ordered? + * + * @default false + */ + isOrdered?: boolean; + /** + * The links. + */ + items: LinksWidgetItemData[]; +}; + +const LinksWidgetWithRef: ForwardRefRenderFunction< + HTMLDivElement, + LinksWidgetProps +> = ({ isOrdered = false, items, ...props }, ref) => { + const listClass = [ + styles.list, + styles[isOrdered ? 'list--ordered' : 'list--unordered'], + ].join(' '); + + const getLinksList = useCallback( + (data: LinksWidgetItemData[]) => ( + <List className={listClass} hideMarker isOrdered={isOrdered}> + {data.map((item) => ( + <ListItem className={styles.item} key={item.id}> + <Link className={styles.link} href={item.url}> + {item.label} + </Link> + {item.child?.length ? getLinksList(item.child) : null} + </ListItem> + ))} + </List> + ), + [isOrdered, listClass] + ); + + return ( + <Collapsible {...props} disablePadding hasBorders ref={ref}> + {getLinksList(items)} + </Collapsible> + ); +}; + +/** + * LinksWidget component + * + * Render a list of links inside a widget. + */ +export const LinksWidget = forwardRef(LinksWidgetWithRef); diff --git a/src/components/organisms/widgets/table-of-contents.tsx b/src/components/organisms/widgets/table-of-contents.tsx index 8892485..5f14415 100644 --- a/src/components/organisms/widgets/table-of-contents.tsx +++ b/src/components/organisms/widgets/table-of-contents.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react'; import { useIntl } from 'react-intl'; import { useHeadingsTree, type Heading } from '../../../utils/hooks'; -import { type LinksListItems, LinksListWidget } from './links-list-widget'; +import { Heading as HeadingComponent } from '../../atoms'; +import { LinksWidget, type LinksWidgetItemData } from './links-widget'; import styles from './table-of-contents.module.scss'; -import { Heading as HeadingComponent } from 'src/components/atoms'; type TableOfContentsProps = { /** @@ -32,17 +32,18 @@ export const TableOfContents: FC<TableOfContentsProps> = ({ wrapper }) => { * @param {Heading[]} tree - The headings tree. * @returns {LinksListItems[]} The list items. */ - const getItems = (tree: Heading[]): LinksListItems[] => + const getItems = (tree: Heading[]): LinksWidgetItemData[] => tree.map((heading) => { return { - name: heading.title, + id: heading.id, + label: heading.title, url: `#${heading.id}`, child: getItems(heading.children), }; }); return ( - <LinksListWidget + <LinksWidget className={styles.list} heading={ <HeadingComponent isFake level={3}> |
