694 lines
25 KiB
JavaScript
694 lines
25 KiB
JavaScript
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;
|