diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx
index 48b36cb..526d6b6 100644
--- a/src/FillSpace.jsx
+++ b/src/FillSpace.jsx
@@ -1,5 +1,7 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
+import Pagination from '@mui/material/Pagination';
+import Typography from '@mui/material/Typography';
import { alpha } from '@mui/material/styles';
/**
@@ -18,6 +20,11 @@ import { alpha } from '@mui/material/styles';
* @prop {number} [measureGap=0] - Vertical gap (px) between repeated `measureElement` units.
* @prop {(count: number) => void} [onMeasurement] - Called whenever the number of `measureElement` units that would fit in the whole spacer height changes, as if there were no `children` (i.e. the total row count the space could hold). Fires once after mount with the initial count.
* @prop {number} [minHeight=0] - Minimum spacer height in px.
+ * @prop {number} [minCount=0] - Minimum number of `measureElement` units the spacer must fit. When the viewport is too small, the spacer grows beyond the viewport so the page scrolls instead of clipping.
+ * @prop {Array} [items] - When provided, FillSpace paginates: it measures the page size, slices `items`, renders the current page via `renderPage`, and owns wheel-pagination plus the pagination header. Omit for legacy children mode.
+ * @prop {(pageItems: Array, page: {offset:number, pageSize:number, currentPage:number, pageCount:number, total:number}) => ReactNode} [renderPage] - Renders the current page slice (paginated mode).
+ * @prop {number} [minPageSize=10] - Minimum rows per page in paginated mode.
+ * @prop {(page: number) => void} [onPageChange] - Called when the current page changes (wheel or header).
* @prop {number} [maxHeight] - Optional maximum spacer height in px.
* @prop {boolean} [debug=false] - When true, draws a dashed outline around the spacer and a translucent overlay over the whole viewport so the measured bounds are visible.
* @prop {ReactNode} [children] - Real content rendered at the top of the spacer. The repeated `measureElement` units (if any) fill the remaining space below it.
@@ -30,29 +37,46 @@ import { alpha } from '@mui/material/styles';
* @behaviour Re-measures on window resize and whenever the observed refs or the content change size (ResizeObserver), throttled with requestAnimationFrame.
* @behaviour `debug` draws a dashed primary outline around the spacer plus a translucent full-viewport overlay.
*/
+
+/** Minimum spacer height so at least `count` units fit (lets the page scroll on small windows). */
+function minHeightFromCount(count, unitHeight, gap) {
+ const n = Number(count) || 0;
+ if (n <= 0 || !(unitHeight > 0)) return 0;
+ return n * unitHeight + Math.max(0, n - 1) * (Number(gap) || 0);
+}
+
class FillSpace extends Component {
constructor(props) {
super(props);
this.state = {
- height: 0,
+ // null = not measured yet: render at natural content height so the
+ // first visible frame is already full-size, no 0 → expanded jump.
+ height: null,
unitHeight: 0,
contentHeight: 0,
totalCount: 0,
fillCount: 0,
+ // Paginated mode (`items`): page size comes from the fit count.
+ pageSize: Number(props.minPageSize ?? props.minCount) || 10,
+ currentPage: 1,
+ measured: false,
};
this.lastTotalCount = null;
this.ref = null;
this.contentRef = null;
this.unitRef = null;
+ this.paginationRef = null;
+ this.wheelAcc = 0;
this.observer = null;
this.raf = null;
}
componentDidMount() {
- requestAnimationFrame(() => {
- this._measure();
- this._observe();
- });
+ // Measure synchronously (no rAF): setState here is batched before the
+ // browser paints, so the first visible frame already has the full
+ // viewport height — no small-box → expanded-box layout shift.
+ this._measure();
+ this._observe();
window.addEventListener('resize', this._onViewportChange);
}
@@ -61,7 +85,10 @@ class FillSpace extends Component {
prevProps.aboveRef !== this.props.aboveRef ||
prevProps.belowRef !== this.props.belowRef ||
prevProps.containerRef !== this.props.containerRef ||
+ prevProps.reserveBottom !== this.props.reserveBottom ||
prevProps.minHeight !== this.props.minHeight ||
+ prevProps.minCount !== this.props.minCount ||
+ prevProps.minPageSize !== this.props.minPageSize ||
prevProps.maxHeight !== this.props.maxHeight ||
prevProps.measureElement !== this.props.measureElement ||
prevProps.measureGap !== this.props.measureGap
@@ -69,6 +96,20 @@ class FillSpace extends Component {
this._observe();
this._measure();
}
+ // Clamp current page when the item list shrinks (e.g. brand deleted).
+ if (
+ Array.isArray(this.props.items) &&
+ this.props.items !== prevProps.items
+ ) {
+ const pageCount = Math.max(
+ 1,
+ Math.ceil(this.props.items.length / Math.max(1, this.state.pageSize)),
+ );
+ if (this.state.currentPage > pageCount) {
+ this.setState({ currentPage: pageCount });
+ this.props.onPageChange?.(pageCount);
+ }
+ }
}
componentWillUnmount() {
@@ -109,6 +150,7 @@ class FillSpace extends Component {
...belowList.map((r) => (r && 'current' in r ? r.current : r)),
containerTarget,
this.contentRef,
+ this.paginationRef,
];
for (const el of targets) {
if (el && el.offsetParent !== null) {
@@ -131,8 +173,11 @@ class FillSpace extends Component {
measureElement,
measureGap = 0,
minHeight = 0,
+ minCount = 0,
+ minPageSize,
maxHeight,
onMeasurement,
+ items,
} = this.props;
// Distance from the document top to the spacer top — includes every
@@ -186,6 +231,18 @@ class FillSpace extends Component {
if (h > 0) unitHeight = h;
}
+ // Minimum rows: grow past the viewport so the page scrolls instead
+ // of clipping when the window is too small. Paginated mode uses
+ // minPageSize (fallback minCount).
+ const paginated = Array.isArray(items);
+ const minSize = Math.max(1, Number(minPageSize ?? minCount) || 10);
+ const effectiveMinCount = paginated ? Math.max(Number(minCount) || 0, minSize) : Number(minCount) || 0;
+ const minHeightForCount = minHeightFromCount(effectiveMinCount, unitHeight, measureGap);
+ const finalHeight = Math.min(
+ Math.max(clamped, minHeightForCount),
+ maxHeight ?? Infinity,
+ );
+
// How many units would fit in the WHOLE spacer height, as if there were
// no children — this is what onMeasurement reports.
let totalCount = 0;
@@ -194,8 +251,8 @@ class FillSpace extends Component {
// the space, none fit.
let fillCount = 0;
if (measureElement != null && unitHeight > 0) {
- totalCount = Math.floor((clamped + measureGap) / (unitHeight + measureGap));
- const fillHeight = Math.max(0, clamped - contentHeight);
+ totalCount = Math.floor((finalHeight + measureGap) / (unitHeight + measureGap));
+ const fillHeight = Math.max(0, finalHeight - contentHeight);
fillCount = Math.floor((fillHeight + measureGap) / (unitHeight + measureGap));
}
@@ -211,42 +268,121 @@ class FillSpace extends Component {
onMeasurement(totalCount);
}
+ // Paginated mode: fit count drives the page size (min minPageSize).
+ let nextPageSize = this.state.pageSize;
+ let measured = this.state.measured;
+ if (paginated && totalCount > 0) {
+ nextPageSize = Math.max(minSize, totalCount);
+ measured = true;
+ }
+
if (
- Math.abs(clamped - this.state.height) > 0.5 ||
+ Math.abs(finalHeight - this.state.height) > 0.5 ||
Math.abs(unitHeight - this.state.unitHeight) > 0.5 ||
Math.abs(contentHeight - this.state.contentHeight) > 0.5 ||
totalCount !== this.state.totalCount ||
- fillCount !== this.state.fillCount
+ fillCount !== this.state.fillCount ||
+ nextPageSize !== this.state.pageSize ||
+ measured !== this.state.measured
) {
- this.setState({ height: clamped, unitHeight, contentHeight, totalCount, fillCount });
+ this.setState({
+ height: finalHeight,
+ unitHeight,
+ contentHeight,
+ totalCount,
+ fillCount,
+ pageSize: nextPageSize,
+ measured,
+ });
}
}
+ _handleWheel = (event) => {
+ const { items, onWheel } = this.props;
+ if (!Array.isArray(items)) {
+ onWheel?.(event);
+ return;
+ }
+ const { pageSize, currentPage } = this.state;
+ const pageCount = Math.max(1, Math.ceil(items.length / Math.max(1, pageSize)));
+ if (pageCount <= 1) return;
+ const delta = event.deltaY || 0;
+ if (delta === 0) return;
+ if (Math.sign(delta) !== Math.sign(this.wheelAcc || 0)) this.wheelAcc = 0;
+ this.wheelAcc = (this.wheelAcc || 0) + delta;
+ // Few pages → long scroll per page (slow); many pages → fast flipping.
+ const threshold = Math.max(50, 800 / pageCount);
+ if (Math.abs(this.wheelAcc) < threshold) return;
+ const dir = this.wheelAcc > 0 ? 1 : -1;
+ this.wheelAcc = 0;
+ const next = dir > 0
+ ? Math.min(pageCount, currentPage + 1)
+ : Math.max(1, currentPage - 1);
+ if (next === currentPage) return;
+ event.preventDefault();
+ this._goToPage(next);
+ };
+
+ _goToPage = (page) => {
+ const { items, onPageChange } = this.props;
+ const total = Array.isArray(items) ? items.length : 0;
+ const pageCount = Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize)));
+ const next = Math.min(Math.max(1, Number(page) || 1), pageCount);
+ if (next === this.state.currentPage) return;
+ this.setState({ currentPage: next });
+ onPageChange?.(next);
+ };
+
render() {
const {
aboveRef,
belowRef,
+ containerRef,
+ reserveBottom,
onMeasurement,
+ onWheel,
+ onPageChange,
minHeight = 0,
+ minCount,
+ minPageSize,
maxHeight,
debug = false,
children,
+ items,
+ renderPage,
measureElement,
measureGap = 0,
sx,
...rest
} = this.props;
+ // Paginated mode: FillSpace owns page size (from fit), current page,
+ // wheel-pagination and the pagination header. Caller just feeds `items`
+ // and renders the slice via `renderPage`.
+ if (Array.isArray(items) && typeof renderPage === 'function') {
+ return this.renderPaginated({
+ minHeight,
+ maxHeight,
+ debug,
+ measureElement,
+ measureGap,
+ sx,
+ rest,
+ });
+ }
+
const { height, fillCount } = this.state;
+ // Before the first measurement, let content size naturally (auto)
+ // instead of forcing height 0 — avoids the small-box flash.
// Repeated measurement units fill only the leftover space below the real
// children. fillCount (computed in _measure) starts at 0: if the content
// already fills the space, no units are rendered.
let fill = null;
if (measureElement != null) {
- const items = [];
+ const fillItems = [];
for (let i = 0; i < fillCount; i++) {
- items.push(
+ fillItems.push(
{measureElement}
,
@@ -262,7 +398,7 @@ class FillSpace extends Component {
overflow: 'hidden',
}}
>
- {items}
+ {fillItems}
);
}
@@ -273,11 +409,12 @@ class FillSpace extends Component {
ref={(el) => {
this.ref = el;
}}
+ onWheel={onWheel}
{...rest}
sx={[
{
position: 'relative',
- height,
+ height: height ?? 'auto',
minHeight,
...(maxHeight != null ? { maxHeight } : {}),
},
@@ -333,6 +470,133 @@ class FillSpace extends Component {
>
);
}
+
+ /**
+ * Paginated mode: pagination header (hidden until measured) + exactly one
+ * page of `items` rendered via `renderPage`. Wheel over the list flips
+ * pages; effort scales with page count (slow for few pages).
+ */
+ renderPaginated({ minHeight, maxHeight, debug, measureElement, measureGap, sx, rest }) {
+ const { items, renderPage, onWheel } = this.props;
+ const { height, pageSize, currentPage, measured } = this.state;
+ const total = items.length;
+ const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
+ const safePage = Math.min(Math.max(1, currentPage), pageCount);
+ const offset = (safePage - 1) * pageSize;
+ const pageItems = items.slice(offset, offset + pageSize);
+ // Hidden until the fit is measured — no "1–10" flash on load.
+ const showPagination = measured && total > pageSize;
+
+ return (
+ <>
+ {
+ this.paginationRef = el;
+ }}
+ sx={{ mb: 1 }}
+ >
+
+
+ {showPagination ? offset + 1 : 1}–
+ {showPagination ? Math.min(offset + pageSize, total) : pageSize + 1} von{' '}
+ {showPagination ? total : pageSize + 1}
+
+ this._goToPage(page)}
+ color="primary"
+ size="large"
+ onMouseDown={(e) => e.preventDefault()}
+ sx={{
+ '& .MuiPaginationItem-root': {
+ minWidth: 44,
+ height: 44,
+ fontSize: '1rem',
+ },
+ }}
+ />
+
+
+ {
+ this.ref = el;
+ }}
+ onWheel={(e) => {
+ this._handleWheel(e);
+ onWheel?.(e);
+ }}
+ {...rest}
+ sx={[
+ {
+ position: 'relative',
+ height: height ?? 'auto',
+ minHeight,
+ ...(maxHeight != null ? { maxHeight } : {}),
+ overflow: 'hidden',
+ width: '100%',
+ },
+ debug
+ ? (theme) => ({
+ outline: `2px dashed ${theme.palette.primary.main}`,
+ })
+ : undefined,
+ sx,
+ ]}
+ >
+ {
+ this.contentRef = el;
+ }}
+ sx={{ width: '100%' }}
+ >
+ {renderPage(pageItems, { offset, pageSize, currentPage: safePage, pageCount, total })}
+
+ {measureElement != null ? (
+ {
+ this.unitRef = el;
+ }}
+ aria-hidden
+ sx={{
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ width: '100%',
+ visibility: 'hidden',
+ pointerEvents: 'none',
+ }}
+ >
+ {measureElement}
+
+ ) : null}
+
+ {debug ? (
+ ({
+ position: 'fixed',
+ inset: 0,
+ pointerEvents: 'none',
+ background: alpha(theme.palette.primary.main, 0.08),
+ zIndex: 9999,
+ })}
+ />
+ ) : null}
+ >
+ );
+ }
}
export default FillSpace;