From 14770f27ce7a37028727c189666c2b651ce7c395 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 9 Oct 2026 20:17:15 +0200 Subject: [PATCH] u --- src/FillSpace.jsx | 292 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 278 insertions(+), 14 deletions(-) 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;