aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms/lists/list
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/lists/list
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/lists/list')
-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
6 files changed, 509 insertions, 0 deletions
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);