aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/meta-list
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-10-10 19:37:51 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:14:41 +0100
commitc87c615b5866b8a8f361eeb0764bfdea85740e90 (patch)
treec27bda05fd96bbe3154472e170ba1abd5f9ea499 /src/components/molecules/meta-list
parent15522ec9146f6f1956620355c44dea2a6a75b67c (diff)
refactor(components): replace Meta component with MetaList
It removes items complexity by allowing consumers to use any label/value association. Translations should also be defined by the consumer. Each item can now be configured separately (borders, layout...).
Diffstat (limited to 'src/components/molecules/meta-list')
-rw-r--r--src/components/molecules/meta-list/index.ts2
-rw-r--r--src/components/molecules/meta-list/meta-item/index.ts1
-rw-r--r--src/components/molecules/meta-list/meta-item/meta-item.module.scss62
-rw-r--r--src/components/molecules/meta-list/meta-item/meta-item.stories.tsx108
-rw-r--r--src/components/molecules/meta-list/meta-item/meta-item.test.tsx97
-rw-r--r--src/components/molecules/meta-list/meta-item/meta-item.tsx90
-rw-r--r--src/components/molecules/meta-list/meta-list.module.scss24
-rw-r--r--src/components/molecules/meta-list/meta-list.stories.tsx70
-rw-r--r--src/components/molecules/meta-list/meta-list.test.tsx79
-rw-r--r--src/components/molecules/meta-list/meta-list.tsx78
10 files changed, 611 insertions, 0 deletions
diff --git a/src/components/molecules/meta-list/index.ts b/src/components/molecules/meta-list/index.ts
new file mode 100644
index 0000000..93f437d
--- /dev/null
+++ b/src/components/molecules/meta-list/index.ts
@@ -0,0 +1,2 @@
+export * from './meta-item';
+export * from './meta-list';
diff --git a/src/components/molecules/meta-list/meta-item/index.ts b/src/components/molecules/meta-list/meta-item/index.ts
new file mode 100644
index 0000000..47795de
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-item/index.ts
@@ -0,0 +1 @@
+export * from './meta-item';
diff --git a/src/components/molecules/meta-list/meta-item/meta-item.module.scss b/src/components/molecules/meta-list/meta-item/meta-item.module.scss
new file mode 100644
index 0000000..a1c2d47
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-item/meta-item.module.scss
@@ -0,0 +1,62 @@
+@use "../../../../styles/abstracts/functions" as fun;
+
+.item {
+ column-gap: var(--spacing-2xs);
+ align-content: baseline;
+
+ &--bordered-values {
+ row-gap: var(--spacing-2xs);
+ }
+
+ &--centered {
+ margin-inline: auto;
+ text-align: center;
+ place-items: center;
+ justify-content: center;
+ }
+
+ &--inlined {
+ align-items: first baseline;
+ }
+
+ &--inlined-values {
+ flex-flow: row wrap;
+ }
+
+ &:not(#{&}--bordered-values) {
+ row-gap: fun.convert-px(3);
+ }
+}
+
+.value {
+ width: fit-content;
+ height: fit-content;
+ color: var(--color-fg);
+ font-weight: 400;
+}
+
+:where(.item--bordered-values) {
+ .value {
+ padding: fun.convert-px(2) var(--spacing-2xs);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ }
+}
+
+:where(.item--inlined-values) {
+ .label {
+ flex: 1 0 100%;
+ }
+}
+
+/* It's an arbitrary choice. When there is only one meta item (like on small
+ * cards) removing the width can mess up the layout. However, must of the times
+ * when there are multiples items, we need to remove the width especially if we
+ * want to use `isCentered` prop. */
+:where(.item--inlined-values:not(:only-of-type)) {
+ .label {
+ /* We need to remove its width to avoid an extra space and make the
+ * container width fit its contents. However the label should be smaller
+ * than the values to avoid unexpected behavior with layout. */
+ width: 0;
+ }
+}
diff --git a/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx
new file mode 100644
index 0000000..3ddb8f1
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-item/meta-item.stories.tsx
@@ -0,0 +1,108 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Link } from '../../../atoms';
+import { MetaItem } from './meta-item';
+
+/**
+ * MetaItem - Storybook Meta
+ */
+export default {
+ title: 'Molecules/MetaList/Item',
+ component: MetaItem,
+ argTypes: {
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The item label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof MetaItem>;
+
+const Template: ComponentStory<typeof MetaItem> = (args) => (
+ <MetaItem {...args} />
+);
+
+/**
+ * MetaItem Stories - SingleValue
+ */
+export const SingleValue = Template.bind({});
+SingleValue.args = {
+ label: 'Comments',
+ value: 'No comments',
+};
+
+/**
+ * MetaItem Stories - MultipleValues
+ */
+export const MultipleValues = Template.bind({});
+MultipleValues.args = {
+ label: 'Tags',
+ value: [
+ { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> },
+ { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> },
+ ],
+};
+
+/**
+ * MetaItem Stories - SingleValueBordered
+ */
+export const SingleValueBordered = Template.bind({});
+SingleValueBordered.args = {
+ hasBorderedValues: true,
+ label: 'Comments',
+ value: 'No comments',
+};
+
+/**
+ * MetaItem Stories - MultipleValuesBordered
+ */
+export const MultipleValuesBordered = Template.bind({});
+MultipleValuesBordered.args = {
+ hasBorderedValues: true,
+ label: 'Tags',
+ value: [
+ { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> },
+ { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> },
+ ],
+};
+
+/**
+ * MetaItem Stories - SingleValueInlined
+ */
+export const SingleValueInlined = Template.bind({});
+SingleValueInlined.args = {
+ isInline: true,
+ label: 'Comments',
+ value: 'No comments',
+};
+
+/**
+ * MetaItem Stories - MultipleValuesInlined
+ */
+export const MultipleValuesInlined = Template.bind({});
+MultipleValuesInlined.args = {
+ isInline: true,
+ label: 'Tags',
+ value: [
+ { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> },
+ { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> },
+ ],
+};
+
+/**
+ * MetaItem Stories - InlinedValues
+ */
+export const InlinedValues = Template.bind({});
+InlinedValues.args = {
+ hasInlinedValues: true,
+ label: 'Tags',
+ value: [
+ { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> },
+ { id: 'tag2', value: <Link href="#tag2">A long tag 2</Link> },
+ { id: 'tag3', value: <Link href="#tag3">Tag 3</Link> },
+ ],
+};
diff --git a/src/components/molecules/meta-list/meta-item/meta-item.test.tsx b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx
new file mode 100644
index 0000000..629c4b2
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-item/meta-item.test.tsx
@@ -0,0 +1,97 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { MetaItem } from './meta-item';
+
+describe('MetaItem', () => {
+ it('renders a label and a value', () => {
+ const label = 'iusto';
+ const value = 'autem';
+
+ render(
+ <dl>
+ <MetaItem label={label} value={value} />
+ </dl>
+ );
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(label);
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(value);
+ });
+
+ it('can render a label with multiple values', () => {
+ const label = 'iusto';
+ const values = [
+ { id: 'autem', value: 'autem' },
+ { id: 'quisquam', value: 'aut' },
+ { id: 'molestias', value: 'voluptatem' },
+ ];
+
+ render(
+ <dl>
+ <MetaItem label={label} value={values} />
+ </dl>
+ );
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(label);
+ expect(rtlScreen.getAllByRole('definition')).toHaveLength(values.length);
+ });
+
+ it('can render a centered group of label and values', () => {
+ const label = 'iusto';
+ const value = 'autem';
+
+ render(
+ <dl>
+ <MetaItem isCentered label={label} value={value} />
+ </dl>
+ );
+
+ expect(rtlScreen.getByRole('term').parentElement).toHaveClass(
+ 'item--centered'
+ );
+ });
+
+ it('can render an inlined group of label and values', () => {
+ const label = 'iusto';
+ const value = 'autem';
+
+ render(
+ <dl>
+ <MetaItem isInline label={label} value={value} />
+ </dl>
+ );
+
+ expect(rtlScreen.getByRole('term').parentElement).toHaveClass(
+ 'item--inlined'
+ );
+ });
+
+ it('can render a group of label and bordered values', () => {
+ const label = 'iusto';
+ const value = 'autem';
+
+ render(
+ <dl>
+ <MetaItem hasBorderedValues label={label} value={value} />
+ </dl>
+ );
+
+ expect(rtlScreen.getByRole('term').parentElement).toHaveClass(
+ 'item--bordered-values'
+ );
+ });
+
+ it('can render a group of label and inlined values', () => {
+ const label = 'iusto';
+ const value = 'autem';
+
+ render(
+ <dl>
+ <MetaItem hasInlinedValues label={label} value={value} />
+ </dl>
+ );
+
+ expect(rtlScreen.getByRole('term').parentElement).toHaveClass(
+ 'item--inlined-values'
+ );
+ });
+});
diff --git a/src/components/molecules/meta-list/meta-item/meta-item.tsx b/src/components/molecules/meta-list/meta-item/meta-item.tsx
new file mode 100644
index 0000000..c5223c2
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-item/meta-item.tsx
@@ -0,0 +1,90 @@
+import {
+ type ForwardRefRenderFunction,
+ type ReactElement,
+ type ReactNode,
+ forwardRef,
+} from 'react';
+import { Description, Group, type GroupProps, Term } from '../../../atoms';
+import styles from './meta-item.module.scss';
+
+export type MetaValue = string | ReactElement;
+
+export type MetaValues = {
+ id: string;
+ value: MetaValue;
+};
+
+export type MetaItemProps = Omit<GroupProps, 'children' | 'spacing'> & {
+ /**
+ * Should the values be bordered?
+ *
+ * @default false
+ */
+ hasBorderedValues?: boolean;
+ /**
+ * Should the values be inlined?
+ *
+ * @warning If you use it make sure the value is larger than the label. It
+ * could mess up your design since we are removing the label width.
+ *
+ * @default false
+ */
+ hasInlinedValues?: boolean;
+ /**
+ * Should the label and values be centered?
+ *
+ * @default false
+ */
+ isCentered?: boolean;
+ /**
+ * The item label.
+ */
+ label: ReactNode;
+ /**
+ * The item value or values.
+ */
+ value: MetaValue | MetaValues[];
+};
+
+const MetaItemWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ MetaItemProps
+> = (
+ {
+ className = '',
+ hasBorderedValues = false,
+ hasInlinedValues = false,
+ isCentered = false,
+ isInline = false,
+ label,
+ value,
+ ...props
+ },
+ ref
+) => {
+ const itemClass = [
+ styles.item,
+ styles[hasBorderedValues ? 'item--bordered-values' : ''],
+ styles[hasInlinedValues ? 'item--inlined-values' : ''],
+ styles[isCentered ? 'item--centered' : ''],
+ styles[isInline ? 'item--inlined' : 'item--stacked'],
+ className,
+ ].join(' ');
+
+ return (
+ <Group {...props} className={itemClass} isInline={isInline} ref={ref}>
+ <Term className={styles.label}>{label}</Term>
+ {Array.isArray(value) ? (
+ value.map((item) => (
+ <Description className={styles.value} key={item.id}>
+ {item.value}
+ </Description>
+ ))
+ ) : (
+ <Description className={styles.value}>{value}</Description>
+ )}
+ </Group>
+ );
+};
+
+export const MetaItem = forwardRef(MetaItemWithRef);
diff --git a/src/components/molecules/meta-list/meta-list.module.scss b/src/components/molecules/meta-list/meta-list.module.scss
new file mode 100644
index 0000000..5570f4c
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-list.module.scss
@@ -0,0 +1,24 @@
+.list {
+ display: grid;
+ width: fit-content;
+ height: fit-content;
+
+ &--centered {
+ margin-inline: auto;
+ justify-items: center;
+ }
+
+ &--inlined {
+ grid-auto-flow: column;
+ grid-template-columns: repeat(
+ auto-fit,
+ min(calc(100vw - (var(--spacing-md) * 2)), 1fr)
+ );
+ column-gap: clamp(var(--spacing-lg), 3vw, var(--spacing-3xl));
+ row-gap: clamp(var(--spacing-sm), 3vw, var(--spacing-md));
+ }
+
+ &--stacked {
+ gap: var(--spacing-2xs);
+ }
+}
diff --git a/src/components/molecules/meta-list/meta-list.stories.tsx b/src/components/molecules/meta-list/meta-list.stories.tsx
new file mode 100644
index 0000000..463ec96
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-list.stories.tsx
@@ -0,0 +1,70 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Link } from '../../atoms';
+import { type MetaItemData, MetaList } from './meta-list';
+
+/**
+ * MetaList - Storybook Meta
+ */
+export default {
+ title: 'Molecules/MetaList',
+ component: MetaList,
+ argTypes: {
+ items: {
+ description: 'The meta items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof MetaList>;
+
+const Template: ComponentStory<typeof MetaList> = (args) => (
+ <MetaList {...args} />
+);
+
+const items: MetaItemData[] = [
+ { id: 'comments', label: 'Comments', value: 'No comments.' },
+ {
+ id: 'category',
+ label: 'Category',
+ value: <Link href="#cat1">Cat 1</Link>,
+ },
+ {
+ id: 'tags',
+ label: 'Tags',
+ value: [
+ { id: 'tag1', value: <Link href="#tag1">Tag 1</Link> },
+ { id: 'tag2', value: <Link href="#tag2">Tag 2</Link> },
+ ],
+ },
+ {
+ hasBorderedValues: true,
+ hasInlinedValues: true,
+ id: 'technologies',
+ label: 'Technologies',
+ value: [
+ { id: 'techno1', value: 'HTML' },
+ { id: 'techno2', value: 'CSS' },
+ { id: 'techno3', value: 'Javascript' },
+ ],
+ },
+];
+
+/**
+ * MetaList Stories - Default
+ */
+export const Default = Template.bind({});
+Default.args = {
+ items,
+};
+
+/**
+ * MetaList Stories - Inlined
+ */
+export const Inlined = Template.bind({});
+Inlined.args = {
+ isInline: true,
+ items,
+};
diff --git a/src/components/molecules/meta-list/meta-list.test.tsx b/src/components/molecules/meta-list/meta-list.test.tsx
new file mode 100644
index 0000000..cc4d2fa
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-list.test.tsx
@@ -0,0 +1,79 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { type MetaItemData, MetaList } from './meta-list';
+
+describe('MetaList', () => {
+ it('renders a list of meta items', () => {
+ const items: MetaItemData[] = [
+ { id: 'item1', label: 'Item 1', value: 'Value 1' },
+ { id: 'item2', label: 'Item 2', value: 'Value 2' },
+ { id: 'item3', label: 'Item 3', value: 'Value 3' },
+ { id: 'item4', label: 'Item 4', value: 'Value 4' },
+ ];
+
+ render(<MetaList items={items} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(items.length);
+ expect(rtlScreen.getAllByRole('definition')).toHaveLength(items.length);
+ });
+
+ it('can render a centered list of meta items', () => {
+ const items: MetaItemData[] = [
+ { id: 'item1', label: 'Item 1', value: 'Value 1' },
+ { id: 'item2', label: 'Item 2', value: 'Value 2' },
+ { id: 'item3', label: 'Item 3', value: 'Value 3' },
+ { id: 'item4', label: 'Item 4', value: 'Value 4' },
+ ];
+
+ render(<MetaList isCentered items={items} />);
+
+ const terms = rtlScreen.getAllByRole('term');
+
+ expect(terms[0].parentElement?.parentElement).toHaveClass('list--centered');
+ });
+
+ it('can render an inlined list of meta items', () => {
+ const items: MetaItemData[] = [
+ { id: 'item1', label: 'Item 1', value: 'Value 1' },
+ { id: 'item2', label: 'Item 2', value: 'Value 2' },
+ { id: 'item3', label: 'Item 3', value: 'Value 3' },
+ { id: 'item4', label: 'Item 4', value: 'Value 4' },
+ ];
+
+ render(<MetaList isInline items={items} />);
+
+ const terms = rtlScreen.getAllByRole('term');
+
+ expect(terms[0].parentElement?.parentElement).toHaveClass('list--inlined');
+ });
+
+ it('can render a list of meta items with bordered values', () => {
+ const items: MetaItemData[] = [
+ { id: 'item1', label: 'Item 1', value: 'Value 1' },
+ { id: 'item2', label: 'Item 2', value: 'Value 2' },
+ { id: 'item3', label: 'Item 3', value: 'Value 3' },
+ { id: 'item4', label: 'Item 4', value: 'Value 4' },
+ ];
+
+ render(<MetaList hasBorderedValues items={items} />);
+
+ const terms = rtlScreen.getAllByRole('term');
+
+ expect(terms[0].parentElement).toHaveClass('item--bordered-values');
+ });
+
+ it('can render a list of meta items with inlined values', () => {
+ const items: MetaItemData[] = [
+ { id: 'item1', label: 'Item 1', value: 'Value 1' },
+ { id: 'item2', label: 'Item 2', value: 'Value 2' },
+ { id: 'item3', label: 'Item 3', value: 'Value 3' },
+ { id: 'item4', label: 'Item 4', value: 'Value 4' },
+ ];
+
+ render(<MetaList hasInlinedValues items={items} />);
+
+ const terms = rtlScreen.getAllByRole('term');
+
+ expect(terms[0].parentElement).toHaveClass('item--inlined-values');
+ });
+});
diff --git a/src/components/molecules/meta-list/meta-list.tsx b/src/components/molecules/meta-list/meta-list.tsx
new file mode 100644
index 0000000..288fd9a
--- /dev/null
+++ b/src/components/molecules/meta-list/meta-list.tsx
@@ -0,0 +1,78 @@
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { DescriptionList, type DescriptionListProps } from '../../atoms';
+import { MetaItem, type MetaItemProps } from './meta-item';
+import styles from './meta-list.module.scss';
+
+export type MetaItemData = Pick<
+ MetaItemProps,
+ | 'hasBorderedValues'
+ | 'hasInlinedValues'
+ | 'isCentered'
+ | 'isInline'
+ | 'label'
+ | 'value'
+> & {
+ id: string;
+};
+
+export type MetaListProps = Omit<DescriptionListProps, 'children' | 'spacing'> &
+ Pick<MetaItemProps, 'hasBorderedValues' | 'hasInlinedValues'> & {
+ /**
+ * Should the items be inlined?
+ *
+ * @default false
+ */
+ hasInlinedItems?: boolean;
+ /**
+ * Should the meta be centered?
+ *
+ * @default false
+ */
+ isCentered?: boolean;
+ /**
+ * The meta items.
+ */
+ items: MetaItemData[];
+ };
+
+const MetaListWithRef: ForwardRefRenderFunction<
+ HTMLDListElement,
+ MetaListProps
+> = (
+ {
+ className = '',
+ hasBorderedValues = false,
+ hasInlinedItems = false,
+ hasInlinedValues = false,
+ isCentered = false,
+ isInline = false,
+ items,
+ ...props
+ },
+ ref
+) => {
+ const listClass = [
+ styles.list,
+ styles[isCentered ? 'list--centered' : ''],
+ styles[isInline ? 'list--inlined' : 'list--stacked'],
+ className,
+ ].join(' ');
+
+ return (
+ <DescriptionList {...props} className={listClass} ref={ref}>
+ {items.map(({ id, ...item }) => (
+ <MetaItem
+ hasBorderedValues={hasBorderedValues}
+ hasInlinedValues={hasInlinedValues}
+ isCentered={isCentered}
+ isInline={hasInlinedItems}
+ // Each item should be able to override the global settings.
+ {...item}
+ key={id}
+ />
+ ))}
+ </DescriptionList>
+ );
+};
+
+export const MetaList = forwardRef(MetaListWithRef);