u
This commit is contained in:
@@ -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} [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 {number} [unfilteredCount] - Alias for totalCount.
|
||||||
* @prop {(page: number) => void} [onPageChange] - Called on page change.
|
* @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 {boolean} [debug=false] - Dashed outline for layout debugging.
|
||||||
* @prop {object} [sx] - Extra styles.
|
* @prop {object} [sx] - Extra styles.
|
||||||
*/
|
*/
|
||||||
@@ -71,8 +71,8 @@ class FillSpace extends Component {
|
|||||||
viewportHeight: null,
|
viewportHeight: null,
|
||||||
pageSize: Math.min(maxSize, minSize),
|
pageSize: Math.min(maxSize, minSize),
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
scrollTop: 0,
|
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
|
dragThumbTop: 0,
|
||||||
};
|
};
|
||||||
this.ref = null;
|
this.ref = null;
|
||||||
this.trackRef = null;
|
this.trackRef = null;
|
||||||
@@ -81,10 +81,8 @@ class FillSpace extends Component {
|
|||||||
this.wheelAcc = 0;
|
this.wheelAcc = 0;
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
this.raf = null;
|
this.raf = null;
|
||||||
this.scrollRaf = null;
|
|
||||||
this.pendingScrollTop = 0;
|
|
||||||
this.dragStartY = 0;
|
this.dragStartY = 0;
|
||||||
this.dragStartScrollTop = 0;
|
this.dragStartThumbTop = 0;
|
||||||
const explicitCount = props.unfilteredCount ?? props.totalCount;
|
const explicitCount = props.unfilteredCount ?? props.totalCount;
|
||||||
this.maxSeenCount =
|
this.maxSeenCount =
|
||||||
explicitCount != null
|
explicitCount != null
|
||||||
@@ -121,9 +119,6 @@ class FillSpace extends Component {
|
|||||||
prevProps.items !== this.props.items ||
|
prevProps.items !== this.props.items ||
|
||||||
prevProps.header !== this.props.header
|
prevProps.header !== this.props.header
|
||||||
) {
|
) {
|
||||||
if (this.state.unitHeight <= 0) {
|
|
||||||
this._probeMeasure();
|
|
||||||
}
|
|
||||||
this._observe();
|
this._observe();
|
||||||
this._viewportMeasure();
|
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() {
|
componentWillUnmount() {
|
||||||
@@ -192,10 +163,6 @@ class FillSpace extends Component {
|
|||||||
cancelAnimationFrame(this.raf);
|
cancelAnimationFrame(this.raf);
|
||||||
this.raf = null;
|
this.raf = null;
|
||||||
}
|
}
|
||||||
if (this.scrollRaf) {
|
|
||||||
cancelAnimationFrame(this.scrollRaf);
|
|
||||||
this.scrollRaf = null;
|
|
||||||
}
|
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
@@ -205,9 +172,20 @@ class FillSpace extends Component {
|
|||||||
_onThumbMouseDown = (e) => {
|
_onThumbMouseDown = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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.dragStartY = e.clientY;
|
||||||
this.dragStartScrollTop = this.state.scrollTop;
|
this.dragStartThumbTop = currentThumbTop;
|
||||||
this.setState({ isDragging: true });
|
this.setState({ isDragging: true, dragThumbTop: currentThumbTop });
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
document.body.style.cursor = 'grabbing';
|
document.body.style.cursor = 'grabbing';
|
||||||
window.addEventListener('mousemove', this._onThumbDragMove);
|
window.addEventListener('mousemove', this._onThumbDragMove);
|
||||||
@@ -216,26 +194,27 @@ class FillSpace extends Component {
|
|||||||
|
|
||||||
_onThumbDragMove = (e) => {
|
_onThumbDragMove = (e) => {
|
||||||
if (!this.state.isDragging) return;
|
if (!this.state.isDragging) return;
|
||||||
const deltaY = e.clientY - this.dragStartY;
|
const { totalCount, unfilteredCount, items } = this.props;
|
||||||
const { viewportHeight, unitHeight } = this.state;
|
const { viewportHeight, pageSize } = this.state;
|
||||||
const { items, totalCount, unfilteredCount } = this.props;
|
|
||||||
const explicitTotal = unfilteredCount ?? totalCount;
|
const explicitTotal = unfilteredCount ?? totalCount;
|
||||||
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
|
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
|
||||||
const totalHeight = total * unitHeight;
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
const maxScrollTop = Math.max(0, totalHeight - (viewportHeight || 0));
|
if (pageCount <= 1) return;
|
||||||
if (maxScrollTop <= 0) return;
|
|
||||||
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
const racewayBottom = Math.max(0, Number(this.props.racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
||||||
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
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 thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / totalHeight) * trackHeight));
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
const maxThumbTravel = trackHeight - thumbHeight;
|
|
||||||
if (maxThumbTravel <= 0) return;
|
if (maxThumbTravel <= 0) return;
|
||||||
const scrollDelta = (deltaY / maxThumbTravel) * maxScrollTop;
|
|
||||||
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, this.dragStartScrollTop + scrollDelta));
|
const deltaY = e.clientY - this.dragStartY;
|
||||||
if (this.ref) {
|
const nextThumbTop = Math.max(0, Math.min(maxThumbTravel, this.dragStartThumbTop + deltaY));
|
||||||
this.ref.scrollTop = nextScrollTop;
|
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 = () => {
|
_onThumbDragEnd = () => {
|
||||||
@@ -251,23 +230,33 @@ class FillSpace extends Component {
|
|||||||
if (!this.trackRef) return;
|
if (!this.trackRef) return;
|
||||||
const trackRect = this.trackRef.getBoundingClientRect();
|
const trackRect = this.trackRef.getBoundingClientRect();
|
||||||
const clickY = e.clientY - trackRect.top;
|
const clickY = e.clientY - trackRect.top;
|
||||||
const { viewportHeight, unitHeight } = this.state;
|
const { viewportHeight, pageSize } = this.state;
|
||||||
const { items, totalCount, unfilteredCount } = this.props;
|
const { items, totalCount, unfilteredCount } = this.props;
|
||||||
const explicitTotal = unfilteredCount ?? totalCount;
|
const explicitTotal = unfilteredCount ?? totalCount;
|
||||||
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
|
const total = explicitTotal != null ? Number(explicitTotal) : (items?.length || 0);
|
||||||
const totalHeight = total * unitHeight;
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
const maxScrollTop = Math.max(0, totalHeight - (viewportHeight || 0));
|
if (pageCount <= 1) return;
|
||||||
if (maxScrollTop <= 0) return;
|
|
||||||
const trackHeight = trackRect.height;
|
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;
|
if (trackHeight <= 0) return;
|
||||||
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / totalHeight) * trackHeight));
|
const thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
|
||||||
const maxThumbTravel = trackHeight - thumbHeight;
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
const targetThumbTop = Math.max(0, Math.min(maxThumbTravel, clickY - thumbHeight / 2));
|
const targetThumbTop = Math.max(0, Math.min(maxThumbTravel, clickY - thumbHeight / 2));
|
||||||
const targetScrollTop = maxThumbTravel > 0 ? (targetThumbTop / maxThumbTravel) * maxScrollTop : 0;
|
const targetPage = maxThumbTravel > 0 ? Math.min(pageCount, Math.max(1, Math.round(1 + (targetThumbTop / maxThumbTravel) * (pageCount - 1)))) : 1;
|
||||||
if (this.ref) {
|
this._goToPage(targetPage);
|
||||||
this.ref.scrollTop = targetScrollTop;
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
_onViewportChange = () => {
|
||||||
@@ -313,21 +302,6 @@ class FillSpace extends Component {
|
|||||||
this._viewportMeasure();
|
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. */
|
/** Only measures the height of the `renderItem` probe. Nothing else. */
|
||||||
_probeMeasure() {
|
_probeMeasure() {
|
||||||
const firstRow = this.ref?.querySelector('[data-fill-row]');
|
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 minSize = Math.max(1, Number(minPageSize) || 10);
|
||||||
const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity;
|
const maxSize = Number(this.props.maxPageSize) > 0 ? Number(this.props.maxPageSize) : Infinity;
|
||||||
let { unitHeight } = this.state;
|
const { 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 calculated =
|
const calculated =
|
||||||
unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize;
|
unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize;
|
||||||
const nextPageSize = Math.min(maxSize, calculated);
|
const nextPageSize = Math.min(maxSize, calculated);
|
||||||
@@ -379,51 +346,14 @@ class FillSpace extends Component {
|
|||||||
Number.isFinite(maxSize) && unitHeight > 0
|
Number.isFinite(maxSize) && unitHeight > 0
|
||||||
? Math.min(available, nextPageSize * unitHeight)
|
? Math.min(available, nextPageSize * unitHeight)
|
||||||
: available;
|
: available;
|
||||||
const updates = {};
|
if (
|
||||||
if (nextViewportHeight !== this.state.viewportHeight) {
|
nextViewportHeight !== this.state.viewportHeight ||
|
||||||
updates.viewportHeight = nextViewportHeight;
|
nextPageSize !== this.state.pageSize
|
||||||
}
|
) {
|
||||||
if (nextPageSize !== this.state.pageSize) {
|
this.setState({ viewportHeight: nextViewportHeight, pageSize: nextPageSize });
|
||||||
updates.pageSize = nextPageSize;
|
|
||||||
}
|
|
||||||
if (unitHeight > 0 && unitHeight !== this.state.unitHeight) {
|
|
||||||
updates.unitHeight = unitHeight;
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length > 0) {
|
|
||||||
this.setState(updates);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_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) => {
|
_handleWheel = (event) => {
|
||||||
const { items, totalCount, unfilteredCount } = this.props;
|
const { items, totalCount, unfilteredCount } = this.props;
|
||||||
if (!Array.isArray(items)) return;
|
if (!Array.isArray(items)) return;
|
||||||
@@ -449,7 +379,7 @@ class FillSpace extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
_goToPage = (page) => {
|
_goToPage = (page) => {
|
||||||
const { items, onPageChange, totalCount, unfilteredCount, virtualScrollbar } = this.props;
|
const { items, onPageChange, totalCount, unfilteredCount } = this.props;
|
||||||
const explicitTotal = unfilteredCount ?? totalCount;
|
const explicitTotal = unfilteredCount ?? totalCount;
|
||||||
const total =
|
const total =
|
||||||
explicitTotal != null
|
explicitTotal != null
|
||||||
@@ -459,16 +389,6 @@ class FillSpace extends Component {
|
|||||||
: 0;
|
: 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, this.state.pageSize)));
|
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);
|
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;
|
if (next === this.state.currentPage) return;
|
||||||
this.setState({ currentPage: next });
|
this.setState({ currentPage: next });
|
||||||
onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total });
|
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.
|
// FillSpace owns current page, wheel-pagination and the pagination header.
|
||||||
// Caller feeds `items` plus one row element via `renderItem` (component
|
// Caller feeds `items` plus one row element via `renderItem` (component
|
||||||
// type or callback). `items` may be undefined while data is loading.
|
// 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 list = Array.isArray(items) ? items : [];
|
||||||
const explicitTotal = unfilteredCount ?? totalCount;
|
const explicitTotal = unfilteredCount ?? totalCount;
|
||||||
const total = explicitTotal != null ? Number(explicitTotal) : list.length;
|
const total = explicitTotal != null ? Number(explicitTotal) : list.length;
|
||||||
@@ -512,47 +432,14 @@ class FillSpace extends Component {
|
|||||||
const needsPaginationContainer = fullTotal > pageSize;
|
const needsPaginationContainer = fullTotal > pageSize;
|
||||||
const showPaginationControls = total > pageSize;
|
const showPaginationControls = total > pageSize;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
|
const safePage = Math.min(Math.max(1, currentPage), pageCount);
|
||||||
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;
|
const offset = (safePage - 1) * pageSize;
|
||||||
renderedItems = list.slice(offset, offset + pageSize);
|
const pageItems = list.slice(offset, offset + pageSize);
|
||||||
itemOffset = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canScroll =
|
const canScroll =
|
||||||
virtualScrollbar &&
|
virtualScrollbar &&
|
||||||
viewportHeight > 0 &&
|
total > pageSize &&
|
||||||
unitHeight > 0 &&
|
(viewportHeight || 0) > 0;
|
||||||
total * unitHeight > viewportHeight;
|
|
||||||
|
|
||||||
const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
const racewayBottom = Math.max(0, Number(racewayMarginBottom ?? RACEWAY_BOTTOM_MARGIN) || 0);
|
||||||
const racewayRightOffset = racewayRight != null ? racewayRight : -(RACEWAY_WIDTH - 8);
|
const racewayRightOffset = racewayRight != null ? racewayRight : -(RACEWAY_WIDTH - 8);
|
||||||
@@ -562,11 +449,11 @@ class FillSpace extends Component {
|
|||||||
|
|
||||||
if (canScroll) {
|
if (canScroll) {
|
||||||
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
const trackHeight = Math.max(0, (viewportHeight || 0) - racewayBottom);
|
||||||
const fullHeight = total * unitHeight;
|
thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, total > 0 ? (pageSize / total) * trackHeight : trackHeight));
|
||||||
thumbHeight = Math.max(MIN_THUMB_HEIGHT, Math.min(trackHeight, (viewportHeight / fullHeight) * trackHeight));
|
const maxThumbTravel = Math.max(0, trackHeight - thumbHeight);
|
||||||
const maxScrollTop = Math.max(0, fullHeight - viewportHeight);
|
thumbTop = this.state.isDragging
|
||||||
const maxThumbTravel = trackHeight - thumbHeight;
|
? this.state.dragThumbTop
|
||||||
thumbTop = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTravel : 0;
|
: (pageCount > 1 && maxThumbTravel > 0 ? ((safePage - 1) / (pageCount - 1)) * maxThumbTravel : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hidden data-free probe: measures `renderItem` itself (no data).
|
// Hidden data-free probe: measures `renderItem` itself (no data).
|
||||||
@@ -600,7 +487,8 @@ class FillSpace extends Component {
|
|||||||
noWrap
|
noWrap
|
||||||
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
|
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}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={pageCount}
|
count={pageCount}
|
||||||
@@ -628,61 +516,43 @@ class FillSpace extends Component {
|
|||||||
) : null}
|
) : null}
|
||||||
{header}
|
{header}
|
||||||
<Box
|
<Box
|
||||||
|
ref={(el) => {
|
||||||
|
this.ref = el;
|
||||||
|
}}
|
||||||
|
onWheel={(e) => {
|
||||||
|
this._handleWheel(e);
|
||||||
|
onWheel?.(e);
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: viewportHeight ?? 'auto',
|
height: viewportHeight ?? 'auto',
|
||||||
|
overflow: 'hidden',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
// Keep wheel ticks from scrolling the page behind the list.
|
||||||
|
// (No preventDefault needed — React wheel is passive.)
|
||||||
overscrollBehavior: 'contain',
|
overscrollBehavior: 'contain',
|
||||||
},
|
},
|
||||||
sx,
|
|
||||||
debug
|
debug
|
||||||
? (theme) => ({
|
? (theme) => ({
|
||||||
outline: `2px dashed ${theme.palette.primary.main}`,
|
outline: `2px dashed ${theme.palette.primary.main}`,
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
sx,
|
||||||
]}
|
]}
|
||||||
>
|
|
||||||
<Box
|
|
||||||
ref={(el) => {
|
|
||||||
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' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
pr: canScroll ? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px` : 0,
|
pr: canScroll ? `${Math.max(0, RACEWAY_WIDTH + racewayRightOffset + 8)}px` : 0,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
...(virtualScrollbar && unitHeight > 0
|
|
||||||
? {
|
|
||||||
paddingTop: `${paddingTop}px`,
|
|
||||||
paddingBottom: `${paddingBottom}px`,
|
|
||||||
minHeight: totalContentHeight,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<List ref={listRef} dense disablePadding sx={{ width: '100%' }}>
|
<List ref={listRef} dense disablePadding sx={{ width: '100%' }}>
|
||||||
{renderedItems.map((item, i) => (
|
{pageItems.map((item, i) => (
|
||||||
<Box key={item?.id ?? itemOffset + i} data-fill-row sx={{ width: '100%' }}>
|
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}>
|
||||||
{renderRowElement(renderItem, item, itemOffset + i)}
|
{renderRowElement(renderItem, item, offset + i)}
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
@@ -709,7 +579,6 @@ class FillSpace extends Component {
|
|||||||
</List>
|
</List>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
|
||||||
|
|
||||||
{canScroll ? (
|
{canScroll ? (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class FillSpaceMockPage extends Component {
|
|||||||
{/* Example 4: Virtual Scrollbar */}
|
{/* Example 4: Virtual Scrollbar */}
|
||||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
Virtual Scrollbar (smooth continuous scroll with scrollbar)
|
Virtual Scrollbar (scrollbar synchronized with pagination)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
Reference in New Issue
Block a user