From c735ba9c65d451181c1294080e4fc99060393dc2 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 12 Oct 2026 02:13:39 +0200 Subject: [PATCH] u --- src/FillSpace.jsx | 351 +++++++---------------- src/dev/mock-pages/FillSpaceMockPage.jsx | 2 +- 2 files changed, 111 insertions(+), 242 deletions(-) diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx index 05ffe65..91b8d8d 100644 --- a/src/FillSpace.jsx +++ b/src/FillSpace.jsx @@ -20,7 +20,7 @@ import { alpha } from '@mui/material/styles'; * @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] - Enables smooth continuous virtual scrolling with a scrollbar synchronized with the pagination controls. + * @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. */ @@ -71,8 +71,8 @@ class FillSpace extends Component { viewportHeight: null, pageSize: Math.min(maxSize, minSize), currentPage: 1, - scrollTop: 0, isDragging: false, + dragThumbTop: 0, }; this.ref = null; this.trackRef = null; @@ -81,10 +81,8 @@ class FillSpace extends Component { this.wheelAcc = 0; this.observer = null; this.raf = null; - this.scrollRaf = null; - this.pendingScrollTop = 0; this.dragStartY = 0; - this.dragStartScrollTop = 0; + this.dragStartThumbTop = 0; const explicitCount = props.unfilteredCount ?? props.totalCount; this.maxSeenCount = explicitCount != null @@ -121,9 +119,6 @@ class FillSpace extends Component { prevProps.items !== this.props.items || prevProps.header !== this.props.header ) { - if (this.state.unitHeight <= 0) { - this._probeMeasure(); - } this._observe(); this._viewportMeasure(); } @@ -154,30 +149,6 @@ class FillSpace extends Component { }); } } - if ( - this.props.virtualScrollbar && - (prevProps.items !== this.props.items || - prevProps.totalCount !== this.props.totalCount || - prevProps.unfilteredCount !== this.props.unfilteredCount) - ) { - const explicitTotal = this.props.unfilteredCount ?? this.props.totalCount; - const total = - explicitTotal != null - ? Number(explicitTotal) - : Array.isArray(this.props.items) - ? this.props.items.length - : 0; - const maxScrollTop = Math.max( - 0, - total * this.state.unitHeight - (this.state.viewportHeight || 0), - ); - if (this.state.scrollTop > maxScrollTop) { - if (this.ref && this.ref.scrollTop !== maxScrollTop) { - this.ref.scrollTop = maxScrollTop; - } - this.setState({ scrollTop: maxScrollTop }); - } - } } componentWillUnmount() { @@ -192,10 +163,6 @@ class FillSpace extends Component { cancelAnimationFrame(this.raf); this.raf = null; } - if (this.scrollRaf) { - cancelAnimationFrame(this.scrollRaf); - this.scrollRaf = null; - } if (this.observer) { this.observer.disconnect(); this.observer = null; @@ -205,9 +172,20 @@ class FillSpace extends Component { _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.dragStartScrollTop = this.state.scrollTop; - this.setState({ isDragging: true }); + this.dragStartThumbTop = currentThumbTop; + this.setState({ isDragging: true, dragThumbTop: currentThumbTop }); document.body.style.userSelect = 'none'; document.body.style.cursor = 'grabbing'; window.addEventListener('mousemove', this._onThumbDragMove); @@ -216,26 +194,27 @@ class FillSpace extends Component { _onThumbDragMove = (e) => { if (!this.state.isDragging) return; - const deltaY = e.clientY - this.dragStartY; - const { viewportHeight, unitHeight } = this.state; - const { items, totalCount, unfilteredCount } = this.props; + 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 totalHeight = total * unitHeight; - const maxScrollTop = Math.max(0, totalHeight - (viewportHeight || 0)); - if (maxScrollTop <= 0) return; + 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, (viewportHeight / totalHeight) * trackHeight)); - const maxThumbTravel = trackHeight - thumbHeight; + 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 scrollDelta = (deltaY / maxThumbTravel) * maxScrollTop; - const nextScrollTop = Math.max(0, Math.min(maxScrollTop, this.dragStartScrollTop + scrollDelta)); - if (this.ref) { - this.ref.scrollTop = nextScrollTop; + + 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); } - this.setState({ scrollTop: nextScrollTop }); }; _onThumbDragEnd = () => { @@ -251,23 +230,33 @@ class FillSpace extends Component { if (!this.trackRef) return; const trackRect = this.trackRef.getBoundingClientRect(); const clickY = e.clientY - trackRect.top; - const { viewportHeight, unitHeight } = this.state; + 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 totalHeight = total * unitHeight; - const maxScrollTop = Math.max(0, totalHeight - (viewportHeight || 0)); - if (maxScrollTop <= 0) return; - const trackHeight = trackRect.height; + 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, (viewportHeight / totalHeight) * trackHeight)); - const maxThumbTravel = trackHeight - thumbHeight; + 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 targetScrollTop = maxThumbTravel > 0 ? (targetThumbTop / maxThumbTravel) * maxScrollTop : 0; - if (this.ref) { - this.ref.scrollTop = targetScrollTop; + 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); } - this.setState({ scrollTop: targetScrollTop }); }; _onViewportChange = () => { @@ -313,21 +302,6 @@ class FillSpace extends Component { this._viewportMeasure(); }; - scrollToTop = () => { - if (this.ref) { - this.ref.scrollTop = 0; - } - this.setState({ scrollTop: 0, currentPage: 1 }); - }; - - scrollToIndex = (index) => { - if (this.ref && this.state.unitHeight > 0) { - const top = Math.max(0, index * this.state.unitHeight); - this.ref.scrollTop = top; - this.setState({ scrollTop: top }); - } - }; - /** Only measures the height of the `renderItem` probe. Nothing else. */ _probeMeasure() { const firstRow = this.ref?.querySelector('[data-fill-row]'); @@ -364,14 +338,7 @@ class FillSpace extends Component { } const minSize = Math.max(1, Number(minPageSize) || 10); const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity; - let { unitHeight } = this.state; - if (unitHeight <= 0) { - const firstRow = this.ref?.querySelector('[data-fill-row]'); - const h = this.probeRef?.offsetHeight || firstRow?.offsetHeight; - if (h > 0) { - unitHeight = h; - } - } + const { unitHeight } = this.state; const calculated = unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize; const nextPageSize = Math.min(maxSize, calculated); @@ -379,51 +346,14 @@ class FillSpace extends Component { Number.isFinite(maxSize) && unitHeight > 0 ? Math.min(available, nextPageSize * unitHeight) : available; - const updates = {}; - if (nextViewportHeight !== this.state.viewportHeight) { - updates.viewportHeight = nextViewportHeight; - } - if (nextPageSize !== this.state.pageSize) { - updates.pageSize = nextPageSize; - } - if (unitHeight > 0 && unitHeight !== this.state.unitHeight) { - updates.unitHeight = unitHeight; - } - if (Object.keys(updates).length > 0) { - this.setState(updates); + if ( + nextViewportHeight !== this.state.viewportHeight || + nextPageSize !== this.state.pageSize + ) { + this.setState({ viewportHeight: nextViewportHeight, pageSize: nextPageSize }); } } - _handleScroll = (event) => { - const st = event.currentTarget.scrollTop; - this.pendingScrollTop = st; - if (this.scrollRaf) return; - this.scrollRaf = requestAnimationFrame(() => { - this.scrollRaf = null; - const nextSt = this.pendingScrollTop; - if (nextSt != null && nextSt !== this.state.scrollTop) { - const { unitHeight, pageSize } = this.state; - const currentFirst = unitHeight > 0 ? Math.floor(nextSt / unitHeight) : 0; - const newPage = pageSize > 0 ? Math.floor(currentFirst / pageSize) + 1 : 1; - const updates = { scrollTop: nextSt }; - if (newPage !== this.state.currentPage) { - updates.currentPage = newPage; - 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, pageSize))); - onPageChange?.(newPage, { pageCount, pageSize, total }); - } - this.setState(updates); - } - }); - }; - _handleWheel = (event) => { const { items, totalCount, unfilteredCount } = this.props; if (!Array.isArray(items)) return; @@ -449,7 +379,7 @@ class FillSpace extends Component { }; _goToPage = (page) => { - const { items, onPageChange, totalCount, unfilteredCount, virtualScrollbar } = this.props; + const { items, onPageChange, totalCount, unfilteredCount } = this.props; const explicitTotal = unfilteredCount ?? totalCount; const total = explicitTotal != null @@ -459,16 +389,6 @@ class FillSpace extends Component { : 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 (virtualScrollbar) { - const targetScrollTop = (next - 1) * this.state.pageSize * this.state.unitHeight; - if (this.ref) { - this.ref.scrollTop = targetScrollTop; - } - if (next === this.state.currentPage && targetScrollTop === this.state.scrollTop) return; - this.setState({ currentPage: next, scrollTop: targetScrollTop }); - onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total }); - return; - } if (next === this.state.currentPage) return; this.setState({ currentPage: next }); onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total }); @@ -504,7 +424,7 @@ class FillSpace extends Component { // 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, unitHeight, scrollTop } = this.state; + const { pageSize, currentPage, viewportHeight } = this.state; const list = Array.isArray(items) ? items : []; const explicitTotal = unfilteredCount ?? totalCount; const total = explicitTotal != null ? Number(explicitTotal) : list.length; @@ -512,47 +432,14 @@ class FillSpace extends Component { const needsPaginationContainer = fullTotal > pageSize; const showPaginationControls = total > pageSize; const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); - - const currentFirstIndex = - virtualScrollbar && unitHeight > 0 - ? Math.min(Math.max(0, total - 1), Math.floor(scrollTop / unitHeight)) - : (Math.min(Math.max(1, currentPage), pageCount) - 1) * pageSize; - const safePage = - virtualScrollbar && pageSize > 0 - ? Math.min(pageCount, Math.floor(currentFirstIndex / pageSize) + 1) - : Math.min(Math.max(1, currentPage), pageCount); - - const countStart = total === 0 ? 0 : currentFirstIndex + 1; - const countEnd = Math.min(total, currentFirstIndex + pageSize); - - let renderedItems; - let itemOffset; - let paddingTop = 0; - let paddingBottom = 0; - let totalContentHeight; - - if (virtualScrollbar && unitHeight > 0) { - const overscan = 3; - const rawStartIndex = Math.floor(scrollTop / unitHeight); - const startIndex = Math.max(0, rawStartIndex - overscan); - const visibleCount = Math.ceil((viewportHeight || 0) / unitHeight); - const endIndex = Math.min(total, rawStartIndex + visibleCount + overscan); - renderedItems = list.slice(startIndex, endIndex); - itemOffset = startIndex; - paddingTop = startIndex * unitHeight; - paddingBottom = Math.max(0, (total - endIndex) * unitHeight); - totalContentHeight = total * unitHeight; - } else { - const offset = (safePage - 1) * pageSize; - renderedItems = list.slice(offset, offset + pageSize); - itemOffset = offset; - } + const safePage = Math.min(Math.max(1, currentPage), pageCount); + const offset = (safePage - 1) * pageSize; + const pageItems = list.slice(offset, offset + pageSize); const canScroll = virtualScrollbar && - viewportHeight > 0 && - unitHeight > 0 && - total * unitHeight > viewportHeight; + total > pageSize && + (viewportHeight || 0) > 0; const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0); const racewayRightOffset = racewayRight != null ? racewayRight : -(RACEWAY_WIDTH - 8); @@ -562,11 +449,11 @@ class FillSpace extends Component { if (canScroll) { const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom); - const fullHeight = total * unitHeight; - thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / fullHeight) * trackHeight)); - const maxScrollTop = Math.max(0, fullHeight - viewportHeight); - const maxThumbTravel = trackHeight - thumbHeight; - thumbTop = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTravel : 0; + 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). @@ -600,7 +487,8 @@ class FillSpace extends Component { noWrap sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} > - {total === 0 ? '0' : `${countStart}–${countEnd}`} von {total} + {total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '} + {total} { + 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', }, - sx, debug ? (theme) => ({ - outline: `2px dashed ${theme.palette.primary.main}`, - }) + outline: `2px dashed ${theme.palette.primary.main}`, + }) : undefined, + sx, ]} > { - this.ref = el; - }} - onScroll={virtualScrollbar ? this._handleScroll : undefined} - onWheel={(e) => { - if (!virtualScrollbar) { - this._handleWheel(e); - } - onWheel?.(e); - }} sx={{ - position: 'relative', - height: '100%', width: '100%', - overflowY: virtualScrollbar ? 'auto' : 'hidden', - overflowX: 'hidden', - scrollbarWidth: 'none', - '&::-webkit-scrollbar': { display: 'none' }, + pr: canScroll ? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px` : 0, + boxSizing: 'border-box', }} > + + {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%', - pr: canScroll ? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px` : 0, - boxSizing: 'border-box', - ...(virtualScrollbar && unitHeight > 0 - ? { - paddingTop: `${paddingTop}px`, - paddingBottom: `${paddingBottom}px`, - minHeight: totalContentHeight, - } - : {}), + visibility: 'hidden', + pointerEvents: 'none', }} > - - {renderedItems.map((item, i) => ( - - {renderRowElement(renderItem, item, itemOffset + i)} - - ))} + + + {probe} + - {probe != null ? ( - { - this.probeRef = el; - }} - aria-hidden - sx={{ - position: 'absolute', - left: 0, - top: 0, - width: '100%', - visibility: 'hidden', - pointerEvents: 'none', - }} - > - - - {probe} - - - - ) : null} - + ) : null} {canScroll ? ( - Virtual Scrollbar (smooth continuous scroll with scrollbar) + Virtual Scrollbar (scrollbar synchronized with pagination)