aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms/widgets/links-widget
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-14 15:11:22 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-14 19:06:42 +0100
commita3a4c50f26b8750ae1c87f1f1103b84b7d2e6315 (patch)
treeae286c7c6b3ab4f556f20adf5e42b24641351296 /src/components/organisms/widgets/links-widget
parent50f1c501a87ef5f5650750dbeca797e833ec7c3a (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')
-rw-r--r--src/components/organisms/widgets/links-widget/index.ts1
-rw-r--r--src/components/organisms/widgets/links-widget/links-widget.module.scss65
-rw-r--r--src/components/organisms/widgets/links-widget/links-widget.stories.tsx80
-rw-r--r--src/components/organisms/widgets/links-widget/links-widget.test.tsx83
-rw-r--r--src/components/organisms/widgets/links-widget/links-widget.tsx78
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);