@zapperwing/pinterest-view
Version:
A Pinterest-style grid layout component for React.js with responsive design, dynamic content support, and bulletproof virtualization
1,005 lines (871 loc) • 29.8 kB
JavaScript
// src/components/StackGrid.js
/* eslint-disable max-classes-per-file */
/* eslint-disable react/default-props-match-prop-types */
/* eslint-disable react/no-unused-prop-types */
/* eslint-disable react/jsx-filename-extension */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable no-param-reassign */
import React, { Component, isValidElement, forwardRef, useRef, useImperativeHandle } from 'react';
import PropTypes from 'prop-types';
import sizeMe from 'react-sizeme';
import computeLayout, { computeContainerHeight } from '../utils/computeLayout';
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
const isPercentageNumber = (v) => typeof v === 'string' && /^\d+(\.\d+)?%$/.test(v);
const getColumnConfig = (containerWidth, columnWidth, gutterWidth, alignment = 'left') => {
let columnCount, actualColumnWidth, totalGridWidth, offsetX;
if (isNumber(columnWidth)) {
// Keep the column width fixed, only calculate number of columns
columnCount = Math.floor((containerWidth + gutterWidth) / (columnWidth + gutterWidth));
actualColumnWidth = columnWidth;
} else if (isPercentageNumber(columnWidth)) {
const percentage = parseFloat(columnWidth) / 100;
columnCount = Math.floor(1 / percentage);
actualColumnWidth = (containerWidth - (columnCount - 1) * gutterWidth) / columnCount;
} else {
throw new Error('columnWidth must be a number or percentage string');
}
columnCount = Math.max(1, columnCount);
// Calculate total grid width and offset for alignment
totalGridWidth = columnCount * actualColumnWidth + (columnCount - 1) * gutterWidth;
switch (alignment) {
case 'center':
offsetX = (containerWidth - totalGridWidth) / 2;
break;
case 'right':
offsetX = containerWidth - totalGridWidth;
break;
case 'left':
default:
offsetX = 0;
break;
}
return {
columnCount,
columnWidth: actualColumnWidth,
totalGridWidth,
offsetX
};
};
// Optimized column finding - O(n) instead of O(n*m)
const getShortestColumn = (heights) => {
let minIndex = 0;
let minHeight = heights[0];
for (let i = 1; i < heights.length; i += 1) {
if (heights[i] < minHeight) {
minHeight = heights[i];
minIndex = i;
}
}
return minIndex;
};
const GridItem = React.memo(React.forwardRef((
{
itemKey,
component: Element,
rect = {
top: 0,
left: 0,
width: 0,
height: 0,
},
style,
rtl,
children,
onHeightChange,
...rest
},
ref,
) => {
const itemRef = React.useRef(null);
React.useEffect(() => {
if (!itemRef.current || typeof onHeightChange !== 'function') return;
// Debounced height update to prevent cascade of updates
let timeoutId;
const updateHeight = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (!itemRef.current) return;
const { height } = itemRef.current.getBoundingClientRect();
onHeightChange(height);
}, 100); // Debounce by 100ms
};
// Initial height
updateHeight();
// ResizeObserver for height changes - use contentRect to avoid reflow
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const { height } = entry.contentRect;
onHeightChange(height);
}
});
ro.observe(itemRef.current);
return () => {
clearTimeout(timeoutId);
ro.disconnect();
};
}, [onHeightChange, itemKey]);
const itemStyle = {
...style,
position: 'absolute',
top: rect.top,
left: rtl ? 'auto' : rect.left,
right: rtl ? rect.left : 'auto',
width: rect.width,
zIndex: 1,
transition: 'none', // REMOVE all transitions for static layout
contain: 'layout style',
willChange: 'auto',
};
return (
<Element
{...rest}
ref={(node) => {
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
itemRef.current = node;
}}
className="grid-item"
style={itemStyle}
>
{children}
</Element>
);
}), (prevProps, nextProps) => prevProps.itemKey === nextProps.itemKey
&& prevProps.rect.top === nextProps.rect.top
&& prevProps.rect.left === nextProps.rect.left
&& prevProps.rect.width === nextProps.rect.width
&& prevProps.rect.height === nextProps.rect.height
&& prevProps.rtl === nextProps.rtl
&& prevProps.children === nextProps.children);
GridItem.displayName = 'GridItem';
GridItem.propTypes = {
itemKey: PropTypes.string,
component: PropTypes.string,
rect: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
}),
style: PropTypes.shape({}),
rtl: PropTypes.bool,
children: PropTypes.node,
onHeightChange: PropTypes.func,
};
GridItem.defaultProps = {
itemKey: '',
component: 'div',
rect: {
top: 0,
left: 0,
width: 0,
height: 0,
},
style: {},
rtl: false,
children: null,
onHeightChange: null,
};
const GridInlinePropTypes = {
className: PropTypes.string,
style: PropTypes.shape({}),
component: PropTypes.string,
itemComponent: PropTypes.string,
children: PropTypes.node,
rtl: PropTypes.bool,
onLayout: PropTypes.func,
gridRef: PropTypes.func,
size: PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number,
registerRef: PropTypes.func,
unregisterRef: PropTypes.func,
}),
columnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
gutterWidth: PropTypes.number,
gutterHeight: PropTypes.number,
virtualized: PropTypes.bool,
debug: PropTypes.bool,
virtualizationBuffer: PropTypes.number,
scrollContainer: PropTypes.instanceOf(HTMLElement),
alignment: PropTypes.oneOf(['left', 'center', 'right']),
};
const GridInlineDefaultProps = {
className: '',
style: {},
component: 'div',
itemComponent: 'div',
children: null,
rtl: false,
onLayout: () => {},
gridRef: null,
size: null,
columnWidth: 150,
gutterWidth: 5,
gutterHeight: 5,
virtualized: false,
debug: false,
virtualizationBuffer: 800,
scrollContainer: null,
alignment: 'left',
};
class GridInline extends Component {
constructor(props) {
super(props);
this.state = {
rects: [],
height: 0,
scrollTop: 0,
measurementPhase: true, // Start in measurement phase
allItemsMeasured: false, // Track when all items are measured
};
this.containerRef = React.createRef();
this.measurementContainerRef = React.createRef(); // Hidden container for measurements
this.heightCache = new Map();
this.columnAssignments = new Map();
this.mounted = false;
this.layoutRequestId = null;
this.scrollRAF = null;
this.scroller = props.scrollContainer || window;
// Layout system
this.rectsMap = new Map();
this.measuredKeys = new Set();
this.measurementTimeout = null; // Timeout fallback for measurement phase
}
componentDidMount() {
this.mounted = true;
const {
size,
gridRef,
virtualized,
columnWidth,
children,
scrollContainer,
} = this.props;
size?.registerRef?.(this);
gridRef?.(this);
// Update scroller if it changed
this.scroller = scrollContainer || window;
this.debugLog('Component mounted', {
virtualized,
columnWidth,
childrenCount: React.Children.count(children),
scrollContainer: scrollContainer ? 'custom' : 'window',
}, true);
// Listen to scroll events only if virtualized
if (virtualized) {
this.scroller.addEventListener('scroll', this.handleScroll, { passive: true });
this.debugLog('Scroll listener attached (virtualized mode)', {
scroller: this.scroller === window ? 'window' : 'custom container',
});
}
window.addEventListener('resize', this.handleResize, { passive: true });
// Set up ResizeObserver fallback for when react-sizeme fails
if (this.containerRef.current) {
this.resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry && this.mounted) {
const { width } = entry.contentRect;
if (width > 0 && (!this.props.size || !this.props.size.width)) {
this.debugLog('ResizeObserver fallback: width =', width);
// Force a layout update with the measured width
this.forceUpdate();
}
}
});
this.resizeObserver.observe(this.containerRef.current);
}
// Initial layout using new system
this.layout();
}
componentDidUpdate(prevProps) {
const {
children,
size,
columnWidth,
gutterWidth,
gutterHeight,
scrollContainer,
virtualized,
} = this.props;
const childrenChanged = prevProps.children !== children;
const sizeChanged = prevProps.size?.width !== size?.width;
const layoutPropsChanged = prevProps.columnWidth !== columnWidth
|| prevProps.gutterWidth !== gutterWidth
|| prevProps.gutterHeight !== gutterHeight;
const scrollContainerChanged = prevProps.scrollContainer !== scrollContainer;
const virtualizedChanged = prevProps.virtualized !== virtualized;
// If the scroll container or virtualization state has changed, we need to update listeners.
if ((scrollContainerChanged || virtualizedChanged) && this.mounted) {
this.debugLog('Updating scroll listeners', {
containerChanged: scrollContainerChanged,
virtualizedChanged,
});
// 1. Always try to remove the listener from the OLD scroller,
// but only if virtualization was previously enabled.
if (prevProps.virtualized) {
const oldScroller = prevProps.scrollContainer || window;
oldScroller.removeEventListener('scroll', this.handleScroll);
this.debugLog('Removed scroll listener from', {
scroller: oldScroller === window ? 'window' : 'old custom container',
});
}
// 2. Update the internal scroller reference to the NEW one.
this.scroller = scrollContainer || window;
// 3. Always try to add the listener to the NEW scroller,
// but only if virtualization is currently enabled.
if (virtualized) {
this.scroller.addEventListener('scroll', this.handleScroll, { passive: true });
this.debugLog('Added scroll listener to', {
scroller: this.scroller === window ? 'window' : 'new custom container',
});
}
}
// Clean up height cache for removed children
if (childrenChanged) {
const currentKeys = new Set(
React.Children.toArray(children)
.filter(isValidElement)
.map((child) => child.key),
);
this.heightCache.forEach((value, key) => {
if (!currentKeys.has(key)) {
this.heightCache.delete(key);
this.columnAssignments.delete(key);
this.measuredKeys.delete(key);
}
});
// Reset measurement state when children change
this.setState((prevState) => ({
...prevState,
measurementPhase: true,
allItemsMeasured: false,
}));
this.measuredKeys.clear();
// Clear any existing measurement timeout
if (this.measurementTimeout) {
clearTimeout(this.measurementTimeout);
this.measurementTimeout = null;
}
// Set a timeout fallback to prevent getting stuck in measurement phase
this.measurementTimeout = setTimeout(() => {
if (this.state.measurementPhase && this.mounted) {
this.debugLog('Measurement timeout reached, forcing transition to virtualized phase');
this.finalizeMeasurementPhase();
}
}, 5000); // 5 second timeout
}
// Only do layout updates if not in measurement phase
const { measurementPhase } = this.state;
if ((childrenChanged || sizeChanged || layoutPropsChanged) && !measurementPhase) {
this.debugLog('Layout update triggered', {
childrenChanged,
sizeChanged: sizeChanged
? `${prevProps.size?.width} → ${size?.width}`
: false,
layoutPropsChanged,
newChildrenCount: React.Children.count(children),
});
this.layout();
}
}
componentWillUnmount() {
this.mounted = false;
const { size } = this.props;
size?.unregisterRef?.(this);
this.debugLog('Component unmounting', null, true);
// Always try to remove the scroll listener if a scroller was ever set
if (this.scroller) {
this.scroller.removeEventListener('scroll', this.handleScroll);
}
window.removeEventListener('resize', this.handleResize);
// Clean up ResizeObserver
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.layoutRequestId) {
cancelAnimationFrame(this.layoutRequestId);
}
if (this.scrollRAF) {
cancelAnimationFrame(this.scrollRAF);
}
// Clear measurement timeout
if (this.measurementTimeout) {
clearTimeout(this.measurementTimeout);
this.measurementTimeout = null;
}
}
// Debug logging method (no-op)
debugLog = () => {
// All logging removed - this method does nothing
};
handleScroll = () => {
const { virtualized } = this.props;
if (!this.mounted || !virtualized || !this.scroller) return;
// Use requestAnimationFrame for better performance
if (this.scrollRAF) return;
this.scrollRAF = requestAnimationFrame(() => {
// Get scroll position from the appropriate container
const scrollTop = this.scroller === window
? (window.pageYOffset || document.documentElement.scrollTop)
: this.scroller.scrollTop;
const { scrollTop: currentScrollTop } = this.state;
// Always update scroll position for proper virtualization
if (Math.abs(scrollTop - currentScrollTop) > 10) {
// Reduced threshold for better responsiveness
this.debugLog('Scroll position changed', {
from: currentScrollTop,
to: scrollTop,
delta: scrollTop - currentScrollTop,
scroller: this.scroller === window ? 'window' : 'custom container',
});
this.setState((prevState) => ({ ...prevState, scrollTop }));
}
this.scrollRAF = null;
});
};
handleResize = () => {
if (!this.mounted) return;
this.debugLog('Window resize detected');
this.layout();
};
// Layout method for manual control
layout = () => {
const {
children,
columnWidth,
gutterWidth,
gutterHeight,
size,
alignment,
} = this.props;
let containerWidth = size?.width;
// Fallback: try to get width from DOM if react-sizeme failed
if (!containerWidth || containerWidth <= 0) {
if (this.containerRef.current) {
containerWidth = this.containerRef.current.clientWidth;
this.debugLog('Using DOM fallback width:', containerWidth);
}
}
if (!containerWidth || containerWidth <= 0) {
this.debugLog('No container width available, skipping layout');
return;
}
const validChildren = React.Children.toArray(children).filter(isValidElement);
if (validChildren.length === 0) {
this.rectsMap.clear();
this.setState((prevState) => ({ ...prevState, rects: [], height: 0 }));
return;
}
try {
const { columnCount, columnWidth: actualColumnWidth, offsetX } = getColumnConfig(
containerWidth,
columnWidth,
gutterWidth,
alignment,
);
const config = {
columnCount,
columnWidth: actualColumnWidth,
gutterWidth,
gutterHeight,
offsetX,
};
const keys = validChildren.map((child) => child.key);
const rectsObj = computeLayout(keys, this.heightCache, config);
this.rectsMap = new Map(Object.entries(rectsObj));
const height = computeContainerHeight(rectsObj, config);
this.debugLog('Layout computed', {
items: validChildren.length,
columns: columnCount,
height,
alignment,
offsetX,
});
this.setState((prevState) => ({
...prevState,
rects: keys.map((key) => rectsObj[key]),
height,
}), () => {
const { onLayout } = this.props;
if (typeof onLayout === 'function') {
onLayout({ height });
}
});
} catch (error) {
// Error logging removed
}
};
handleHeightChange = (key, height) => {
// Always cache the height
const oldHeight = this.heightCache.get(key);
this.heightCache.set(key, height);
// Track that this key has been measured
this.measuredKeys.add(key);
// Check if this is the first time we're measuring this item
const isInitialMeasurement = oldHeight === undefined;
// During measurement phase, only collect heights, don't trigger layout updates
if (this.state.measurementPhase) {
if (isInitialMeasurement) {
this.debugLog('Measurement phase - height measured', {
key,
height,
measuredKeysCount: this.measuredKeys.size,
});
}
// Always check for measurement completion, not just on initial measurements
this.checkMeasurementCompletion();
return; // Don't do anything else during measurement phase
}
// After measurement phase, only trigger layout updates for actual height changes
if (oldHeight !== height && !isInitialMeasurement) {
this.debugLog('Item height changed (post-measurement)', {
key,
from: oldHeight,
to: height,
});
// Debounce layout updates to prevent thrashing
if (this.layoutRequestId) {
cancelAnimationFrame(this.layoutRequestId);
}
this.layoutRequestId = requestAnimationFrame(() => {
this.updateLayoutForHeightChange(key, oldHeight, height);
this.layoutRequestId = null;
});
}
};
checkMeasurementCompletion = () => {
// Check if we have measured all current items
const { children } = this.props;
const validChildren = React.Children.toArray(children).filter(isValidElement);
const currentKeys = new Set(validChildren.map(child => child.key));
// Only count keys that are still in the current children
const measuredCurrentKeys = Array.from(this.measuredKeys).filter(key => currentKeys.has(key));
this.debugLog('Measurement progress', {
measuredKeys: Array.from(this.measuredKeys),
currentKeys: Array.from(currentKeys),
measuredCurrentKeys,
measuredCurrentCount: measuredCurrentKeys.length,
totalCurrentCount: validChildren.length,
});
if (measuredCurrentKeys.length >= validChildren.length) {
// All current items measured - transition to virtualized phase
this.debugLog('All current items measured, transitioning to virtualized phase');
this.finalizeMeasurementPhase();
}
};
finalizeMeasurementPhase = () => {
// Clear measurement timeout
if (this.measurementTimeout) {
clearTimeout(this.measurementTimeout);
this.measurementTimeout = null;
}
// Calculate the final layout with all measured heights
this.layout();
// Transition to virtualized phase
this.setState((prevState) => ({
...prevState,
measurementPhase: false,
allItemsMeasured: true,
}), () => {
this.debugLog('Measurement phase complete, virtualization active');
});
};
updateLayoutForHeightChange = (changedKey, oldHeight, newHeight) => {
if (!this.mounted) return;
const {
children,
columnWidth,
gutterWidth,
gutterHeight,
size,
alignment,
} = this.props;
let containerWidth = size?.width;
// Fallback: try to get width from DOM if react-sizeme failed
if (!containerWidth || containerWidth <= 0) {
if (this.containerRef.current) {
containerWidth = this.containerRef.current.clientWidth;
}
}
if (!containerWidth || containerWidth <= 0) return;
const validChildren = React.Children.toArray(children).filter(isValidElement);
const changedIndex = validChildren.findIndex((child) => child.key === changedKey);
if (changedIndex === -1) return;
try {
const { columnCount, columnWidth: actualColumnWidth, offsetX } = getColumnConfig(
containerWidth,
columnWidth,
gutterWidth,
alignment,
);
// Recalculate layout from the changed item onwards
const columnHeights = new Array(columnCount).fill(0);
const rects = [];
// First pass: calculate positions up to the changed item
for (let i = 0; i < changedIndex; i += 1) {
const child = validChildren[i];
const itemHeight = this.heightCache.get(child.key) || 200;
// Find shortest column
const shortestColumnIndex = getShortestColumn(columnHeights);
const left = offsetX + shortestColumnIndex * (actualColumnWidth + gutterWidth);
const top = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] = top + itemHeight + gutterHeight;
rects.push({
top,
left,
width: actualColumnWidth,
height: itemHeight,
});
}
// Second pass: recalculate from changed item onwards
for (let i = changedIndex; i < validChildren.length; i += 1) {
const child = validChildren[i];
const itemHeight = this.heightCache.get(child.key) || 200;
// Find shortest column
const shortestColumnIndex = getShortestColumn(columnHeights);
const left = offsetX + shortestColumnIndex * (actualColumnWidth + gutterWidth);
const top = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] = top + itemHeight + gutterHeight;
rects.push({
top,
left,
width: actualColumnWidth,
height: itemHeight,
});
}
const height = Math.max(...columnHeights) - gutterHeight;
this.debugLog('Layout updated for height change', {
changedKey,
oldHeight,
newHeight,
totalHeight: height,
alignment,
offsetX,
});
this.setState((prevState) => ({ ...prevState, rects, height }), () => {
const { onLayout } = this.props;
if (typeof onLayout === 'function') {
onLayout({ height });
}
});
} catch (error) {
// Error logging removed
}
};
render() {
const {
className,
style,
component: ElementType,
itemComponent,
children,
rtl,
virtualized,
} = this.props;
const { rects, height, scrollTop, measurementPhase, allItemsMeasured } = this.state;
const validChildren = React.Children.toArray(children).filter(isValidElement);
// Always render the measurement container in the background (hidden)
const measurementStyle = {
position: 'absolute',
top: '-9999px',
left: '-9999px',
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
};
const measurementItems = validChildren.map((child) => (
<GridItem
key={`measure-${child.key}`}
itemKey={child.key}
component={itemComponent}
rect={{ top: 0, left: 0, width: 300, height: 200 }}
rtl={rtl}
onHeightChange={(itemHeight) => this.handleHeightChange(child.key, itemHeight)}
>
{child}
</GridItem>
));
// Check if all items are already measured (this can happen when items are removed)
if (measurementPhase && validChildren.length > 0) {
const currentKeys = new Set(validChildren.map(child => child.key));
const measuredCurrentKeys = Array.from(this.measuredKeys).filter(key => currentKeys.has(key));
if (measuredCurrentKeys.length >= validChildren.length) {
// Use setTimeout to avoid calling setState during render
setTimeout(() => {
if (this.state.measurementPhase && this.mounted) {
this.debugLog('All items already measured, transitioning immediately');
this.finalizeMeasurementPhase();
}
}, 0);
}
}
// Main grid container
const containerStyle = {
position: 'relative',
height: measurementPhase ? (height || '100vh') : height,
...style,
};
let renderedItems = validChildren;
let virtualizedCount = 0;
// Only virtualize if we have measured all items and virtualization is enabled
if (virtualized && this.scroller && allItemsMeasured) {
const { virtualizationBuffer = 200 } = this.props;
const viewportHeight = this.scroller === window
? window.innerHeight
: this.scroller.clientHeight;
const visibleTop = scrollTop - virtualizationBuffer;
const visibleBottom = scrollTop + viewportHeight + virtualizationBuffer;
const beforeCount = renderedItems.length;
renderedItems = validChildren.filter((child, index) => {
const rect = rects[index];
if (!rect) return false;
const itemTop = rect.top;
const itemBottom = itemTop + rect.height;
return itemBottom >= visibleTop && itemTop <= visibleBottom;
});
virtualizedCount = beforeCount - renderedItems.length;
if (virtualizedCount > 0) {
this.debugLog('Virtualization active', {
total: validChildren.length,
rendered: renderedItems.length,
hidden: virtualizedCount,
scrollTop,
visibleRange: [visibleTop, visibleBottom],
viewportHeight,
});
}
}
const gridItems = renderedItems.map((child) => {
const originalIndex = validChildren.indexOf(child);
const rect = rects[originalIndex];
// During measurement phase, show items with estimated positions or previous positions
if (measurementPhase) {
// Use previous rect if available, otherwise use a default
const displayRect = rect || { top: 0, left: 0, width: 300, height: 200 };
return (
<GridItem
key={child.key}
itemKey={child.key}
component={itemComponent}
rect={displayRect}
rtl={rtl}
onHeightChange={(itemHeight) => this.handleHeightChange(child.key, itemHeight)}
>
{child}
</GridItem>
);
}
// Only render items that have calculated positions after measurement
if (!rect) return null;
return (
<GridItem
key={child.key}
itemKey={child.key}
component={itemComponent}
rect={rect}
rtl={rtl}
onHeightChange={(itemHeight) => this.handleHeightChange(child.key, itemHeight)}
>
{child}
</GridItem>
);
});
return (
<div>
{/* Hidden measurement container */}
<div ref={this.measurementContainerRef} style={measurementStyle}>
{measurementItems}
</div>
{/* Main grid container */}
<ElementType
className={className}
style={containerStyle}
ref={this.containerRef}
>
{gridItems}
</ElementType>
</div>
);
}
}
// Move propTypes and defaultProps outside the class
GridInline.propTypes = GridInlinePropTypes;
GridInline.defaultProps = GridInlineDefaultProps;
// --- Ref forwarding adapter for react-sizeme HOC ---
const SizedGrid = sizeMe({
monitorHeight: false,
monitorWidth: true,
refreshMode: 'debounce',
refreshRate: 16,
noPlaceholder: true
})(GridInline);
const StackGridPropTypes = {
children: PropTypes.node,
className: PropTypes.string,
style: PropTypes.shape({}),
gridRef: PropTypes.func,
component: PropTypes.string,
itemComponent: PropTypes.string,
columnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
gutterWidth: PropTypes.number,
gutterHeight: PropTypes.number,
onLayout: PropTypes.func,
rtl: PropTypes.bool,
virtualized: PropTypes.bool,
debug: PropTypes.bool,
size: PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number,
registerRef: PropTypes.func,
unregisterRef: PropTypes.func,
}),
scrollContainer: PropTypes.instanceOf(HTMLElement),
alignment: PropTypes.oneOf(['left', 'center', 'right']),
};
const StackGridDefaultProps = {
children: null,
className: '',
style: {},
gridRef: null,
component: 'div',
itemComponent: 'div',
gutterWidth: 5,
gutterHeight: 5,
onLayout: null,
rtl: false,
virtualized: false,
debug: false,
size: null,
scrollContainer: null,
alignment: 'left',
};
const StackGrid = forwardRef((props, ref) => {
const inner = useRef(null);
useImperativeHandle(
ref,
() => {
if (inner.current) {
// Alias updateLayout for test compatibility
inner.current.updateLayout = inner.current.layout.bind(inner.current);
}
return inner.current;
},
[]
);
const handleGridRef = (inst) => {
inner.current = inst;
// Also call the original gridRef prop if provided
if (props.gridRef) {
props.gridRef(inst);
}
};
// Use react-sizeme HOC with better configuration
return <SizedGrid {...props} gridRef={handleGridRef} />;
});
// Move propTypes and defaultProps outside the class
StackGrid.propTypes = StackGridPropTypes;
StackGrid.defaultProps = StackGridDefaultProps;
export default StackGrid;
export { GridInline };