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 ``, callbacks are called. */ function renderRowElement(renderItem, item, index) { if (isRowComponent(renderItem)) { const Row = renderItem; return ; } 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 ; } 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 ? ( {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 != null ? ( {header} ) : null} { 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;