From a9464ceca394347a66805f08af59a0a70a74ac13 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 13 May 2026 13:56:29 +0200 Subject: [PATCH 1/2] Add --- .../MasonryGrid/MasonryGrid.hooks.ts | 74 +++++++++++++++++++ .../MasonryGrid/MasonryGrid.module.scss | 11 +++ .../MasonryGrid/MasonryGrid.module.scss.d.ts | 2 + .../MasonryGrid/MasonryGrid.stories.tsx | 68 +++++++++++++++++ src/components/MasonryGrid/MasonryGrid.tsx | 61 +++++++++++++++ src/components/MasonryGrid/index.ts | 1 + src/index.ts | 1 + 7 files changed, 218 insertions(+) create mode 100644 src/components/MasonryGrid/MasonryGrid.hooks.ts create mode 100644 src/components/MasonryGrid/MasonryGrid.module.scss create mode 100644 src/components/MasonryGrid/MasonryGrid.module.scss.d.ts create mode 100644 src/components/MasonryGrid/MasonryGrid.stories.tsx create mode 100644 src/components/MasonryGrid/MasonryGrid.tsx create mode 100644 src/components/MasonryGrid/index.ts diff --git a/src/components/MasonryGrid/MasonryGrid.hooks.ts b/src/components/MasonryGrid/MasonryGrid.hooks.ts new file mode 100644 index 0000000..a8d5948 --- /dev/null +++ b/src/components/MasonryGrid/MasonryGrid.hooks.ts @@ -0,0 +1,74 @@ +import { + CSSProperties, + Ref, + useEffect, + useRef, + useState, +} from 'react'; + +export type MasonryConfig = { + minColumns?: number; + maxColumns?: number; + minColumnWidth?: number; + gap?: number; + columnGap?: number; + rowGap?: number; +}; + +type UseMasonryResult = { + ref: Ref; + columnCount: number; + wrapperStyle: CSSProperties; +}; + +export const useMasonry = (config: MasonryConfig): UseMasonryResult => { + const { + minColumns = 1, + maxColumns = 4, + minColumnWidth = 200, + gap = 16, + columnGap, + rowGap, + } = config; + + const resolvedColumnGap = columnGap ?? gap; + const resolvedRowGap = rowGap ?? gap; + + const clampedMinColumns = Math.min(minColumns, maxColumns); + const clampedMaxColumns = Math.max(minColumns, maxColumns); + + const ref = useRef(null); + const [columnCount, setColumnCount] = useState(minColumns); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + const updateColumns = (containerWidth: number): void => { + const effectiveGap = parseFloat(getComputedStyle(element).getPropertyValue('--masonry-column-gap')) || resolvedColumnGap; + const rawColumns = Math.floor((containerWidth + effectiveGap) / (minColumnWidth + effectiveGap)); + const clampedColumns = rawColumns > 0 ? rawColumns : clampedMinColumns; + setColumnCount(Math.min(clampedMaxColumns, Math.max(clampedMinColumns, clampedColumns))); + }; + + const resizeObserver = new ResizeObserver(([entry]) => { + const containerWidth = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + updateColumns(containerWidth); + }); + + resizeObserver.observe(element); + updateColumns(element.offsetWidth); + return () => resizeObserver.disconnect(); + }, [clampedMinColumns, clampedMaxColumns, minColumnWidth, resolvedColumnGap]); + + return { + ref, + columnCount, + wrapperStyle: { + ['--masonry-column-gap' as string]: `${resolvedColumnGap}px`, + ['--masonry-row-gap' as string]: `${resolvedRowGap}px`, + }, + }; +}; diff --git a/src/components/MasonryGrid/MasonryGrid.module.scss b/src/components/MasonryGrid/MasonryGrid.module.scss new file mode 100644 index 0000000..29d297b --- /dev/null +++ b/src/components/MasonryGrid/MasonryGrid.module.scss @@ -0,0 +1,11 @@ +.masonry-grid { + display: grid; + gap: var(--masonry-column-gap); + align-items: start; + + &-column { + display: flex; + flex-direction: column; + gap: var(--masonry-row-gap); + } +} diff --git a/src/components/MasonryGrid/MasonryGrid.module.scss.d.ts b/src/components/MasonryGrid/MasonryGrid.module.scss.d.ts new file mode 100644 index 0000000..ca8dc4c --- /dev/null +++ b/src/components/MasonryGrid/MasonryGrid.module.scss.d.ts @@ -0,0 +1,2 @@ +export declare const masonryGrid: string; +export declare const masonryGridColumn: string; diff --git a/src/components/MasonryGrid/MasonryGrid.stories.tsx b/src/components/MasonryGrid/MasonryGrid.stories.tsx new file mode 100644 index 0000000..367c608 --- /dev/null +++ b/src/components/MasonryGrid/MasonryGrid.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { MasonryGrid } from './MasonryGrid'; + +const SAMPLE_ITEMS = [ + { height: 120, color: '#c7f2d3' }, + { height: 200, color: '#fde8c8' }, + { height: 80, color: '#d4e4fb' }, + { height: 160, color: '#f9d4d4' }, + { height: 100, color: '#e8d5fb' }, + { height: 220, color: '#d4f5fb' }, + { height: 140, color: '#fbf3d4' }, + { height: 90, color: '#fbd4ee' }, + { height: 180, color: '#d4fbdf' }, + { height: 110, color: '#fbddd4' }, + { height: 130, color: '#d4d9fb' }, + { height: 75, color: '#f2fbd4' }, +]; + +const meta = { + title: 'Masonry Grid', + component: MasonryGrid, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + minColumns: { control: { type: 'range', min: 1, max: 6, step: 1 } }, + maxColumns: { control: { type: 'range', min: 1, max: 8, step: 1 } }, + minColumnWidth: { control: { type: 'range', min: 80, max: 400, step: 10 } }, + gap: { control: { type: 'range', min: 0, max: 48, step: 2 } }, + columnGap: { control: { type: 'range', min: 0, max: 48, step: 2 } }, + rowGap: { control: { type: 'range', min: 0, max: 48, step: 2 } }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const SampleChildren = SAMPLE_ITEMS.map((item, index) => ( +
+ #{index + 1} ยท {item.height}px +
+)); + +export const Default: Story = { + args: { + minColumns: 1, + maxColumns: 4, + minColumnWidth: 150, + columnGap: 12, + rowGap: 12, + }, + render: (args) => {SampleChildren}, +}; diff --git a/src/components/MasonryGrid/MasonryGrid.tsx b/src/components/MasonryGrid/MasonryGrid.tsx new file mode 100644 index 0000000..4dd0f40 --- /dev/null +++ b/src/components/MasonryGrid/MasonryGrid.tsx @@ -0,0 +1,61 @@ +import React, { Children } from 'react'; + +import { MasonryConfig, useMasonry } from './MasonryGrid.hooks'; + +import styles from './MasonryGrid.module.scss'; + +/** + * MasonryGrid + * + * Props: + * minColumns {number} Minimum number of columns (default: 1) + * maxColumns {number} Maximum number of columns (default: 4) + * minColumnWidth {number} Minimum column width in px used to compute column count (default: 200) + * gap {number} Shorthand for both columnGap and rowGap (default: 16) + * columnGap {number} Horizontal gap between columns in px (overrides gap) + * rowGap {number} Vertical gap between items in px (overrides gap) + * + * children {node[]} Items to lay out + * className {string} Class name applied to the wrapper div + * style {object} Inline styles applied to the wrapper div + * + * CSS vars (set on the wrapper, overridable externally): + * --masonry-column-gap + * --masonry-row-gap + */ +export interface MasonryGridProps extends MasonryConfig { + children?: React.ReactNode; + className?: string; + style?: React.CSSProperties; +} + +export const MasonryGrid = ({ + children, + className, + style, + ...props +}: MasonryGridProps) => { + const { ref, columnCount, wrapperStyle } = useMasonry(props); + + // Distribute children into columns top-to-bottom, left-to-right: + const columns = Array.from({ length: columnCount }, () => [] as React.ReactNode[]); + Children.forEach(children, (child, index) => { + columns[index % columnCount].push(child); + }); + + return ( +
+
+ {columns.map((column, columnIndex) => ( +
+ {column.map((child, rowIndex) => ( +
+ {child} +
+ ))} +
+ ))} +
+
+ ); +}; diff --git a/src/components/MasonryGrid/index.ts b/src/components/MasonryGrid/index.ts new file mode 100644 index 0000000..d76b216 --- /dev/null +++ b/src/components/MasonryGrid/index.ts @@ -0,0 +1 @@ +export * from './MasonryGrid'; diff --git a/src/index.ts b/src/index.ts index 2f7a13c..4a9c446 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export * from './components/InputDateTime'; export * from './components/InputPanel'; export * from './components/InputText'; export * from './components/InputTextArea'; +export * from './components/MasonryGrid'; export * from './components/Menu'; export * from './components/PdfViewer'; export * from './components/Radio'; From 7bd804ed5cd5d7862ff3d2832cec715bec833f16 Mon Sep 17 00:00:00 2001 From: Ian Paschal Date: Wed, 13 May 2026 14:15:23 +0200 Subject: [PATCH 2/2] CC-41: