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/components/Select/Select.module.scss b/src/components/Select/Select.module.scss index c1a6639..440f0b0 100644 --- a/src/components/Select/Select.module.scss +++ b/src/components/Select/Select.module.scss @@ -14,8 +14,6 @@ user-select: none; width: 100%; - min-height: var(--min-height); - padding: calc(0.5rem - var(--border-width)) calc(0.75rem - var(--border-width)); &[data-clearable="true"] { border-top-right-radius: 0; diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx index 31aa1b8..ba38be9 100644 --- a/src/components/Select/Select.stories.tsx +++ b/src/components/Select/Select.stories.tsx @@ -27,6 +27,12 @@ const meta: Meta = { table: { category: 'Behavior' }, }, onChange: { table: { disable: true } }, + size: { + control: 'select', + options: ['small', 'normal', 'large'], + description: 'The size of the select.', + table: { category: 'Appearance' }, + }, value: { table: { disable: true } }, }, }; @@ -38,6 +44,7 @@ export const Text: Story = { name: 'Text', args: { disabled: false, + size: 'normal', options: [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, @@ -53,6 +60,7 @@ export const Numeric: Story = { name: 'Numeric', args: { disabled: false, + size: 'normal', options: [ { value: 1, label: 'Critical' }, { value: 2, label: 'High' }, @@ -68,6 +76,7 @@ export const CustomItemComponents: Story = { name: 'Custom Item Components', args: { disabled: false, + size: 'normal', options: [ { value: '7b90a423-1979-4e61-a30b-b19d663b3e43', @@ -94,6 +103,7 @@ export const ControlledValue: Story = { }, args: { disabled: false, + size: 'normal', options: [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, @@ -109,6 +119,7 @@ export const ManyItems: Story = { name: 'Many (200+) Items', args: { disabled: false, + size: 'normal', options: Array.from({ length: 200 }, (_, i) => ({ value: i + 1, label: `Item ${i + 1}`, diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 6cca790..010859c 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -14,6 +14,7 @@ import { ChevronUp, } from 'lucide-react'; +import { ElementSize } from '../../types'; import { getStyleClassNames, sx } from '../../utils/getStyleClassNames'; import styles from './Select.module.scss'; @@ -34,6 +35,7 @@ export interface SelectProps extends Omit< 'multiple' | 'onChange' | 'placeholder' | + 'size' | 'value' > { defaultValue?: SelectValue | null; @@ -42,6 +44,7 @@ export interface SelectProps extends Omit< onChange?: (value: SelectValue | null) => void; options: SelectOption[]; placeholder?: ReactNode; + size?: ElementSize; value?: SelectValue | null; } @@ -58,6 +61,7 @@ export const Select = forwardRef(({ placeholder = 'Select...', readOnly, required, + size = 'normal', type: _type, value, ...triggerProps @@ -82,11 +86,16 @@ export const Select = forwardRef(({ intent: 'secondary', variant: 'ghost', border: true, + size, }), className)} > {(val: SelectValue | null) => { - const opt = options.find((o) => o.value === val) ?? null; + + /* BaseUI may pass undefined instead of null when the selected value is null, so use loose + * equality for null-valued options to match both. + */ + const opt = options.find((o) => (o.value === null ? val == null : o.value === val)) ?? null; if (opt === null) { return placeholder; } 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'; diff --git a/src/style/sizes.module.scss b/src/style/sizes.module.scss index 4c257a5..1c987f7 100644 --- a/src/style/sizes.module.scss +++ b/src/style/sizes.module.scss @@ -19,7 +19,9 @@ box-sizing: border-box; min-width: $size; min-height: $size; - padding: calc(#{$padding} - var(--border-width)); // Assume always used with variants (1px border, even if invisible) + + // Assume always used with variants (1px border, even if invisible) + padding: calc(#{$padding} - var(--border-width)) calc(#{$padding} + 0.125rem - var(--border-width)); &.collapse-padding { margin: 0 calc($padding * -1);