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;