diff options
Diffstat (limited to 'src/components/molecules/grid')
| -rw-r--r-- | src/components/molecules/grid/grid.module.scss | 41 | ||||
| -rw-r--r-- | src/components/molecules/grid/grid.stories.tsx | 140 | ||||
| -rw-r--r-- | src/components/molecules/grid/grid.test.tsx | 69 | ||||
| -rw-r--r-- | src/components/molecules/grid/grid.tsx | 116 | ||||
| -rw-r--r-- | src/components/molecules/grid/index.ts | 1 |
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'; |
