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 Box from '@mui/material/Box';
import List from '@mui/material/List';
import Pagination from '@mui/material/Pagination';
import Typography from '@mui/material/Typography';
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 {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 {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 {(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 {(page: number) => void} [onPageChange] - Called when the current page changes (wheel or header).
* @prop {number} [maxHeight] - Optional maximum spacer height in px.
@@ -91,7 +92,8 @@ class FillSpace extends Component {
prevProps.minPageSize !== this.props.minPageSize ||
prevProps.maxHeight !== this.props.maxHeight ||
prevProps.measureElement !== this.props.measureElement ||
prevProps.measureGap !== this.props.measureGap
prevProps.measureGap !== this.props.measureGap ||
prevProps.items !== this.props.items
) {
this._observe();
this._measure();
@@ -171,6 +173,7 @@ class FillSpace extends Component {
containerRef,
reserveBottom = 0,
measureElement,
renderItem,
measureGap = 0,
minHeight = 0,
minCount = 0,
@@ -223,18 +226,23 @@ class FillSpace extends Component {
contentHeight = this.contentRef.offsetHeight;
}
// Measure the height of one measurement unit from the hidden copy so the
// repeats match the real rendered size.
// Unit height: in paginated mode the first rendered row is the measure
// 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;
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;
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);
@@ -243,14 +251,14 @@ class FillSpace extends Component {
maxHeight ?? Infinity,
);
// How many units would fit in the WHOLE spacer height, as if there were
// no children — this is what onMeasurement reports.
// How many units fit in the spacer height — drives the page size in
// paginated mode and the `onMeasurement` callback in legacy mode.
let totalCount = 0;
// 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
// the space, none fit.
let fillCount = 0;
if (measureElement != null && unitHeight > 0) {
if (unitHeight > 0 && (measureElement != null || paginated)) {
totalCount = Math.floor((finalHeight + measureGap) / (unitHeight + measureGap));
const fillHeight = Math.max(0, finalHeight - contentHeight);
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).
// Legacy mode only forwards the count via onMeasurement.
let nextPageSize = this.state.pageSize;
let measured = this.state.measured;
if (paginated && totalCount > 0) {
@@ -349,22 +358,21 @@ class FillSpace extends Component {
debug = false,
children,
items,
renderPage,
renderItem,
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') {
// Paginated mode: FillSpace owns page size (from the rendered row
// height), current page, wheel-pagination and the pagination header.
// Caller feeds `items` plus one row element via `renderItem`.
if (Array.isArray(items) && typeof renderItem === 'function') {
return this.renderPaginated({
minHeight,
maxHeight,
debug,
measureElement,
measureGap,
sx,
rest,
@@ -473,11 +481,12 @@ 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).
* page of `items`, each row rendered via `renderItem`. The first row
* 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 }) {
const { items, renderPage, onWheel } = this.props;
renderPaginated({ minHeight, maxHeight, debug, measureGap, sx, rest }) {
const { items, renderItem, 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)));
@@ -562,26 +571,14 @@ class FillSpace extends Component {
}}
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>
{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>
{debug ? (
<Box