aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-29 21:29:45 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:14:41 +0100
commit4f768afe543bbf9e1857c41d03804f8e37ab3512 (patch)
treed751219a147688b5665c51db3c8dbdca1f1345ee /src/components/atoms
parent9128c224c65f8f2a172b22a443ccb4573c7acd90 (diff)
refactor(components): rewrite List component
* change `items` prop to children * replace `kind` prop with `isHierarchical`, `isOrdered` & `isInline` props * add `hideMarker` prop * add `spacing` prop to control item spacing * move lists styles to Sass placeholders to avoid repeats because of headless WordPress
Diffstat (limited to 'src/components/atoms')
-rw-r--r--src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx2
-rw-r--r--src/components/atoms/images/logo/logo.module.scss2
-rw-r--r--src/components/atoms/lists/list.module.scss45
-rw-r--r--src/components/atoms/lists/list.stories.tsx109
-rw-r--r--src/components/atoms/lists/list.test.tsx27
-rw-r--r--src/components/atoms/lists/list.tsx74
-rw-r--r--src/components/atoms/lists/list/index.ts2
-rw-r--r--src/components/atoms/lists/list/list-item.tsx23
-rw-r--r--src/components/atoms/lists/list/list.module.scss28
-rw-r--r--src/components/atoms/lists/list/list.stories.tsx196
-rw-r--r--src/components/atoms/lists/list/list.test.tsx110
-rw-r--r--src/components/atoms/lists/list/list.tsx150
12 files changed, 511 insertions, 257 deletions
diff --git a/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx b/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx
index 0dc701a..9d863a0 100644
--- a/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx
+++ b/src/components/atoms/images/icons/svg-paths/icons-paths/arrow-icon-paths.tsx
@@ -1,6 +1,6 @@
/* eslint-disable react/jsx-no-literals */
import type { FC } from 'react';
-import type { Position } from '../../../../../types';
+import type { Position } from '../../../../../../types';
export type ArrowOrientation = Exclude<Position, 'center'>;
diff --git a/src/components/atoms/images/logo/logo.module.scss b/src/components/atoms/images/logo/logo.module.scss
index bd4ee90..beeb7ff 100644
--- a/src/components/atoms/images/logo/logo.module.scss
+++ b/src/components/atoms/images/logo/logo.module.scss
@@ -1,4 +1,4 @@
-@use "../../../styles/abstracts/functions" as fun;
+@use "../../../../styles/abstracts/functions" as fun;
.wrapper {
width: var(--logo-size, fun.convert-px(100));
diff --git a/src/components/atoms/lists/list.module.scss b/src/components/atoms/lists/list.module.scss
deleted file mode 100644
index cbb391e..0000000
--- a/src/components/atoms/lists/list.module.scss
+++ /dev/null
@@ -1,45 +0,0 @@
-@use "../../../styles/abstracts/placeholders";
-
-.list {
- margin: 0;
-
- ::marker {
- color: var(--color-primary-dark);
- }
-
- &--ordered {
- padding: 0;
- counter-reset: li;
- list-style-type: none;
- }
-
- &--ordered &__item {
- display: table;
- counter-increment: li;
-
- &::before {
- content: counters(li, ".") ". ";
- display: table-cell;
- padding-right: var(--spacing-2xs);
- color: var(--color-secondary);
- }
- }
-
- &--unordered {
- padding: 0 0 0 var(--spacing-sm);
- }
-
- &--flex {
- @extend %reset-list;
-
- display: flex;
- flex-flow: row wrap;
- gap: var(--spacing-sm);
- }
-
- &--flex &--flex {
- display: initial;
- position: relative;
- top: var(--spacing-2xs);
- }
-}
diff --git a/src/components/atoms/lists/list.stories.tsx b/src/components/atoms/lists/list.stories.tsx
deleted file mode 100644
index c4f3c3b..0000000
--- a/src/components/atoms/lists/list.stories.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { ComponentMeta, ComponentStory } from '@storybook/react';
-import { List, type ListItem } from './list';
-
-/**
- * List - Storybook Meta
- */
-export default {
- title: 'Atoms/Typography/Lists',
- component: List,
- args: {
- kind: 'unordered',
- },
- argTypes: {
- className: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the list wrapper',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- items: {
- control: {
- type: null,
- },
- description: 'The list items.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- itemsClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the list items.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- kind: {
- control: {
- type: 'select',
- },
- description: 'The list kind: flex, ordered or unordered.',
- options: ['flex', 'ordered', 'unordered'],
- table: {
- category: 'Options',
- defaultValue: { summary: 'unordered' },
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- },
-} as ComponentMeta<typeof List>;
-
-const Template: ComponentStory<typeof List> = (args) => <List {...args} />;
-
-const items: ListItem[] = [
- { id: 'item-1', value: 'Item 1' },
- { id: 'item-2', value: 'Item 2' },
- {
- child: [
- { id: 'nested-item-1', value: 'Nested item 1' },
- { id: 'nested-item-2', value: 'Nested item 2' },
- ],
- id: 'item-3',
- value: 'Item 3',
- },
- { id: 'item-4', value: 'Item 4' },
-];
-
-/**
- * List Stories - Flex list
- */
-export const Flex = Template.bind({});
-Flex.args = {
- items,
- kind: 'flex',
-};
-
-/**
- * List Stories - Ordered list
- */
-export const Ordered = Template.bind({});
-Ordered.args = {
- items,
- kind: 'ordered',
-};
-
-/**
- * List Stories - Unordered list
- */
-export const Unordered = Template.bind({});
-Unordered.args = {
- items,
-};
diff --git a/src/components/atoms/lists/list.test.tsx b/src/components/atoms/lists/list.test.tsx
deleted file mode 100644
index 89de922..0000000
--- a/src/components/atoms/lists/list.test.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import { render, screen } from '../../../../tests/utils';
-import { List, type ListItem } from './list';
-
-const items: ListItem[] = [
- { id: 'item-1', value: 'Item 1' },
- { id: 'item-2', value: 'Item 2' },
- {
- child: [
- { id: 'nested-item-1', value: 'Nested item 1' },
- { id: 'nested-item-2', value: 'Nested item 2' },
- ],
- id: 'item-3',
- value: 'Item 3',
- },
- { id: 'item-4', value: 'Item 4' },
-];
-
-describe('List', () => {
- it('renders a nested unordered list', () => {
- render(<List items={items} />);
- const listItems = screen.getAllByRole('list');
- listItems.forEach((listItem) =>
- expect(listItem).toHaveClass('list--unordered')
- );
- });
-});
diff --git a/src/components/atoms/lists/list.tsx b/src/components/atoms/lists/list.tsx
deleted file mode 100644
index 8fc9672..0000000
--- a/src/components/atoms/lists/list.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { FC } from 'react';
-import styles from './list.module.scss';
-
-export type ListItem = {
- /**
- * Nested list.
- */
- child?: ListItem[];
- /**
- * Item id.
- */
- id: string;
- /**
- * Item value.
- */
- value: any;
-};
-
-export type ListProps = {
- /**
- * Set additional classnames to the list wrapper.
- */
- className?: string;
- /**
- * An array of list items.
- */
- items: ListItem[];
- /**
- * Set additional classnames to the list items.
- */
- itemsClassName?: string;
- /**
- * The list kind.
- */
- kind?: 'ordered' | 'unordered' | 'flex';
-};
-
-/**
- * List component
- *
- * Render either an ordered or an unordered list.
- */
-export const List: FC<ListProps> = ({
- className = '',
- items,
- itemsClassName = '',
- kind = 'unordered',
-}) => {
- const ListTag = kind === 'ordered' ? 'ol' : 'ul';
- const kindClass = `list--${kind}`;
- const listClass = `${styles.list} ${styles[kindClass]} ${className}`;
-
- /**
- * Retrieve the list items.
- * @param array - An array of items.
- * @returns {JSX.Element[]} - An array of li elements.
- */
- const getItems = (array: ListItem[]): JSX.Element[] => {
- return array.map(({ child, id, value }) => (
- <li key={id} className={`${styles.list__item} ${itemsClassName}`}>
- {value}
- {child && (
- <ListTag
- className={`${styles.list} ${styles[kindClass]} ${className}`}
- >
- {getItems(child)}
- </ListTag>
- )}
- </li>
- ));
- };
-
- return <ListTag className={listClass}>{getItems(items)}</ListTag>;
-};
diff --git a/src/components/atoms/lists/list/index.ts b/src/components/atoms/lists/list/index.ts
new file mode 100644
index 0000000..553d0ba
--- /dev/null
+++ b/src/components/atoms/lists/list/index.ts
@@ -0,0 +1,2 @@
+export * from './list';
+export * from './list-item';
diff --git a/src/components/atoms/lists/list/list-item.tsx b/src/components/atoms/lists/list/list-item.tsx
new file mode 100644
index 0000000..3438eac
--- /dev/null
+++ b/src/components/atoms/lists/list/list-item.tsx
@@ -0,0 +1,23 @@
+import type { FC, LiHTMLAttributes } from 'react';
+import styles from './list.module.scss';
+
+export type ListItemProps = LiHTMLAttributes<HTMLElement>;
+
+/**
+ * ListItem component
+ *
+ * Used it inside a `<List />` component.
+ */
+export const ListItem: FC<ListItemProps> = ({
+ children,
+ className = '',
+ ...props
+}) => {
+ const itemClass = `${styles.item} ${className}`;
+
+ return (
+ <li {...props} className={itemClass}>
+ {children}
+ </li>
+ );
+};
diff --git a/src/components/atoms/lists/list/list.module.scss b/src/components/atoms/lists/list/list.module.scss
new file mode 100644
index 0000000..5a38b44
--- /dev/null
+++ b/src/components/atoms/lists/list/list.module.scss
@@ -0,0 +1,28 @@
+@use "../../../../styles/abstracts/placeholders";
+
+.list {
+ &--hierarchical {
+ @extend %hierarchical-list;
+ }
+
+ &--inline {
+ @extend %inline-list;
+ }
+
+ &--stack {
+ .item {
+ @extend %list-item;
+ }
+ }
+
+ &--stack#{&} {
+ &--ordered,
+ &--unordered {
+ @extend %regular-list;
+ }
+ }
+
+ &--no-marker {
+ list-style-type: none;
+ }
+}
diff --git a/src/components/atoms/lists/list/list.stories.tsx b/src/components/atoms/lists/list/list.stories.tsx
new file mode 100644
index 0000000..538947a
--- /dev/null
+++ b/src/components/atoms/lists/list/list.stories.tsx
@@ -0,0 +1,196 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { List, type ListProps } from './list';
+import { ListItem } from './list-item';
+
+/**
+ * List - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Lists',
+ component: List,
+ args: {},
+ argTypes: {
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the list wrapper',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ },
+} as ComponentMeta<typeof List>;
+
+const Template: ComponentStory<typeof List> = <
+ O extends boolean,
+ H extends boolean,
+>(
+ args: ListProps<O, H>
+) => <List {...args} />;
+
+/**
+ * List Stories - Hierarchical list
+ */
+export const Hierarchical = Template.bind({});
+Hierarchical.args = {
+ children: [
+ <ListItem key="item-1">
+ Item 1
+ <List isHierarchical isOrdered>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>Subitem 2</ListItem>
+ </List>
+ </ListItem>,
+ <ListItem key="item-2">
+ Item 2
+ <List isHierarchical isOrdered>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>
+ Subitem 2
+ <List isHierarchical isOrdered>
+ <ListItem>Nested item 1</ListItem>
+ <ListItem>Nested item 2</ListItem>
+ </List>
+ </ListItem>
+ <ListItem>Subitem 3</ListItem>
+ </List>
+ </ListItem>,
+ <ListItem key="item-3">Item 3</ListItem>,
+ ],
+ isHierarchical: true,
+};
+
+/**
+ * List Stories - Ordered list
+ */
+export const Ordered = Template.bind({});
+Ordered.args = {
+ children: [
+ <ListItem key="item-1">
+ Item 1
+ <List isOrdered>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>Subitem 2</ListItem>
+ </List>
+ </ListItem>,
+ <ListItem key="item-2">
+ Item 2
+ <List isOrdered>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>
+ Subitem 2
+ <List isOrdered>
+ <ListItem>Nested item 1</ListItem>
+ <ListItem>Nested item 2</ListItem>
+ </List>
+ </ListItem>
+ <ListItem>Subitem 3</ListItem>
+ </List>
+ </ListItem>,
+ <ListItem key="item-3">Item 3</ListItem>,
+ ],
+ isOrdered: true,
+};
+
+/**
+ * List Stories - Unordered list
+ */
+export const Unordered = Template.bind({});
+Unordered.args = {
+ children: [
+ <ListItem key="item-1">
+ Item 1
+ <List>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>Subitem 2</ListItem>
+ </List>
+ </ListItem>,
+ <ListItem key="item-2">
+ Item 2
+ <List isOrdered>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>
+ Subitem 2
+ <List>
+ <ListItem>Nested item 1</ListItem>
+ <ListItem>Nested item 2</ListItem>
+ </List>
+ </ListItem>
+ <ListItem>Subitem 3</ListItem>
+ </List>
+ </ListItem>,
+ <ListItem key="item-3">Item 3</ListItem>,
+ ],
+};
+
+const items = [
+ { id: 'item-1', label: 'Item 1' },
+ { id: 'item-2', label: 'Item 2' },
+ {
+ id: 'item-3',
+ label: (
+ <>
+ Item 3
+ <List>
+ <ListItem>Subitem 1</ListItem>
+ <ListItem>Subitem 2</ListItem>
+ </List>
+ </>
+ ),
+ },
+ { id: 'item-4', label: 'Item 4' },
+ { id: 'item-5', label: 'Item 5' },
+];
+
+/**
+ * List Stories - Inline and ordered list
+ */
+export const InlineOrdered = Template.bind({});
+InlineOrdered.args = {
+ children: items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ )),
+ isInline: true,
+ isOrdered: true,
+ spacing: 'sm',
+};
+
+/**
+ * List Stories - Inline and unordered list
+ */
+export const InlineUnordered = Template.bind({});
+InlineUnordered.args = {
+ children: items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ )),
+ isInline: true,
+ spacing: 'sm',
+};
+
+/**
+ * List Stories - Ordered list without marker
+ */
+export const OrderedHideMarker = Template.bind({});
+OrderedHideMarker.args = {
+ children: items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ )),
+ hideMarker: true,
+ isOrdered: true,
+};
+
+/**
+ * List Stories - Unordered list without marker
+ */
+export const UnorderedHideMarker = Template.bind({});
+UnorderedHideMarker.args = {
+ children: items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ )),
+ hideMarker: true,
+};
diff --git a/src/components/atoms/lists/list/list.test.tsx b/src/components/atoms/lists/list/list.test.tsx
new file mode 100644
index 0000000..d8001f5
--- /dev/null
+++ b/src/components/atoms/lists/list/list.test.tsx
@@ -0,0 +1,110 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { List } from './list';
+import { ListItem } from './list-item';
+
+const items = [
+ { id: 'item-1', label: 'Item 1' },
+ { id: 'item-2', label: 'Item 2' },
+ { id: 'item-3', label: 'Item 3' },
+ { id: 'item-4', label: 'Item 4' },
+];
+
+describe('List', () => {
+ it('renders a list of items', () => {
+ render(
+ <List>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toBeInTheDocument();
+ expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('can render an ordered list', () => {
+ render(
+ <List isOrdered>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--ordered');
+ });
+
+ it('can render a hierarchical list', () => {
+ render(
+ <List isHierarchical isOrdered>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--hierarchical');
+ });
+
+ it('can render an unordered list', () => {
+ render(
+ <List isOrdered={false}>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--unordered');
+ });
+
+ it('can render list items in a row', () => {
+ render(
+ <List isInline>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--inline');
+ });
+
+ it('can render list items as one column', () => {
+ render(
+ <List isInline={false}>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--stack');
+ });
+
+ it('can render a list with marker', () => {
+ render(
+ <List hideMarker={false}>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--has-marker');
+ });
+
+ it('can render a list without marker', () => {
+ render(
+ <List hideMarker>
+ {items.map((item) => (
+ <ListItem key={item.id}>{item.label}</ListItem>
+ ))}
+ </List>
+ );
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('list--no-marker');
+ });
+});
diff --git a/src/components/atoms/lists/list/list.tsx b/src/components/atoms/lists/list/list.tsx
new file mode 100644
index 0000000..6e58433
--- /dev/null
+++ b/src/components/atoms/lists/list/list.tsx
@@ -0,0 +1,150 @@
+import {
+ forwardRef,
+ type CSSProperties,
+ type HTMLAttributes,
+ type OlHTMLAttributes,
+ type ReactNode,
+ type ForwardedRef,
+} from 'react';
+import type { Spacing } from '../../../../types';
+import styles from './list.module.scss';
+
+type OrderedListProps = Omit<OlHTMLAttributes<HTMLOListElement>, 'children'>;
+
+type UnorderedListProps = Omit<HTMLAttributes<HTMLUListElement>, 'children'>;
+
+type BaseListProps<O extends boolean, H extends boolean> = O extends true
+ ? OrderedListProps
+ : H extends true
+ ? OrderedListProps
+ : UnorderedListProps;
+
+type AdditionalProps<O extends boolean, H extends boolean> = {
+ /**
+ * An array of list items.
+ */
+ children: ReactNode;
+ /**
+ * Should the items marker be hidden?
+ *
+ * @default false
+ */
+ hideMarker?: boolean;
+ /**
+ * Should the list be ordered and hierarchical?
+ *
+ * @default false
+ */
+ isHierarchical?: H;
+ /**
+ * Should the list be inlined?
+ *
+ * @default false
+ */
+ isInline?: boolean;
+ /**
+ * Should the list be ordered?
+ *
+ * @default false
+ */
+ isOrdered?: O;
+ /**
+ * Define the spacing between list items.
+ *
+ * @default null
+ */
+ spacing?: Spacing | null;
+};
+
+type BuildClassNameConfig<O extends boolean, H extends boolean> = Pick<
+ BaseListProps<O, H>,
+ 'className'
+> &
+ Pick<
+ AdditionalProps<O, H>,
+ 'hideMarker' | 'isHierarchical' | 'isInline' | 'isOrdered'
+ >;
+
+const buildClassName = <O extends boolean, H extends boolean>({
+ className = '',
+ hideMarker,
+ isHierarchical,
+ isInline,
+ isOrdered,
+}: BuildClassNameConfig<O, H>) => {
+ const orderedClassName = isHierarchical
+ ? 'list--hierarchical'
+ : 'list--ordered';
+ const classNames: string[] = [
+ isHierarchical || isOrdered ? orderedClassName : 'list--unordered',
+ isInline ? 'list--inline' : 'list--stack',
+ hideMarker ? 'list--no-marker' : 'list--has-marker',
+ className,
+ ].map((key) => styles[key]);
+
+ if (className) classNames.push(className);
+
+ return classNames.join(' ');
+};
+
+export type ListProps<O extends boolean, H extends boolean> = BaseListProps<
+ O,
+ H
+> &
+ AdditionalProps<O, H>;
+
+const ListWithRef = <O extends boolean, H extends boolean>(
+ {
+ className,
+ children,
+ hideMarker = false,
+ isHierarchical,
+ isInline = false,
+ isOrdered,
+ spacing = null,
+ style,
+ ...props
+ }: ListProps<O, H>,
+ ref: ForwardedRef<
+ O extends true
+ ? HTMLOListElement
+ : H extends true
+ ? HTMLOListElement
+ : HTMLUListElement
+ >
+) => {
+ const itemSpacing = spacing === null ? 0 : `var(--spacing-${spacing})`;
+ const listClass = buildClassName({
+ className,
+ hideMarker,
+ isHierarchical,
+ isInline,
+ isOrdered,
+ });
+ const listStyles = {
+ ...style,
+ '--itemSpacing': itemSpacing,
+ } as CSSProperties;
+
+ return isHierarchical || isOrdered ? (
+ <ol
+ {...props}
+ className={listClass}
+ ref={ref as ForwardedRef<HTMLOListElement>}
+ style={listStyles}
+ >
+ {children}
+ </ol>
+ ) : (
+ <ul {...props} className={listClass} ref={ref} style={listStyles}>
+ {children}
+ </ul>
+ );
+};
+
+/**
+ * List component
+ *
+ * Render either an ordered or an unordered list.
+ */
+export const List = forwardRef(ListWithRef);