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/widgets/links-widget | |
| 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/widgets/links-widget')
5 files changed, 307 insertions, 0 deletions
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); |
