Files
dashgui/src/FillSpace.jsx
2026-10-15 02:33:26 +02:00

694 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {any} [headerItem] - Optional dummy/sample item passed to probe element for layout measurement.
* @prop {number} [minPageSize=10] - Rows per page.
* @prop {number} [maxPageSize] - Maximum rows per page.
* @prop {number} [totalCount] - Total items in current dataset (or filtered dataset when paginating).
* @prop {number} [unfilteredTotal] - Total items in unfiltered dataset. When unfilteredTotal > pageSize, pagination space is reserved even if current filtered count <= pageSize, preventing layout shift.
* @prop {number} [unfilteredCount] - Alias for unfilteredTotal.
* @prop {(page: number) => void} [onPageChange] - Called on page change.
* @prop {boolean} [virtualScrollbar=false] - Shows a synchronized scrollbar alongside pagination controls.
* @prop {boolean} [reserveScrollbar] - Reserves raceway space even when items do not overflow (defaults to true when virtualScrollbar is true).
* @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 `<Row brand item index />`, callbacks are called. */
function renderRowElement(renderItem, item, index) {
if (isRowComponent(renderItem)) {
const Row = renderItem;
return <Row brand={item} item={item} index={index} />;
}
return renderItem(item, index);
}
/**
* 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). When `headerItem` is supplied, it is passed to the row
* for precise measurement of data-driven layouts.
*/
function renderProbeElement(renderItem, headerItem) {
if (!isRowComponent(renderItem)) return null;
const Row = renderItem;
return <Row brand={headerItem} item={headerItem} index={0} disabled />;
}
const MIN_THUMB_HEIGHT = 48;
const RACEWAY_WIDTH = 16;
const RACEWAY_BOTTOM_MARGIN = 16;
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 rawUnfiltered = props.unfilteredTotal ?? props.unfilteredCount;
const unfilteredTotal = rawUnfiltered != null ? Number(rawUnfiltered) : null;
const total =
props.totalCount != null
? Number(props.totalCount)
: Array.isArray(props.items)
? props.items.length
: 0;
this.maxSeenCount = unfilteredTotal != null ? unfilteredTotal : total;
}
componentDidMount() {
this._probeMeasure();
this._viewportMeasure();
this._observe();
window.addEventListener('resize', this._onViewportChange);
}
componentDidUpdate(prevProps) {
if (
prevProps.renderItem !== this.props.renderItem ||
prevProps.headerItem !== this.props.headerItem
) {
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 rawUnfiltered = this.props.unfilteredTotal ?? this.props.unfilteredCount;
const unfilteredTotal = rawUnfiltered != null ? Number(rawUnfiltered) : null;
const total =
this.props.totalCount != null
? Number(this.props.totalCount)
: Array.isArray(this.props.items)
? this.props.items.length
: 0;
if (unfilteredTotal != null) {
this.maxSeenCount = unfilteredTotal;
} else {
this.maxSeenCount = Math.max(this.maxSeenCount || 0, total);
}
if (this.props.resetKey !== undefined && this.props.resetKey !== prevProps.resetKey) {
if (this.state.currentPage !== 1) {
this._goToPage(1);
} else {
this.props.onPageChange?.(1, {
pageCount: Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize))),
pageSize: this.state.pageSize,
total,
});
}
}
// Clamp current page when the item list shrinks (e.g. brand deleted) or trigger load for current page if items are missing.
if (
Array.isArray(this.props.items) &&
this.props.items !== prevProps.items
) {
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,
});
} else if (this.state.currentPage > 1) {
const offset = (this.state.currentPage - 1) * this.state.pageSize;
if (this.props.items.length > offset && this.props.items[offset] == null) {
this.props.onPageChange?.(this.state.currentPage, {
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, items } = this.props;
const { viewportHeight, pageSize, currentPage } = this.state;
const total = totalCount != null ? Number(totalCount) : (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, items } = this.props;
const { viewportHeight, pageSize } = this.state;
const total = totalCount != null ? Number(totalCount) : (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 } = this.props;
const total = totalCount != null ? Number(totalCount) : (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 } = this.props;
if (!Array.isArray(items)) return;
if (this.ref && event.target && !this.ref.contains(event.target)) return;
const { pageSize, currentPage } = this.state;
const total = totalCount != null ? Number(totalCount) : 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 } = this.props;
const total =
totalCount != null
? Number(totalCount)
: 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,
unfilteredTotal: propUnfilteredTotal,
resetKey,
listRef,
onPageChange,
onWheel,
virtualScrollbar = false,
reserveScrollbar,
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 total = totalCount != null ? Number(totalCount) : list.length;
const rawUnfiltered = propUnfilteredTotal ?? unfilteredCount;
const unfilteredTotal = rawUnfiltered != null ? Number(rawUnfiltered) : null;
const fullTotal = unfilteredTotal != null ? unfilteredTotal : 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;
const scrollbarReserved = reserveScrollbar ?? Boolean(virtualScrollbar);
const scrollbarPadding = scrollbarReserved
? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px`
: 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 probe: measures `renderItem` (passes headerItem when provided).
const probe = renderProbeElement(renderItem, this.props.headerItem);
return (
<>
{needsPaginationContainer ? (
<Box
// Fixed single-line bar: the header height must not depend on the
// filtered count so the content never shifts when filtering.
sx={{ mb: 1, height: 44, overflow: 'hidden' }}
>
<Box
aria-hidden={!showPaginationControls || undefined}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
gap: 1,
width: '100%',
height: 44,
visibility: showPaginationControls ? 'visible' : 'hidden',
pointerEvents: showPaginationControls ? 'auto' : 'none',
}}
>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '}
{total}
</Typography>
<Pagination
count={pageCount}
page={safePage}
onChange={(_e, page) => 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',
},
}}
/>
</Box>
</Box>
) : null}
{header != null ? (
<Box
sx={{
width: '100%',
pr: scrollbarPadding,
boxSizing: 'border-box',
}}
>
{header}
</Box>
) : null}
<Box
ref={(el) => {
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,
]}
>
<Box
sx={{
width: '100%',
pr: scrollbarPadding,
boxSizing: 'border-box',
}}
>
<List ref={listRef} dense disablePadding sx={{ width: '100%' }}>
{pageItems.map((item, i) => (
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}>
{renderRowElement(renderItem, item, offset + i)}
</Box>
))}
</List>
</Box>
{probe != null ? (
<Box
ref={(el) => {
this.probeRef = el;
}}
aria-hidden
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<List dense disablePadding sx={{ width: '100%' }}>
<Box data-fill-row sx={{ width: '100%' }}>
{probe}
</Box>
</List>
</Box>
) : null}
{canScroll ? (
<Box
ref={(el) => {
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),
},
}}
>
<Box
ref={(el) => {
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),
},
}}
/>
</Box>
) : null}
</Box>
{debug ? (
<Box
sx={(theme) => ({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
background: alpha(theme.palette.primary.main, 0.08),
zIndex: 9999,
})}
/>
) : null}
</>
);
}
}
export default FillSpace;