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 — paginates `items`.
*
* @component FillSpace
* @import dashgui/FillSpace
* @defaultExport
*
* @prop {Array} items - Wheel flips pages.
* @prop {React.ComponentType|Function} renderItem - Row renderer (component or callback).
* @prop {React.ReactNode} [header] - Rendered below pagination controls and above the list items.
* @prop {number} [minPageSize=10] - Rows per page.
* @prop {number} [maxPageSize] - Maximum rows per page.
* @prop {number} [totalCount] - Total items in unfiltered dataset. When unfiltered count <= pageSize, pagination header is removed entirely. When filtering, the header height is preserved to prevent layout shift.
* @prop {number} [unfilteredCount] - Alias for totalCount.
* @prop {(page: number) => void} [onPageChange] - Called on page change.
* @prop {boolean} [virtualScrollbar=false] - Shows a synchronized scrollbar alongside pagination controls.
* @prop {boolean} [debug=false] - Dashed outline for layout debugging.
* @prop {object} [sx] - Extra styles.
*/
/** 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
;
}
const MIN_THUMB_HEIGHT = 48;
const RACEWAY_WIDTH = 16;
const RACEWAY_BOTTOM_MARGIN = 8;
class FillSpace extends Component {
constructor(props) {
super(props);
const minSize = Math.max(1, Number(props.minPageSize) || 10);
const maxSize = Number(props.maxPageSize) > 0 ? Number(props.maxPageSize) : Infinity;
this.state = {
unitHeight: 0,
viewportHeight: null,
pageSize: Math.min(maxSize, minSize),
currentPage: 1,
isDragging: false,
dragThumbTop: 0,
};
this.ref = null;
this.trackRef = null;
this.thumbRef = null;
this.probeRef = null;
this.wheelAcc = 0;
this.observer = null;
this.raf = null;
this.dragStartY = 0;
this.dragStartThumbTop = 0;
const explicitCount = props.unfilteredCount ?? props.totalCount;
this.maxSeenCount =
explicitCount != null
? Number(explicitCount)
: Array.isArray(props.items)
? props.items.length
: 0;
}
componentDidMount() {
this._probeMeasure();
this._viewportMeasure();
this._observe();
window.addEventListener('resize', this._onViewportChange);
}
componentDidUpdate(prevProps) {
if (prevProps.renderItem !== this.props.renderItem) {
this._probeMeasure();
}
if (
prevProps.minPageSize !== this.props.minPageSize ||
prevProps.maxPageSize !== this.props.maxPageSize
) {
const minSize = Math.max(1, Number(this.props.minPageSize) || 10);
const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity;
this.setState({ pageSize: Math.min(maxSize, minSize) });
}
if (
prevProps.aboveRef !== this.props.aboveRef ||
prevProps.belowRef !== this.props.belowRef ||
prevProps.containerRef !== this.props.containerRef ||
prevProps.reserveBottom !== this.props.reserveBottom ||
prevProps.items !== this.props.items ||
prevProps.header !== this.props.header
) {
this._observe();
this._viewportMeasure();
}
const explicitCount = this.props.unfilteredCount ?? this.props.totalCount;
if (explicitCount != null) {
this.maxSeenCount = Number(explicitCount);
} else if (Array.isArray(this.props.items)) {
this.maxSeenCount = Math.max(this.maxSeenCount || 0, this.props.items.length);
}
// Clamp current page when the item list shrinks (e.g. brand deleted).
if (
Array.isArray(this.props.items) &&
this.props.items !== prevProps.items
) {
const explicitTotal = this.props.unfilteredCount ?? this.props.totalCount;
const total =
explicitTotal != null ? Number(explicitTotal) : this.props.items.length;
const pageCount = Math.max(
1,
Math.ceil(total / Math.max(1, this.state.pageSize)),
);
if (this.state.currentPage > pageCount) {
this.setState({ currentPage: pageCount });
this.props.onPageChange?.(pageCount, {
pageCount,
pageSize: this.state.pageSize,
total,
});
}
}
}
componentWillUnmount() {
if (this.state.isDragging) {
document.body.style.userSelect = '';
document.body.style.cursor = '';
window.removeEventListener('mousemove', this._onThumbDragMove);
window.removeEventListener('mouseup', this._onThumbDragEnd);
}
window.removeEventListener('resize', this._onViewportChange);
if (this.raf) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
_onThumbMouseDown = (e) => {
e.preventDefault();
e.stopPropagation();
const { totalCount, unfilteredCount, items } = this.props;
const { viewportHeight, pageSize, currentPage } = this.state;
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
const currentThumbTop = pageCount > 1 && maxThumbTravel > 0 ? ((currentPage - 1) / (pageCount - 1)) * maxThumbTravel : 0;
this.dragStartY = e.clientY;
this.dragStartThumbTop = currentThumbTop;
this.setState({ isDragging: true, dragThumbTop: currentThumbTop });
document.body.style.userSelect = 'none';
document.body.style.cursor = 'grabbing';
window.addEventListener('mousemove', this._onThumbDragMove);
window.addEventListener('mouseup', this._onThumbDragEnd);
};
_onThumbDragMove = (e) => {
if (!this.state.isDragging) return;
const { totalCount, unfilteredCount, items } = this.props;
const { viewportHeight, pageSize } = this.state;
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
if (pageCount <= 1) return;
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
if (maxThumbTravel <= 0) return;
const deltaY = e.clientY - this.dragStartY;
const nextThumbTop = Math.max(0, Math.min(maxThumbTravel, this.dragStartThumbTop + deltaY));
const targetPage = Math.min(pageCount, Math.max(1, Math.round(1 + (nextThumbTop / maxThumbTravel) * (pageCount - 1))));
this.setState({ dragThumbTop: nextThumbTop });
if (targetPage !== this.state.currentPage) {
this._goToPage(targetPage);
}
};
_onThumbDragEnd = () => {
document.body.style.userSelect = '';
document.body.style.cursor = '';
window.removeEventListener('mousemove', this._onThumbDragMove);
window.removeEventListener('mouseup', this._onThumbDragEnd);
this.setState({ isDragging: false });
};
_handleTrackMouseDown = (e) => {
if (e.target === this.thumbRef || this.thumbRef?.contains(e.target)) return;
if (!this.trackRef) return;
const trackRect = this.trackRef.getBoundingClientRect();
const clickY = e.clientY - trackRect.top;
const { viewportHeight, pageSize } = this.state;
const { items, totalCount, unfilteredCount } = this.props;
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
if (pageCount <= 1) return;
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
if (trackHeight <= 0) return;
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
const targetThumbTop = Math.max(0, Math.min(maxThumbTravel, clickY - thumbHeight / 2));
const targetPage = maxThumbTravel > 0 ? Math.min(pageCount, Math.max(1, Math.round(1 + (targetThumbTop / maxThumbTravel) * (pageCount - 1)))) : 1;
this._goToPage(targetPage);
};
scrollToTop = () => {
this._goToPage(1);
};
scrollToIndex = (index) => {
const { pageSize } = this.state;
if (pageSize > 0) {
const targetPage = Math.floor(index / pageSize) + 1;
this._goToPage(targetPage);
}
};
_onViewportChange = () => {
if (this.raf) return;
this.raf = requestAnimationFrame(() => {
this.raf = null;
this._viewportMeasure();
});
};
_observe() {
if (this.observer) {
this.observer.disconnect();
}
this.observer = new ResizeObserver(() => this._onViewportChange());
const { aboveRef, belowRef, containerRef } = this.props;
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,
];
for (const el of targets) {
if (el && el.offsetParent !== null) {
this.observer.observe(el);
}
}
}
probeMeasure = () => {
this._probeMeasure();
};
viewportMeasure = () => {
this._viewportMeasure();
};
measure = () => {
this._probeMeasure();
this._viewportMeasure();
};
/** Only measures the height of the `renderItem` probe. Nothing else. */
_probeMeasure() {
const firstRow = this.ref?.querySelector('[data-fill-row]');
const h = this.probeRef?.offsetHeight || firstRow?.offsetHeight;
if (h > 0 && h !== this.state.unitHeight) {
this.setState({ unitHeight: h }, () => this._viewportMeasure());
}
}
/** Viewport height minus above/below refs: free space for the list. */
_viewportMeasure() {
if (!this.ref) return;
const { belowRef, containerRef, reserveBottom = 0, minPageSize } = this.props;
const rect = this.ref.getBoundingClientRect();
let belowPx = Number(reserveBottom) || 0;
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
for (const ref of belowList) {
const el = ref && 'current' in ref ? ref.current : ref;
if (el && el.offsetParent !== null) {
belowPx += el.offsetHeight;
const mt = parseFloat(window.getComputedStyle(el).marginTop);
if (Number.isFinite(mt)) belowPx += mt;
}
}
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 - rect.top - belowPx);
}
const minSize = Math.max(1, Number(minPageSize) || 10);
const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity;
const { unitHeight } = this.state;
const calculated =
unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize;
const nextPageSize = Math.min(maxSize, calculated);
const nextViewportHeight =
Number.isFinite(maxSize) && unitHeight > 0
? Math.min(available, nextPageSize * unitHeight)
: available;
if (
nextViewportHeight !== this.state.viewportHeight ||
nextPageSize !== this.state.pageSize
) {
this.setState({ viewportHeight: nextViewportHeight, pageSize: nextPageSize });
}
}
_handleWheel = (event) => {
const { items, totalCount, unfilteredCount } = this.props;
if (!Array.isArray(items)) return;
if (this.ref && event.target && !this.ref.contains(event.target)) return;
const { pageSize, currentPage } = this.state;
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : items.length;
const pageCount = Math.max(1, Math.ceil(total / 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, totalCount, unfilteredCount } = this.props;
const explicitTotal = unfilteredCount ?? totalCount;
const total =
explicitTotal != null
? Number(explicitTotal)
: 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, { pageCount, pageSize: this.state.pageSize, total });
};
render() {
const {
// Discarded legacy layout props (kept here so they don't leak to the DOM).
aboveRef,
belowRef,
containerRef,
reserveBottom,
minHeight,
maxHeight,
minPageSize,
maxPageSize,
totalCount,
unfilteredCount,
listRef,
onPageChange,
onWheel,
virtualScrollbar = false,
racewayMarginBottom,
racewayRight,
debug = false,
items,
renderItem,
header,
sx,
...rest
} = this.props;
// FillSpace owns current page, wheel-pagination and the pagination header.
// Caller feeds `items` plus one row element via `renderItem` (component
// type or callback). `items` may be undefined while data is loading.
const { pageSize, currentPage, viewportHeight } = this.state;
const list = Array.isArray(items) ? items : [];
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : list.length;
const fullTotal = Math.max(this.maxSeenCount || 0, total);
const needsPaginationContainer = fullTotal > pageSize;
const showPaginationControls = total > pageSize;
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 = list.slice(offset, offset + pageSize);
const canScroll =
virtualScrollbar &&
total > pageSize &&
(viewportHeight || 0) > 0;
const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
const racewayRightOffset = racewayRight != null ? racewayRight : 0;
let thumbHeight = 0;
let thumbTop = 0;
if (canScroll) {
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
thumbTop = this.state.isDragging
? this.state.dragThumbTop
: (pageCount > 1 && maxThumbTravel > 0 ? ((safePage - 1) / (pageCount - 1)) * maxThumbTravel : 0);
}
// Hidden data-free probe: measures `renderItem` itself (no data).
const probe = renderProbeElement(renderItem);
return (
<>
{needsPaginationContainer ? (
{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',
},
}}
/>
) : null}
{header}
{
this.ref = el;
}}
onWheel={(e) => {
this._handleWheel(e);
onWheel?.(e);
}}
{...rest}
sx={[
{
position: 'relative',
height: viewportHeight ?? 'auto',
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,
]}
>
{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}
{canScroll ? (
{
this.trackRef = el;
}}
onMouseDown={this._handleTrackMouseDown}
sx={{
position: 'absolute',
top: 0,
right: racewayRightOffset,
bottom: `${racewayBottom}px`,
width: RACEWAY_WIDTH,
zIndex: 5,
cursor: 'pointer',
borderRadius: 8,
bgcolor: (theme) => alpha(theme.palette.text.primary, 0.04),
transition: 'background-color 0.15s ease',
'&:hover': {
bgcolor: (theme) => alpha(theme.palette.text.primary, 0.09),
},
}}
>
{
this.thumbRef = el;
}}
onMouseDown={this._onThumbMouseDown}
sx={{
position: 'absolute',
top: thumbTop,
height: thumbHeight,
left: 2,
right: 2,
borderRadius: 6,
bgcolor: (theme) =>
this.state.isDragging
? alpha(theme.palette.primary.main, 0.7)
: alpha(theme.palette.text.primary, 0.3),
cursor: this.state.isDragging ? 'grabbing' : 'grab',
transition: this.state.isDragging ? 'none' : 'background-color 0.15s ease',
'&:hover': {
bgcolor: (theme) =>
this.state.isDragging
? alpha(theme.palette.primary.main, 0.7)
: alpha(theme.palette.text.primary, 0.55),
},
}}
/>
) : null}
{debug ? (
({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
background: alpha(theme.palette.primary.main, 0.08),
zIndex: 9999,
})}
/>
) : null}
>
);
}
}
export default FillSpace;