This commit is contained in:
seb
2026-10-09 20:55:28 +02:00
parent 14770f27ce
commit eda3b193c6

View File

@@ -1,5 +1,6 @@
import { Component } from 'react'; import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import List from '@mui/material/List';
import Pagination from '@mui/material/Pagination'; import Pagination from '@mui/material/Pagination';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { alpha } from '@mui/material/styles'; import { alpha } from '@mui/material/styles';
@@ -21,8 +22,8 @@ import { alpha } from '@mui/material/styles';
* @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 {(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} [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 {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 {Array} [items] - When provided with `renderItem`, FillSpace paginates: it measures the page size from the rendered row height, slices `items`, renders the current page as a list, 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 {(item: any, index: number) => ReactNode} [renderItem] - Renders one row element (e.g. a brand row). The first rendered row doubles as the measure element, so no separate `measureElement` is needed in paginated mode.
* @prop {number} [minPageSize=10] - Minimum rows per page in 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 {(page: number) => void} [onPageChange] - Called when the current page changes (wheel or header).
* @prop {number} [maxHeight] - Optional maximum spacer height in px. * @prop {number} [maxHeight] - Optional maximum spacer height in px.
@@ -91,7 +92,8 @@ class FillSpace extends Component {
prevProps.minPageSize !== this.props.minPageSize || prevProps.minPageSize !== this.props.minPageSize ||
prevProps.maxHeight !== this.props.maxHeight || prevProps.maxHeight !== this.props.maxHeight ||
prevProps.measureElement !== this.props.measureElement || prevProps.measureElement !== this.props.measureElement ||
prevProps.measureGap !== this.props.measureGap prevProps.measureGap !== this.props.measureGap ||
prevProps.items !== this.props.items
) { ) {
this._observe(); this._observe();
this._measure(); this._measure();
@@ -171,6 +173,7 @@ class FillSpace extends Component {
containerRef, containerRef,
reserveBottom = 0, reserveBottom = 0,
measureElement, measureElement,
renderItem,
measureGap = 0, measureGap = 0,
minHeight = 0, minHeight = 0,
minCount = 0, minCount = 0,
@@ -223,18 +226,23 @@ class FillSpace extends Component {
contentHeight = this.contentRef.offsetHeight; contentHeight = this.contentRef.offsetHeight;
} }
// Measure the height of one measurement unit from the hidden copy so the // Unit height: in paginated mode the first rendered row is the measure
// repeats match the real rendered size. // element — no separate hidden sample needed. Legacy children mode
// still uses the hidden `measureElement` copy.
const paginated = Array.isArray(items) && typeof renderItem === 'function';
let unitHeight = this.state.unitHeight; let unitHeight = this.state.unitHeight;
if (measureElement != null && this.unitRef) { if (paginated) {
const firstRow = this.contentRef?.querySelector?.('[data-fill-row]') ?? this.contentRef?.firstElementChild;
const h = firstRow?.offsetHeight;
if (h > 0) unitHeight = h;
if (!(unitHeight > 0) && measureElement != null && this.unitRef) {
const fallback = this.unitRef.offsetHeight;
if (fallback > 0) unitHeight = fallback;
}
} else if (measureElement != null && this.unitRef) {
const h = this.unitRef.offsetHeight; const h = this.unitRef.offsetHeight;
if (h > 0) unitHeight = h; 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 minSize = Math.max(1, Number(minPageSize ?? minCount) || 10);
const effectiveMinCount = paginated ? Math.max(Number(minCount) || 0, minSize) : Number(minCount) || 0; const effectiveMinCount = paginated ? Math.max(Number(minCount) || 0, minSize) : Number(minCount) || 0;
const minHeightForCount = minHeightFromCount(effectiveMinCount, unitHeight, measureGap); const minHeightForCount = minHeightFromCount(effectiveMinCount, unitHeight, measureGap);
@@ -243,14 +251,14 @@ class FillSpace extends Component {
maxHeight ?? Infinity, maxHeight ?? Infinity,
); );
// How many units would fit in the WHOLE spacer height, as if there were // How many units fit in the spacer height — drives the page size in
// no children — this is what onMeasurement reports. // paginated mode and the `onMeasurement` callback in legacy mode.
let totalCount = 0; let totalCount = 0;
// How many units actually fit in the leftover space below the children — // How many units actually fit in the leftover space below the children —
// this is what gets rendered. Starts at 0: if the content already fills // this is what gets rendered. Starts at 0: if the content already fills
// the space, none fit. // the space, none fit.
let fillCount = 0; let fillCount = 0;
if (measureElement != null && unitHeight > 0) { if (unitHeight > 0 && (measureElement != null || paginated)) {
totalCount = Math.floor((finalHeight + measureGap) / (unitHeight + measureGap)); totalCount = Math.floor((finalHeight + measureGap) / (unitHeight + measureGap));
const fillHeight = Math.max(0, finalHeight - contentHeight); const fillHeight = Math.max(0, finalHeight - contentHeight);
fillCount = Math.floor((fillHeight + measureGap) / (unitHeight + measureGap)); fillCount = Math.floor((fillHeight + measureGap) / (unitHeight + measureGap));
@@ -269,6 +277,7 @@ class FillSpace extends Component {
} }
// Paginated mode: fit count drives the page size (min minPageSize). // Paginated mode: fit count drives the page size (min minPageSize).
// Legacy mode only forwards the count via onMeasurement.
let nextPageSize = this.state.pageSize; let nextPageSize = this.state.pageSize;
let measured = this.state.measured; let measured = this.state.measured;
if (paginated && totalCount > 0) { if (paginated && totalCount > 0) {
@@ -349,22 +358,21 @@ class FillSpace extends Component {
debug = false, debug = false,
children, children,
items, items,
renderPage, renderItem,
measureElement, measureElement,
measureGap = 0, measureGap = 0,
sx, sx,
...rest ...rest
} = this.props; } = this.props;
// Paginated mode: FillSpace owns page size (from fit), current page, // Paginated mode: FillSpace owns page size (from the rendered row
// wheel-pagination and the pagination header. Caller just feeds `items` // height), current page, wheel-pagination and the pagination header.
// and renders the slice via `renderPage`. // Caller feeds `items` plus one row element via `renderItem`.
if (Array.isArray(items) && typeof renderPage === 'function') { if (Array.isArray(items) && typeof renderItem === 'function') {
return this.renderPaginated({ return this.renderPaginated({
minHeight, minHeight,
maxHeight, maxHeight,
debug, debug,
measureElement,
measureGap, measureGap,
sx, sx,
rest, rest,
@@ -473,11 +481,12 @@ class FillSpace extends Component {
/** /**
* Paginated mode: pagination header (hidden until measured) + exactly one * Paginated mode: pagination header (hidden until measured) + exactly one
* page of `items` rendered via `renderPage`. Wheel over the list flips * page of `items`, each row rendered via `renderItem`. The first row
* pages; effort scales with page count (slow for few pages). * doubles as the measure element. Wheel over the list flips pages;
* effort scales with page count (slow for few pages).
*/ */
renderPaginated({ minHeight, maxHeight, debug, measureElement, measureGap, sx, rest }) { renderPaginated({ minHeight, maxHeight, debug, measureGap, sx, rest }) {
const { items, renderPage, onWheel } = this.props; const { items, renderItem, onWheel } = this.props;
const { height, pageSize, currentPage, measured } = this.state; const { height, pageSize, currentPage, measured } = this.state;
const total = items.length; const total = items.length;
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
@@ -562,26 +571,14 @@ class FillSpace extends Component {
}} }}
sx={{ width: '100%' }} sx={{ width: '100%' }}
> >
{renderPage(pageItems, { offset, pageSize, currentPage: safePage, pageCount, total })} <List dense disablePadding sx={{ width: '100%' }}>
{pageItems.map((item, i) => (
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}>
{renderItem(item, offset + i)}
</Box>
))}
</List>
</Box> </Box>
{measureElement != null ? (
<Box
ref={(el) => {
this.unitRef = el;
}}
aria-hidden
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
}}
>
{measureElement}
</Box>
) : null}
</Box> </Box>
{debug ? ( {debug ? (
<Box <Box