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';
/**
* FillSpace — fills remaining viewport height; optionally paginates `items`.
*
* @component FillSpace
* @import dashgui/FillSpace
* @defaultExport
*
* @prop {React.Ref} [aboveRef] - Element(s) above; re-measure when they resize.
* @prop {React.Ref|React.Ref[]} [belowRef] - Footer element(s) below; their height is reserved.
* @prop {number} [reserveBottom=0] - Extra px to reserve below the spacer.
* @prop {ReactNode} [measureElement] - Legacy mode: hidden sample row, repeated to fill leftover space.
* @prop {number} [measureGap=0] - Gap (px) between repeated units.
* @prop {(count: number) => void} [onMeasurement] - Legacy mode: called with fitting unit count.
* @prop {number} [minHeight=0] - Minimum height in px.
* @prop {number} [minCount=0] - Minimum units that must fit (page scrolls instead of clipping).
* @prop {Array} [items] - Paginated mode: page size is measured from row height, wheel flips pages.
* @prop {React.ComponentType|Function} [renderItem] - Row renderer (component or callback).
* @prop {number} [minPageSize=10] - Minimum rows per page.
* @prop {(page: number) => void} [onPageChange] - Called on page change.
* @prop {number} [maxHeight] - Maximum height in px.
* @prop {boolean} [debug=false] - Dashed outline for layout debugging.
* @prop {ReactNode} [children] - Legacy mode content rendered on top.
* @prop {object} [sx] - Extra styles.
*/
/** 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);
}
/** True for class components (e.g. `renderItem={ArtikelMarke}`). Rendered as element, not called. */
function isRowComponent(renderItem) {
return (
typeof renderItem === 'function' &&
(renderItem.prototype?.isReactComponent || renderItem.isFillRowComponent === true)
);
}
/** Renders one row: component types become `
`, callbacks are called. */
function renderRowElement(renderItem, item, index) {
if (isRowComponent(renderItem)) {
const Row = renderItem;
return
;
}
return renderItem(item, index);
}
/**
* Data-free probe of the row height, rendered hidden. Class components
* (e.g. `renderItem={ArtikelMarke}`) tolerate empty props
* (`brand`/`item` undefined) and keep a fixed height (40px thumb /
* text field), so the probe measures the row without depending on the
* first data element. Plain callbacks are NOT probed — calling arbitrary
* user code with `undefined` could throw — they fall back to the first
* rendered data row.
*/
function renderProbeElement(renderItem) {
if (!isRowComponent(renderItem)) return null;
const Row = renderItem;
return
;
}
class FillSpace extends Component {
constructor(props) {
super(props);
this.state = {
// 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.probeRef = null;
this.paginationRef = null;
this.wheelAcc = 0;
this.observer = null;
this.raf = null;
}
componentDidMount() {
// 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);
}
componentDidUpdate(prevProps) {
if (
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 ||
prevProps.items !== this.props.items
) {
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() {
window.removeEventListener('resize', this._onViewportChange);
if (this.raf) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
_onViewportChange = () => {
if (this.raf) return;
this.raf = requestAnimationFrame(() => {
this.raf = null;
this._measure();
});
};
_observe() {
if (this.observer) {
this.observer.disconnect();
}
this.observer = new ResizeObserver(() => this._onViewportChange());
// Never observe the spacer's own parent: resizing the spacer would resize the parent.
// Paginated mode also skips contentRef: every flip swaps all rows, which
// would fire the observer -> _measure -> setState -> render in a loop.
// Unit height is read lazily in _measure, so no observer is needed for it.
const { aboveRef, belowRef, containerRef, items, renderItem } = this.props;
const paginated = Array.isArray(items) && typeof renderItem === 'function';
const aboveList = Array.isArray(aboveRef) ? aboveRef : [aboveRef];
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
const containerTarget = containerRef && 'current' in containerRef ? containerRef.current : containerRef;
const targets = [
...aboveList.map((r) => (r && 'current' in r ? r.current : r)),
...belowList.map((r) => (r && 'current' in r ? r.current : r)),
containerTarget,
...(paginated ? [] : [this.contentRef]),
this.paginationRef,
];
for (const el of targets) {
if (el && el.offsetParent !== null) {
this.observer.observe(el);
}
}
}
measure = () => {
this._measure();
};
_measure() {
if (!this.ref) return;
const {
belowRef,
containerRef,
reserveBottom = 0,
measureElement,
renderItem,
measureGap = 0,
minHeight = 0,
minCount = 0,
minPageSize,
maxHeight,
onMeasurement,
items,
} = this.props;
// Spacer top, document-relative: scrolling the page does not change the height.
const rect = this.ref.getBoundingClientRect();
const abovePx = rect.top + window.scrollY;
// Footer below (box + top margin) plus extra trailing space.
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
let belowPx = 0;
for (const ref of belowList) {
const belowEl = ref?.current;
if (belowEl && belowEl.offsetParent !== null) {
belowPx += belowEl.offsetHeight;
const marginTop = parseFloat(window.getComputedStyle(belowEl).marginTop);
belowPx += Number.isFinite(marginTop) ? marginTop : 0;
}
}
// Extra trailing space (app-shell padding, tile padding) that must stay visible.
belowPx += Number(reserveBottom) || 0;
const containerEl = containerRef && 'current' in containerRef ? containerRef.current : containerRef;
let available;
if (containerEl && typeof containerEl.getBoundingClientRect === 'function') {
const cRect = containerEl.getBoundingClientRect();
const paddingBottom = parseFloat(window.getComputedStyle(containerEl).paddingBottom) || 0;
available = Math.max(0, cRect.bottom - rect.top - belowPx - paddingBottom);
} else {
available = Math.max(0, window.innerHeight - abovePx - belowPx);
}
const clamped = Math.min(
Math.max(available, minHeight),
maxHeight ?? Infinity,
);
let contentHeight = 0;
if (this.contentRef) {
contentHeight = this.contentRef.offsetHeight;
}
// Paginated: hidden probe rendered from `renderItem` itself (no data).
// Legacy: hidden copy.
const paginated = Array.isArray(items) && typeof renderItem === 'function';
let unitHeight = this.state.unitHeight;
if (paginated) {
const probeH = this.probeRef?.offsetHeight;
if (probeH > 0) {
unitHeight = probeH;
} else {
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;
}
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,
);
// Fitting unit count (drives page size) and leftover fill count.
let totalCount = 0;
let fillCount = 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));
}
// Report total fitting count once per change (legacy mode + paginated
// debug). Paginated callers use this to display the live measurement
// in a side tile without polling internal state.
if (
typeof onMeasurement === 'function' &&
(measureElement != null || paginated) &&
totalCount !== this.lastTotalCount
) {
this.lastTotalCount = totalCount;
onMeasurement(totalCount, {
height: finalHeight,
unitHeight,
pageSize: paginated && totalCount > 0 ? Math.max(minSize, totalCount) : this.state.pageSize,
measured: paginated ? totalCount > 0 : this.state.measured,
});
}
// Paginated: fit count drives 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(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 ||
nextPageSize !== this.state.pageSize ||
measured !== this.state.measured
) {
this.setState({
height: finalHeight,
unitHeight,
contentHeight,
totalCount,
fillCount,
pageSize: nextPageSize,
measured,
});
}
}
_handleWheel = (event) => {
const { items } = this.props;
if (!Array.isArray(items)) return;
const { pageSize, currentPage } = this.state;
const pageCount = Math.max(1, Math.ceil(items.length / Math.max(1, pageSize)));
const delta = event.deltaY || 0;
if (pageCount <= 1) return;
if (delta === 0) return;
if (Math.sign(delta) !== Math.sign(this.wheelAcc || 0)) this.wheelAcc = 0;
this.wheelAcc = (this.wheelAcc || 0) + delta;
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;
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,
renderItem,
measureElement,
measureGap = 0,
sx,
...rest
} = this.props;
// 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` (component
// type or callback).
if (Array.isArray(items) && typeof renderItem === 'function') {
return this.renderPaginated({
minHeight,
maxHeight,
debug,
measureGap,
sx,
rest,
});
}
const { height, fillCount } = this.state;
let fill = null;
if (measureElement != null) {
const fillItems = [];
for (let i = 0; i < fillCount; i++) {
fillItems.push(
{measureElement}
,
);
}
fill = (
{fillItems}
);
}
return (
<>
{
this.ref = el;
}}
onWheel={onWheel}
{...rest}
sx={[
{
position: 'relative',
height: height ?? 'auto',
minHeight,
...(maxHeight != null ? { maxHeight } : {}),
},
debug
? (theme) => ({
outline: `2px dashed ${theme.palette.primary.main}`,
})
: undefined,
sx,
]}
>
{children != null ? (
{
this.contentRef = el;
}}
sx={{ width: '100%', ...(measureElement == null ? { height: '100%' } : {}) }}
>
{children}
) : null}
{fill}
{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}
>
);
}
/** Pagination header + one page of rows. Wheel over the list flips pages. */
renderPaginated({ minHeight, maxHeight, debug, measureGap, sx, rest }) {
const { items, renderItem, measureElement, 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;
// Hidden data-free probe: measures `renderItem` itself (no data), so
// the first pass already uses the final row height — one pass, no flip.
const probe = renderProbeElement(renderItem) ?? measureElement;
return (
<>
{
this.paginationRef = el;
}}
// Fixed single-line bar: the header height must not depend on the
// page count. Otherwise the first fit (e.g. "1-17") pushes the
// spacer down, the available height shrinks, and a second pass
// flips to "1-15". One pass: constant 44px, real values always
// rendered (only visibility is gated), no placeholder numbers.
sx={{ mb: 1, height: 44, overflow: 'hidden' }}
>
{total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '}
{total}
this._goToPage(page)}
color="primary"
size="large"
onMouseDown={(e) => e.preventDefault()}
sx={{
flexShrink: 0,
maxWidth: '100%',
overflowX: 'auto',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
'& .MuiPagination-ul': { flexWrap: 'nowrap' },
'& .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%',
// Keep wheel ticks from scrolling the page behind the list.
// (No preventDefault needed — React wheel is passive.)
overscrollBehavior: 'contain',
},
debug
? (theme) => ({
outline: `2px dashed ${theme.palette.primary.main}`,
})
: undefined,
sx,
]}
>
{
this.contentRef = el;
}}
sx={{ width: '100%' }}
>
{pageItems.map((item, i) => (
{renderRowElement(renderItem, item, offset + i)}
))}
{probe != null ? (
{
this.probeRef = el;
}}
aria-hidden
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
}}
>
{probe}
) : null}
{debug ? (
({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
background: alpha(theme.palette.primary.main, 0.08),
zIndex: 9999,
})}
/>
) : null}
>
);
}
}
export default FillSpace;