Skip to content

Commit 76160b7

Browse files
committed
fix(ktl-2781): balance masonry columns
1 parent d37c71e commit 76160b7

File tree

2 files changed

+146
-39
lines changed

2 files changed

+146
-39
lines changed

blocks/case-studies/grid/case-studies-grid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ export const CaseStudiesGrid: React.FC = () => {
2020
</div>
2121
</section>
2222
);
23-
};
23+
};
Lines changed: 145 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import cn from 'classnames';
2-
import { useMemo, ReactNode, useEffect, useState } from 'react';
2+
import { useMemo, ReactNode, useEffect, useRef, useState } from 'react';
33
import styles from './masonry-grid.module.css';
44

55
export interface MasonryGridProps<T> {
@@ -15,65 +15,172 @@ export interface MasonryGridProps<T> {
1515
}
1616

1717
export function MasonryGrid<T>({
18-
items,
19-
columnCount = 2,
20-
gap,
21-
renderItem,
22-
getKey,
23-
className,
24-
columnClassName,
25-
itemClassName,
26-
mobileBreakpoint = 808,
27-
}: MasonryGridProps<T>) {
18+
items,
19+
columnCount = 2,
20+
gap,
21+
renderItem,
22+
getKey,
23+
className,
24+
columnClassName,
25+
itemClassName,
26+
mobileBreakpoint = 808,
27+
}: MasonryGridProps<T>) {
2828
const [isMobile, setIsMobile] = useState(false);
2929

30+
// Container refs for measuring and responsive recalculation
31+
const gridRef = useRef<HTMLDivElement | null>(null);
32+
const measureWrapperRef = useRef<HTMLDivElement | null>(null);
33+
const measureItemRefs = useRef<(HTMLDivElement | null)[]>([]);
34+
35+
const [containerWidth, setContainerWidth] = useState<number>(0);
36+
37+
// Debounce helper
38+
const debounce = (fn: () => void, ms: number) => {
39+
let id: number | undefined;
40+
return () => {
41+
if (id) window.clearTimeout(id);
42+
id = window.setTimeout(fn, ms);
43+
};
44+
};
45+
46+
// Track viewport breakpoint and container width
3047
useEffect(() => {
31-
const update = () => {
48+
const handleResize = () => {
3249
if (typeof window !== 'undefined') {
3350
setIsMobile(window.innerWidth <= mobileBreakpoint);
51+
if (gridRef.current) {
52+
setContainerWidth(gridRef.current.clientWidth);
53+
}
3454
}
3555
};
36-
update();
37-
window.addEventListener('resize', update);
38-
return () => window.removeEventListener('resize', update);
56+
// Debounced to avoid thrashing
57+
const debounced = debounce(handleResize, 150);
58+
handleResize();
59+
window.addEventListener('resize', debounced);
60+
return () => window.removeEventListener('resize', debounced);
3961
}, [mobileBreakpoint]);
4062

4163
const effectiveColumnCount = Math.max(1, isMobile ? 1 : columnCount);
4264

43-
const columns = useMemo(() => {
44-
const cols: T[][] = Array.from({ length: effectiveColumnCount }, () => []);
65+
// Compute per-column width (used by hidden measuring container)
66+
const columnWidth = useMemo(() => {
67+
const g = gap ?? 0;
68+
if (!containerWidth || effectiveColumnCount <= 0) return 0;
69+
return Math.max(0, Math.floor((containerWidth - g * (effectiveColumnCount - 1)) / effectiveColumnCount));
70+
}, [containerWidth, effectiveColumnCount, gap]);
71+
72+
// State with the final greedy distribution
73+
const [greedyColumns, setGreedyColumns] = useState<T[][] | null>(null);
74+
75+
// Measure item heights in a hidden container and distribute greedily
76+
useEffect(() => {
77+
// Avoid running on server or when measurement isn't possible yet
78+
if (typeof window === 'undefined') return;
79+
if (effectiveColumnCount <= 0) return;
80+
if (!measureWrapperRef.current) return;
81+
if (columnWidth <= 0) return;
82+
83+
// Defer until browser lays out hidden nodes
84+
const id = window.setTimeout(() => {
85+
// Collect heights for each item (fallback to 0 if not measurable)
86+
const heights = items.map((_, i) => measureItemRefs.current[i]?.offsetHeight ?? 0);
4587

88+
// Greedy placement by current column total heights
89+
const cols: T[][] = Array.from({ length: effectiveColumnCount }, () => []);
90+
const colHeights: number[] = Array.from({ length: effectiveColumnCount }, () => 0);
91+
const g = gap ?? 0;
92+
93+
items.forEach((item, i) => {
94+
// Find the shortest column
95+
let minIndex = 0;
96+
let minValue = colHeights[0];
97+
for (let c = 1; c < effectiveColumnCount; c++) {
98+
if (colHeights[c] < minValue) {
99+
minIndex = c;
100+
minValue = colHeights[c];
101+
}
102+
}
103+
cols[minIndex].push(item);
104+
const h = heights[i] || 0;
105+
// Add gap except for the very first item in a column
106+
colHeights[minIndex] += (colHeights[minIndex] > 0 ? g : 0) + h;
107+
});
108+
109+
setGreedyColumns(cols);
110+
}, 0);
111+
112+
return () => window.clearTimeout(id);
113+
// Recompute when items, columns or width change
114+
}, [items, effectiveColumnCount, columnWidth, gap]);
115+
116+
// If we don't have measurements yet, fall back to simple cyclic distribution to avoid empty UI
117+
const cyclicColumns = useMemo(() => {
118+
const cols: T[][] = Array.from({ length: effectiveColumnCount }, () => []);
46119
items.forEach((item, index) => {
47120
const columnIndex = index % effectiveColumnCount;
48121
cols[columnIndex].push(item);
49122
});
50-
51123
return cols;
52124
}, [items, effectiveColumnCount]);
53125

126+
const columns = greedyColumns ?? cyclicColumns;
127+
54128
const style = gap !== undefined ? { gap: `${gap}px` } : undefined;
55129

56130
return (
57-
<div className={cn(styles.grid, className)} style={style}>
58-
{columns.map((column, columnIndex) => (
59-
<div
60-
key={columnIndex}
61-
className={cn(styles.column, columnClassName)}
62-
style={style}
63-
>
64-
{column.map((item, itemIndex) => {
65-
const originalIndex = columnIndex + itemIndex * effectiveColumnCount;
66-
return (
67-
<div
68-
key={getKey(item, originalIndex)}
69-
className={cn(styles.item, itemClassName)}
70-
>
71-
{renderItem(item, originalIndex)}
72-
</div>
73-
);
74-
})}
131+
<>
132+
{/* Visible grid */}
133+
<div ref={gridRef} className={cn(styles.grid, className)} style={style}>
134+
{columns.map((column, columnIndex) => (
135+
<div
136+
key={columnIndex}
137+
className={cn(styles.column, columnClassName)}
138+
style={style}
139+
>
140+
{column.map((item, itemIndex) => {
141+
// Preserve stable keys based on original index order
142+
const originalIndex = columnIndex + itemIndex * Math.max(1, effectiveColumnCount);
143+
return (
144+
<div
145+
key={getKey(item, originalIndex)}
146+
className={cn(styles.item, itemClassName)}
147+
>
148+
{/*<div style={{color: 'white'}}>{originalIndex}</div>*/}
149+
{renderItem(item, originalIndex)}
150+
</div>
151+
);
152+
})}
153+
</div>
154+
))}
155+
</div>
156+
157+
{/* Hidden measuring container: renders all items at exact column width to get real heights */}
158+
<div
159+
ref={measureWrapperRef}
160+
aria-hidden
161+
style={{
162+
position: 'absolute',
163+
visibility: 'hidden',
164+
pointerEvents: 'none',
165+
left: -99999,
166+
top: 0,
167+
width: columnWidth || undefined,
168+
}}
169+
>
170+
{/* Force the same width as a column so heights match real layout */}
171+
<div style={{ width: columnWidth }}>
172+
{items.map((item, i) => (
173+
<div
174+
key={getKey(item, i)}
175+
ref={(el) => (measureItemRefs.current[i] = el)}
176+
className={cn(styles.item, itemClassName)}
177+
style={{ marginBottom: gap && i < items.length - 1 ? gap : undefined }}
178+
>
179+
{renderItem(item, i)}
180+
</div>
181+
))}
75182
</div>
76-
))}
77-
</div>
183+
</div>
184+
</>
78185
);
79186
}

0 commit comments

Comments
 (0)