From 94448fa278ab352a741ff13f22d6104869571144 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Wed, 18 Oct 2023 19:25:02 +0200 Subject: feat(components): add a generic Grid component * merge Columns, Gallery and CardsList into Grid component * add more options to control the grid --- src/components/molecules/grid/grid.module.scss | 41 ++++++++ src/components/molecules/grid/grid.stories.tsx | 140 +++++++++++++++++++++++++ src/components/molecules/grid/grid.test.tsx | 69 ++++++++++++ src/components/molecules/grid/grid.tsx | 116 ++++++++++++++++++++ src/components/molecules/grid/index.ts | 1 + 5 files changed, 367 insertions(+) create mode 100644 src/components/molecules/grid/grid.module.scss create mode 100644 src/components/molecules/grid/grid.stories.tsx create mode 100644 src/components/molecules/grid/grid.test.tsx create mode 100644 src/components/molecules/grid/grid.tsx create mode 100644 src/components/molecules/grid/index.ts (limited to 'src/components/molecules/grid') 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; + +const Template: ComponentStory = (args) => ; + +type ItemProps = { + children: ReactNode; +}; + +const Item: FC = ({ children }) => ( +
{children}
+); + +export const Default = Template.bind({}); +Default.args = { + items: [ + { 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 }, + ], +}; + +export const OneColumn = Template.bind({}); +OneColumn.args = { + items: [ + { id: 'item-1', item: Item 1 }, + { id: 'item-2', item: Item 2 }, + { id: 'item-3', item: Item 3 }, + ], + col: 1, + gap: 'sm', +}; + +export const TwoColumns = Template.bind({}); +TwoColumns.args = { + items: [ + { id: 'item-1', item: Item 1 }, + { id: 'item-2', item: Item 2 }, + { id: 'item-3', item: Item 3 }, + ], + col: 2, + gap: 'sm', +}; + +export const ThreeColumns = Template.bind({}); +ThreeColumns.args = { + items: [ + { id: 'item-1', item: Item 1 }, + { id: 'item-2', item: Item 2 }, + { id: 'item-3', item: Item 3 }, + { id: 'item-4', item: Item 4 }, + ], + col: 3, + gap: 'sm', +}; + +export const FixedSize = Template.bind({}); +FixedSize.args = { + items: [ + { 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 }, + ], + size: '300px', + gap: 'sm', +}; + +export const MaxSize = Template.bind({}); +MaxSize.args = { + items: [ + { 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 }, + ], + sizeMax: '300px', + gap: 'sm', +}; + +export const MinSize = Template.bind({}); +MinSize.args = { + items: [ + { 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 }, + ], + sizeMin: '100px', + gap: 'sm', +}; + +export const MinAndMaxSize = Template.bind({}); +MinAndMaxSize.args = { + items: [ + { 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 }, + ], + sizeMax: '300px', + sizeMin: '100px', + gap: 'sm', +}; + +export const Fill = Template.bind({}); +Fill.args = { + items: [ + { 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 }, + ], + 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(); + + expect(rtlScreen.getAllByRole('listitem')).toHaveLength(items.length); + }); + + it('can render a list of items with fixed size', () => { + const size = '200px'; + + render(); + + 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(); + + 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(); + + expect(rtlScreen.getByRole('list')).toHaveStyle({ '--size-max': size }); + }); + + it('can render a list of items with a custom gap', () => { + const gap = 'md'; + + render(); + + 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(); + + expect(rtlScreen.getByRole('list')).toHaveStyle(`--col: ${col}`); + }); + + it('can render a centered list of items', () => { + render(); + + 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 = Omit< + ListProps, + '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 = ( + { + className = '', + col = 'auto-fit', + gap, + isCentered = false, + items, + size, + sizeMax, + sizeMin, + style, + ...props + }: GridProps, + ref?: ForwardedRef +) => { + 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 ( + + {items.map(({ id, item }) => ( + + {item} + + ))} + + ); +}; + +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'; -- cgit v1.2.3