aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/molecules/grid
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/molecules/grid')
-rw-r--r--src/components/molecules/grid/grid.module.scss41
-rw-r--r--src/components/molecules/grid/grid.stories.tsx140
-rw-r--r--src/components/molecules/grid/grid.test.tsx69
-rw-r--r--src/components/molecules/grid/grid.tsx116
-rw-r--r--src/components/molecules/grid/index.ts1
5 files changed, 367 insertions, 0 deletions
diff --git a/src/components/molecules/grid/grid.module.scss b/src/components/molecules/grid/grid.module.scss
new file mode 100644
index 0000000..e253a89
--- /dev/null
+++ b/src/components/molecules/grid/grid.module.scss
@@ -0,0 +1,41 @@
+.wrapper {
+ display: grid;
+ gap: var(--gap);
+
+ &--is-centered {
+ place-content: center;
+ }
+
+ &--has-fixed-size {
+ grid-template-columns: repeat(
+ var(--col, auto-fit),
+ min(100vw - (var(--spacing-md) * 2), var(--size))
+ );
+ }
+
+ &--has-min-size {
+ grid-template-columns: repeat(
+ var(--col, auto-fit),
+ minmax(
+ min(100vw - (var(--spacing-md) * 2), var(--size-min)),
+ var(--size-max, 1fr)
+ )
+ );
+ }
+
+ &:not(#{&}--has-fixed-size):not(#{&}--has-min-size) {
+ grid-template-columns: repeat(
+ var(--col, auto-fit),
+ minmax(0, var(--size-max, 1fr))
+ );
+ }
+}
+
+.item {
+ display: flex;
+ flex-flow: row wrap;
+
+ > * {
+ flex: 1;
+ }
+}
diff --git a/src/components/molecules/grid/grid.stories.tsx b/src/components/molecules/grid/grid.stories.tsx
new file mode 100644
index 0000000..ce3ee2b
--- /dev/null
+++ b/src/components/molecules/grid/grid.stories.tsx
@@ -0,0 +1,140 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import type { FC, ReactNode } from 'react';
+import { Grid } from './grid';
+
+export default {
+ title: 'Molecules/Grid',
+ component: Grid,
+ argTypes: {
+ items: {
+ description: 'The grid items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof Grid>;
+
+const Template: ComponentStory<typeof Grid> = (args) => <Grid {...args} />;
+
+type ItemProps = {
+ children: ReactNode;
+};
+
+const Item: FC<ItemProps> = ({ children }) => (
+ <div style={{ border: '1px solid #000', padding: '1rem' }}>{children}</div>
+);
+
+export const Default = Template.bind({});
+Default.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ { id: 'item-5', item: <Item>Item 5</Item> },
+ ],
+};
+
+export const OneColumn = Template.bind({});
+OneColumn.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ ],
+ col: 1,
+ gap: 'sm',
+};
+
+export const TwoColumns = Template.bind({});
+TwoColumns.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ ],
+ col: 2,
+ gap: 'sm',
+};
+
+export const ThreeColumns = Template.bind({});
+ThreeColumns.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ ],
+ col: 3,
+ gap: 'sm',
+};
+
+export const FixedSize = Template.bind({});
+FixedSize.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ { id: 'item-5', item: <Item>Item 5</Item> },
+ ],
+ size: '300px',
+ gap: 'sm',
+};
+
+export const MaxSize = Template.bind({});
+MaxSize.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ { id: 'item-5', item: <Item>Item 5</Item> },
+ ],
+ sizeMax: '300px',
+ gap: 'sm',
+};
+
+export const MinSize = Template.bind({});
+MinSize.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ { id: 'item-5', item: <Item>Item 5</Item> },
+ ],
+ sizeMin: '100px',
+ gap: 'sm',
+};
+
+export const MinAndMaxSize = Template.bind({});
+MinAndMaxSize.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ { id: 'item-5', item: <Item>Item 5</Item> },
+ ],
+ sizeMax: '300px',
+ sizeMin: '100px',
+ gap: 'sm',
+};
+
+export const Fill = Template.bind({});
+Fill.args = {
+ items: [
+ { id: 'item-1', item: <Item>Item 1</Item> },
+ { id: 'item-2', item: <Item>Item 2</Item> },
+ { id: 'item-3', item: <Item>Item 3</Item> },
+ { id: 'item-4', item: <Item>Item 4</Item> },
+ { id: 'item-5', item: <Item>Item 5</Item> },
+ ],
+ col: 'auto-fill',
+ sizeMin: '100px',
+ gap: 'sm',
+};
diff --git a/src/components/molecules/grid/grid.test.tsx b/src/components/molecules/grid/grid.test.tsx
new file mode 100644
index 0000000..212bdc4
--- /dev/null
+++ b/src/components/molecules/grid/grid.test.tsx
@@ -0,0 +1,69 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { Grid, type GridItem } from './grid';
+
+const items: GridItem[] = [
+ { id: 'item-1', item: 'Item 1' },
+ { id: 'item-2', item: 'Item 2' },
+ { id: 'item-3', item: 'Item 3' },
+ { id: 'item-4', item: 'Item 4' },
+ { id: 'item-5', item: 'Item 5' },
+];
+
+describe('Grid', () => {
+ it('render a list of items as grid', () => {
+ render(<Grid items={items} />);
+
+ expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length);
+ });
+
+ it('can render a list of items with fixed size', () => {
+ const size = '200px';
+
+ render(<Grid items={items} size={size} />);
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('wrapper--has-fixed-size');
+ expect(rtlScreen.getByRole('list')).toHaveStyle({ '--size': size });
+ });
+
+ it('can render a list of items with min size', () => {
+ const size = '200px';
+
+ render(<Grid items={items} sizeMin={size} />);
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('wrapper--has-min-size');
+ expect(rtlScreen.getByRole('list')).toHaveStyle({ '--size-min': size });
+ });
+
+ it('can render a list of items with max size', () => {
+ const size = '200px';
+
+ render(<Grid items={items} sizeMax={size} />);
+
+ expect(rtlScreen.getByRole('list')).toHaveStyle({ '--size-max': size });
+ });
+
+ it('can render a list of items with a custom gap', () => {
+ const gap = 'md';
+
+ render(<Grid items={items} gap={gap} />);
+
+ expect(rtlScreen.getByRole('list')).toHaveStyle({
+ '--gap': `var(--spacing-${gap})`,
+ });
+ });
+
+ it('can render a list of items with an explicit number of columns', () => {
+ const col = 4;
+
+ render(<Grid col={col} items={items} />);
+
+ expect(rtlScreen.getByRole('list')).toHaveStyle(`--col: ${col}`);
+ });
+
+ it('can render a centered list of items', () => {
+ render(<Grid isCentered items={items} />);
+
+ expect(rtlScreen.getByRole('list')).toHaveClass('wrapper--is-centered');
+ });
+});
diff --git a/src/components/molecules/grid/grid.tsx b/src/components/molecules/grid/grid.tsx
new file mode 100644
index 0000000..ca920f8
--- /dev/null
+++ b/src/components/molecules/grid/grid.tsx
@@ -0,0 +1,116 @@
+import {
+ type ForwardedRef,
+ type ReactNode,
+ forwardRef,
+ type CSSProperties,
+} from 'react';
+import type { Spacing } from '../../../types';
+import { List, ListItem, type ListProps } from '../../atoms';
+import styles from './grid.module.scss';
+
+export type GridItem = {
+ id: string;
+ item: ReactNode;
+};
+
+export type GridProps<T extends boolean> = Omit<
+ ListProps<T, false>,
+ 'children' | 'hideMarker' | 'isHierarchical' | 'isInline' | 'spacing'
+> & {
+ /**
+ * Control the number of column.
+ *
+ * @default 'auto-fit'
+ */
+ col?: number | 'auto-fill' | 'auto-fit';
+ /**
+ * The gap between the items.
+ *
+ * @default null
+ */
+ gap?: Spacing | null;
+ /**
+ * Should the grid be centered?
+ *
+ * @default false
+ */
+ isCentered?: boolean;
+ /**
+ * The grid items.
+ */
+ items: GridItem[];
+ /**
+ * Define a fixed size for each item.
+ *
+ * You should either use `size` or `sizeMax`/`sizeMin` not both.
+ *
+ * @default undefined
+ */
+ size?: string;
+ /**
+ * Define the maximal size of each item.
+ *
+ * You should either use `size` or `sizeMax`/`sizeMin` not both.
+ *
+ * @default '1fr'
+ */
+ sizeMax?: string;
+ /**
+ * Define the maximal size of each item.
+ *
+ * You should either use `size` or `sizeMax`/`sizeMin` not both.
+ *
+ * @default 0
+ */
+ sizeMin?: 0 | string;
+};
+
+const GridWithRef = <T extends boolean>(
+ {
+ className = '',
+ col = 'auto-fit',
+ gap,
+ isCentered = false,
+ items,
+ size,
+ sizeMax,
+ sizeMin,
+ style,
+ ...props
+ }: GridProps<T>,
+ ref?: ForwardedRef<T extends true ? HTMLOListElement : HTMLUListElement>
+) => {
+ const gridClass = [
+ styles.wrapper,
+ styles[isCentered ? 'wrapper--is-centered' : ''],
+ styles[size ? 'wrapper--has-fixed-size' : ''],
+ styles[sizeMin ? 'wrapper--has-min-size' : ''],
+ className,
+ ].join(' ');
+ const gridStyles = {
+ ...style,
+ '--col': col,
+ ...(size ? { '--size': size } : {}),
+ ...(sizeMax ? { '--size-max': sizeMax } : {}),
+ ...(sizeMin ? { '--size-min': sizeMin } : {}),
+ ...(gap ? { '--gap': `var(--spacing-${gap})` } : {}),
+ } as CSSProperties;
+
+ return (
+ <List
+ {...props}
+ className={gridClass}
+ hideMarker
+ ref={ref}
+ style={gridStyles}
+ >
+ {items.map(({ id, item }) => (
+ <ListItem className={styles.item} key={id}>
+ {item}
+ </ListItem>
+ ))}
+ </List>
+ );
+};
+
+export const Grid = forwardRef(GridWithRef);
diff --git a/src/components/molecules/grid/index.ts b/src/components/molecules/grid/index.ts
new file mode 100644
index 0000000..d24d1bd
--- /dev/null
+++ b/src/components/molecules/grid/index.ts
@@ -0,0 +1 @@
+export * from './grid';