diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx
index 85d9dec..7d33540 100644
--- a/src/FillSpace.jsx
+++ b/src/FillSpace.jsx
@@ -20,6 +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} [debug=false] - Dashed outline for layout debugging.
* @prop {object} [sx] - Extra styles.
*/
@@ -56,6 +57,10 @@ function renderProbeElement(renderItem) {
return
;
}
+const MIN_THUMB_HEIGHT = 48;
+const RACEWAY_WIDTH = 16;
+const RACEWAY_BOTTOM_MARGIN = 8;
+
class FillSpace extends Component {
constructor(props) {
super(props);
@@ -66,12 +71,20 @@ class FillSpace extends Component {
viewportHeight: null,
pageSize: Math.min(maxSize, minSize),
currentPage: 1,
+ scrollTop: 0,
+ isDragging: false,
};
this.ref = null;
+ this.trackRef = null;
+ this.thumbRef = null;
this.probeRef = null;
this.wheelAcc = 0;
this.observer = null;
this.raf = null;
+ this.scrollRaf = null;
+ this.pendingScrollTop = 0;
+ this.dragStartY = 0;
+ this.dragStartScrollTop = 0;
const explicitCount = props.unfilteredCount ?? props.totalCount;
this.maxSeenCount =
explicitCount != null
@@ -138,20 +151,122 @@ 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() {
+ 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.scrollRaf) {
+ cancelAnimationFrame(this.scrollRaf);
+ this.scrollRaf = null;
+ }
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
+ _onThumbMouseDown = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dragStartY = e.clientY;
+ this.dragStartScrollTop = this.state.scrollTop;
+ this.setState({ isDragging: true });
+ 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 deltaY = e.clientY - this.dragStartY;
+ const { viewportHeight, unitHeight } = 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 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;
+ 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;
+ }
+ this.setState({ scrollTop: nextScrollTop });
+ };
+
+ _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, unitHeight } = 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;
+ if (trackHeight <= 0) return;
+ const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / totalHeight) * trackHeight));
+ const maxThumbTravel = 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;
+ }
+ this.setState({ scrollTop: targetScrollTop });
+ };
+
_onViewportChange = () => {
if (this.raf) return;
this.raf = requestAnimationFrame(() => {
@@ -195,6 +310,21 @@ 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]');
@@ -247,6 +377,36 @@ class FillSpace extends Component {
}
}
+ _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;
@@ -272,7 +432,7 @@ class FillSpace extends Component {
};
_goToPage = (page) => {
- const { items, onPageChange, totalCount, unfilteredCount } = this.props;
+ const { items, onPageChange, totalCount, unfilteredCount, virtualScrollbar } = this.props;
const explicitTotal = unfilteredCount ?? totalCount;
const total =
explicitTotal != null
@@ -282,6 +442,16 @@ 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 });
@@ -303,6 +473,8 @@ class FillSpace extends Component {
listRef,
onPageChange,
onWheel,
+ virtualScrollbar = false,
+ racewayMarginBottom,
debug = false,
items,
renderItem,
@@ -314,7 +486,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 } = this.state;
+ const { pageSize, currentPage, viewportHeight, unitHeight, scrollTop } = this.state;
const list = Array.isArray(items) ? items : [];
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : list.length;
@@ -322,9 +494,62 @@ 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 safePage = Math.min(Math.max(1, currentPage), pageCount);
- const offset = (safePage - 1) * pageSize;
- const pageItems = list.slice(offset, offset + 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 canScroll =
+ virtualScrollbar &&
+ viewportHeight > 0 &&
+ unitHeight > 0 &&
+ total * unitHeight > viewportHeight;
+
+ const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
+
+ let thumbHeight = 0;
+ let thumbTop = 0;
+
+ 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;
+ }
+
// Hidden data-free probe: measures `renderItem` itself (no data).
const probe = renderProbeElement(renderItem);
@@ -356,8 +581,7 @@ class FillSpace extends Component {
noWrap
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
>
- {total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '}
- {total}
+ {total === 0 ? '0' : `${countStart}–${countEnd}`} 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}`,
})
: undefined,
- sx,
]}
>
-
-
- {pageItems.map((item, i) => (
-
- {renderRowElement(renderItem, item, offset + i)}
-
- ))}
-
-
- {probe != null ? (
+ {
+ 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' },
+ }}
+ >
{
- this.probeRef = el;
- }}
- aria-hidden
sx={{
- position: 'absolute',
- left: 0,
- top: 0,
width: '100%',
- visibility: 'hidden',
- pointerEvents: 'none',
+ pr: canScroll ? `${RACEWAY_WIDTH + 8}px` : 0,
+ boxSizing: 'border-box',
+ ...(virtualScrollbar && unitHeight > 0
+ ? {
+ paddingTop: `${paddingTop}px`,
+ paddingBottom: `${paddingBottom}px`,
+ minHeight: totalContentHeight,
+ }
+ : {}),
}}
>
-
-
- {probe}
-
+
+ {renderedItems.map((item, i) => (
+
+ {renderRowElement(renderItem, item, itemOffset + 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: 3,
+ 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 ? (
diff --git a/src/dev/mock-pages/FillSpaceMockPage.jsx b/src/dev/mock-pages/FillSpaceMockPage.jsx
index 4984765..8907e95 100644
--- a/src/dev/mock-pages/FillSpaceMockPage.jsx
+++ b/src/dev/mock-pages/FillSpaceMockPage.jsx
@@ -34,6 +34,7 @@ class FillSpaceMockPage extends Component {
this.ex1 = { header: createRef(), footer: createRef() };
this.ex2 = { header: createRef(), footer: createRef() };
this.ex3 = { header: createRef(), footer: createRef() };
+ this.exVirtual = { header: createRef(), footer: createRef() };
}
render() {
@@ -138,6 +139,37 @@ class FillSpaceMockPage extends Component {
+ {/* Example 4: Virtual Scrollbar */}
+
+
+ Virtual Scrollbar (smooth continuous scroll with scrollbar)
+
+
+
+ Header
+
+
+
+
+
+ Footer
+
+
+
+
);