Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/MasonryGrid/MasonryGrid.hooks.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>;
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<HTMLDivElement>(null);
const [columnCount, setColumnCount] = useState(minColumns);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Initialize state with clamped value for consistency.

The initial state uses minColumns directly, but if minColumns > maxColumns, the state will briefly hold an invalid value until the effect runs and corrects it. Initialize with clampedMinColumns to maintain consistency from the first render.

✨ Proposed fix
-  const [columnCount, setColumnCount] = useState(minColumns);
+  const [columnCount, setColumnCount] = useState(clampedMinColumns);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [columnCount, setColumnCount] = useState(minColumns);
const [columnCount, setColumnCount] = useState(clampedMinColumns);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/MasonryGrid/MasonryGrid.hooks.ts` at line 41, The initial
state for columnCount is set with minColumns which can be out of range before
the effect runs; change the useState initializer to use the already-computed
clampedMinColumns (i.e., useState(clampedMinColumns)) so columnCount starts
within [minColumns, maxColumns] consistently — update the initializer where
columnCount and setColumnCount are declared and ensure clampedMinColumns is
computed before that call.


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`,
},
};
};
11 changes: 11 additions & 0 deletions src/components/MasonryGrid/MasonryGrid.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.masonry-grid {
display: grid;
gap: var(--masonry-column-gap);
align-items: start;
Comment thread
ianpaschal marked this conversation as resolved.

&-column {
display: flex;
flex-direction: column;
gap: var(--masonry-row-gap);
}
}
2 changes: 2 additions & 0 deletions src/components/MasonryGrid/MasonryGrid.module.scss.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export declare const masonryGrid: string;
export declare const masonryGridColumn: string;
68 changes: 68 additions & 0 deletions src/components/MasonryGrid/MasonryGrid.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MasonryGrid>;

export default meta;
type Story = StoryObj<typeof meta>;

const SampleChildren = SAMPLE_ITEMS.map((item, index) => (
<div
key={index}
style={{
height: item.height,
background: item.color,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
color: '#555',
fontSize: 13,
}}
>
#{index + 1} · {item.height}px
</div>
));

export const Default: Story = {
args: {
minColumns: 1,
maxColumns: 4,
minColumnWidth: 150,
columnGap: 12,
rowGap: 12,
},
render: (args) => <MasonryGrid {...args}>{SampleChildren}</MasonryGrid>,
};
61 changes: 61 additions & 0 deletions src/components/MasonryGrid/MasonryGrid.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
Comment thread
ianpaschal marked this conversation as resolved.

return (
<div ref={ref} className={className} style={{ ...wrapperStyle, ...style }}>
<div className={styles.masonryGrid} style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}>
{columns.map((column, columnIndex) => (
<div key={columnIndex} className={styles.masonryGridColumn}>
{column.map((child, rowIndex) => (
<div key={rowIndex}>
{child}
</div>
))}
Comment thread
ianpaschal marked this conversation as resolved.
</div>
))}
</div>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/MasonryGrid/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './MasonryGrid';
2 changes: 0 additions & 2 deletions src/components/Select/Select.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/components/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ const meta: Meta<typeof Select> = {
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 } },
},
};
Expand All @@ -38,6 +44,7 @@ export const Text: Story = {
name: 'Text',
args: {
disabled: false,
size: 'normal',
options: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
Expand All @@ -53,6 +60,7 @@ export const Numeric: Story = {
name: 'Numeric',
args: {
disabled: false,
size: 'normal',
options: [
{ value: 1, label: 'Critical' },
{ value: 2, label: 'High' },
Expand All @@ -68,6 +76,7 @@ export const CustomItemComponents: Story = {
name: 'Custom Item Components',
args: {
disabled: false,
size: 'normal',
options: [
{
value: '7b90a423-1979-4e61-a30b-b19d663b3e43',
Expand All @@ -94,6 +103,7 @@ export const ControlledValue: Story = {
},
args: {
disabled: false,
size: 'normal',
options: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
Expand All @@ -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}`,
Expand Down
11 changes: 10 additions & 1 deletion src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -34,6 +35,7 @@ export interface SelectProps extends Omit<
'multiple' |
'onChange' |
'placeholder' |
'size' |
'value'
> {
defaultValue?: SelectValue | null;
Expand All @@ -42,6 +44,7 @@ export interface SelectProps extends Omit<
onChange?: (value: SelectValue | null) => void;
options: SelectOption[];
placeholder?: ReactNode;
size?: ElementSize;
value?: SelectValue | null;
}

Expand All @@ -58,6 +61,7 @@ export const Select = forwardRef<SelectRef, SelectProps>(({
placeholder = 'Select...',
readOnly,
required,
size = 'normal',
type: _type,
value,
...triggerProps
Expand All @@ -82,11 +86,16 @@ export const Select = forwardRef<SelectRef, SelectProps>(({
intent: 'secondary',
variant: 'ghost',
border: true,
size,
}), className)}
>
<BaseSelect.Value>
{(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;
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 3 additions & 1 deletion src/style/sizes.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading