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 + src/components/molecules/index.ts | 1 + .../molecules/layout/columns.module.scss | 30 ----- .../molecules/layout/columns.stories.tsx | 107 ---------------- src/components/molecules/layout/columns.test.tsx | 48 ------- src/components/molecules/layout/columns.tsx | 62 --------- src/components/molecules/layout/index.ts | 1 - 11 files changed, 368 insertions(+), 248 deletions(-) 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 delete mode 100644 src/components/molecules/layout/columns.module.scss delete mode 100644 src/components/molecules/layout/columns.stories.tsx delete mode 100644 src/components/molecules/layout/columns.test.tsx delete mode 100644 src/components/molecules/layout/columns.tsx (limited to 'src/components/molecules') 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'; diff --git a/src/components/molecules/index.ts b/src/components/molecules/index.ts index b042a96..7f48e45 100644 --- a/src/components/molecules/index.ts +++ b/src/components/molecules/index.ts @@ -4,6 +4,7 @@ export * from './card'; export * from './code'; export * from './collapsible'; export * from './forms'; +export * from './grid'; export * from './images'; export * from './layout'; export * from './meta-list'; diff --git a/src/components/molecules/layout/columns.module.scss b/src/components/molecules/layout/columns.module.scss deleted file mode 100644 index 0d383e7..0000000 --- a/src/components/molecules/layout/columns.module.scss +++ /dev/null @@ -1,30 +0,0 @@ -@use "../../../styles/abstracts/mixins" as mix; - -.wrapper { - display: grid; - gap: var(--spacing-md); - - &--responsive#{&} { - @for $i from 2 through 4 { - &--#{$i}-columns { - @include mix.media("screen") { - @include mix.dimensions("sm") { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - @include mix.dimensions("md") { - grid-template-columns: repeat($i, minmax(0, 1fr)); - } - } - } - } - } - - &--no-responsive#{&} { - @for $i from 2 through 4 { - &--#{$i}-columns { - grid-template-columns: repeat($i, minmax(0, 1fr)); - } - } - } -} diff --git a/src/components/molecules/layout/columns.stories.tsx b/src/components/molecules/layout/columns.stories.tsx deleted file mode 100644 index 43d0629..0000000 --- a/src/components/molecules/layout/columns.stories.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Column, Columns } from './columns'; - -export default { - title: 'Molecules/Layout/Columns', - args: { - responsive: true, - }, - component: Columns, - argTypes: { - children: { - description: 'The columns.', - type: { - name: 'function', - required: true, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the columns wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - count: { - control: { - type: 'number', - min: 2, - max: 4, - }, - description: 'The number of columns.', - type: { - name: 'number', - required: true, - }, - }, - responsive: { - control: { - type: 'boolean', - }, - description: 'Should the columns be stacked on small devices?', - table: { - category: 'Options', - defaultValue: { summary: true }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - }, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ( - -); - -const column1 = - 'Non praesentium voluptas quisquam ex est. Distinctio accusamus facilis libero in aut. Et veritatis quo impedit fugit amet sit accusantium. Ut est rerum asperiores sint libero eveniet. Molestias placeat recusandae suscipit eligendi sunt hic.'; - -const column2 = - 'Quaerat eum dignissimos tempore ab enim. Iusto inventore nemo. Veritatis voluptas quod maxime earum soluta illo atque vel. Nam et corrupti. Dolorem qui cum dolorem. Aut ut nobis. Mollitia qui voluptas rerum et quibusdam.'; - -const column3 = - 'Libero aut ab neque voluptatem commodi. Quam quia voluptatem iusto dolorum. Enim ipsa totam corrupti qui cum quidem ea. Eos sed aliquam porro consequatur officia sed.'; - -const column4 = - 'Ratione placeat ea ea. Explicabo rem eaque voluptatibus. Nihil nulla culpa et dolor numquam omnis est. Quis quas excepturi est dignissimos ducimus et ad quis quis. Eos enim et nam delectus.'; - -export const TwoColumns = Template.bind({}); -TwoColumns.args = { - children: [ - {column1}, - {column2}, - {column3}, - {column4}, - ], - count: 2, -}; - -export const ThreeColumns = Template.bind({}); -ThreeColumns.args = { - children: [ - {column1}, - {column2}, - {column3}, - {column4}, - ], - count: 3, -}; - -export const FourColumns = Template.bind({}); -FourColumns.args = { - children: [ - {column1}, - {column2}, - {column3}, - {column4}, - ], - count: 4, -}; diff --git a/src/components/molecules/layout/columns.test.tsx b/src/components/molecules/layout/columns.test.tsx deleted file mode 100644 index 2b016e7..0000000 --- a/src/components/molecules/layout/columns.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { Column, Columns } from './columns'; - -const column1 = - 'Non praesentium voluptas quisquam ex est. Distinctio accusamus facilis libero in aut. Et veritatis quo impedit fugit amet sit accusantium. Ut est rerum asperiores sint libero eveniet. Molestias placeat recusandae suscipit eligendi sunt hic.'; - -const column2 = - 'Quo perspiciatis mollitia non et. Modi voluptatem molestias. Facere ut molestiae exercitationem non nesciunt unde adipisci. Non cupiditate provident repudiandae. Natus quia necessitatibus libero enim earum quam et.'; - -const column3 = - 'Libero aut ab neque voluptatem commodi. Quam quia voluptatem iusto dolorum. Enim ipsa totam corrupti qui cum quidem ea. Eos sed aliquam porro consequatur officia sed.'; - -const column4 = - 'Ratione placeat ea ea. Explicabo rem eaque voluptatibus. Nihil nulla culpa et dolor numquam omnis est. Quis quas excepturi est dignissimos ducimus et ad quis quis. Eos enim et nam delectus.'; - -describe('Columns', () => { - it('renders all the children', () => { - render( - - {column1} - {column2} - {column3} - {column4} - - ); - - expect(rtlScreen.getByText(column1)).toBeInTheDocument(); - expect(rtlScreen.getByText(column2)).toBeInTheDocument(); - expect(rtlScreen.getByText(column3)).toBeInTheDocument(); - expect(rtlScreen.getByText(column4)).toBeInTheDocument(); - }); - - it('renders the right number of columns', () => { - render( - - {column1} - {column2} - {column3} - {column4} - - ); - - const container = rtlScreen.getByText(column1).parentElement; - - expect(container).toHaveClass('wrapper--3-columns'); - }); -}); diff --git a/src/components/molecules/layout/columns.tsx b/src/components/molecules/layout/columns.tsx deleted file mode 100644 index 56cd1a1..0000000 --- a/src/components/molecules/layout/columns.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { - FC, - HTMLAttributes, - ReactComponentElement, - ReactNode, -} from 'react'; -import styles from './columns.module.scss'; - -export type ColumnProps = HTMLAttributes & { - children: ReactNode; -}; - -/** - * Column component. - * - * Render the body as a column. - */ -export const Column: FC = ({ children, ...props }) => ( -
{children}
-); - -// eslint-disable-next-line @typescript-eslint/no-magic-numbers -type ColumnsNumber = 2 | 3 | 4; - -export type ColumnsProps = { - /** - * The columns. - */ - children: ReactComponentElement[]; - /** - * Set additional classnames to the columns wrapper. - */ - className?: string; - /** - * The number of columns. - */ - count: ColumnsNumber; - /** - * Should the columns be stacked on small devices? Default: true. - */ - responsive?: boolean; -}; - -/** - * Columns component. - * - * Render some Column components as columns. - */ -export const Columns: FC = ({ - children, - className = '', - count, - responsive = true, -}) => { - const countClass = `wrapper--${count}-columns`; - const responsiveClass = responsive - ? `wrapper--responsive` - : 'wrapper--no-responsive'; - const wrapperClass = `${styles.wrapper} ${styles[countClass]} ${styles[responsiveClass]} ${className}`; - - return
{children}
; -}; diff --git a/src/components/molecules/layout/index.ts b/src/components/molecules/layout/index.ts index 75cbe28..f204f56 100644 --- a/src/components/molecules/layout/index.ts +++ b/src/components/molecules/layout/index.ts @@ -1,3 +1,2 @@ -export * from './columns'; export * from './page-footer'; export * from './page-header'; -- cgit v1.2.3