Compare commits

...

7 Commits

Author SHA1 Message Date
seb
37461e539b u 2026-10-11 22:09:08 +02:00
seb
3e90ec7f67 u 2026-10-11 22:08:12 +02:00
seb
90141a539a u 2026-10-11 22:05:18 +02:00
seb
64cfc56ccd u 2026-10-11 19:46:22 +02:00
seb
1976cfb617 u 2026-10-11 17:27:36 +02:00
seb
c34f86f762 u 2026-10-11 13:44:39 +02:00
seb
8325402bc6 u 2026-10-11 12:49:16 +02:00
2 changed files with 495 additions and 106 deletions

View File

@@ -14,8 +14,13 @@ import { alpha } from '@mui/material/styles';
*
* @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 {number} [minPageSize=10] - Rows per page.
* @prop {number} [maxPageSize] - Maximum rows per page.
* @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.
*/
@@ -52,20 +57,41 @@ function renderProbeElement(renderItem) {
return <Row brand={undefined} item={undefined} index={0} disabled />;
}
const MIN_THUMB_HEIGHT = 48;
const RACEWAY_WIDTH = 16;
const RACEWAY_BOTTOM_MARGIN = 8;
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: Number(props.minPageSize) || 10,
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
? Number(explicitCount)
: Array.isArray(props.items)
? props.items.length
: 0;
}
componentDidMount() {
@@ -79,47 +105,168 @@ class FillSpace extends Component {
if (prevProps.renderItem !== this.props.renderItem) {
this._probeMeasure();
}
if (prevProps.minPageSize !== this.props.minPageSize) {
this.setState({ pageSize: Number(this.props.minPageSize) || 10 });
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.items !== this.props.items ||
prevProps.header !== this.props.header
) {
this._observe();
this._viewportMeasure();
}
const explicitCount = this.props.unfilteredCount ?? this.props.totalCount;
if (explicitCount != null) {
this.maxSeenCount = Number(explicitCount);
} else if (Array.isArray(this.props.items)) {
this.maxSeenCount = Math.max(this.maxSeenCount || 0, this.props.items.length);
}
// Clamp current page when the item list shrinks (e.g. brand deleted).
if (
Array.isArray(this.props.items) &&
this.props.items !== prevProps.items
) {
const explicitTotal = this.props.unfilteredCount ?? this.props.totalCount;
const total =
explicitTotal != null ? Number(explicitTotal) : this.props.items.length;
const pageCount = Math.max(
1,
Math.ceil(this.props.items.length / Math.max(1, this.state.pageSize)),
Math.ceil(total / Math.max(1, this.state.pageSize)),
);
if (this.state.currentPage > pageCount) {
this.setState({ currentPage: pageCount });
this.props.onPageChange?.(pageCount);
this.props.onPageChange?.(pageCount, {
pageCount,
pageSize: this.state.pageSize,
total,
});
}
}
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(() => {
@@ -163,9 +310,25 @@ 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 h = this.probeRef?.offsetHeight;
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());
}
@@ -197,23 +360,61 @@ class FillSpace extends Component {
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 nextPageSize =
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 (
available !== this.state.viewportHeight ||
nextViewportHeight !== this.state.viewportHeight ||
nextPageSize !== this.state.pageSize
) {
this.setState({ viewportHeight: available, pageSize: nextPageSize });
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 } = this.props;
const { items, totalCount, unfilteredCount } = this.props;
if (!Array.isArray(items)) return;
if (this.ref && event.target && !this.ref.contains(event.target)) return;
const { pageSize, currentPage } = this.state;
const pageCount = Math.max(1, Math.ceil(items.length / Math.max(1, pageSize)));
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : 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;
@@ -231,13 +432,29 @@ class FillSpace extends Component {
};
_goToPage = (page) => {
const { items, onPageChange } = this.props;
const total = Array.isArray(items) ? items.length : 0;
const { items, onPageChange, totalCount, unfilteredCount, virtualScrollbar } = 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, 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);
onPageChange?.(next, { pageCount, pageSize: this.state.pageSize, total });
};
render() {
@@ -250,11 +467,19 @@ class FillSpace extends Component {
minHeight,
maxHeight,
minPageSize,
maxPageSize,
totalCount,
unfilteredCount,
listRef,
onPageChange,
onWheel,
virtualScrollbar = false,
racewayMarginBottom,
racewayRight,
debug = false,
items,
renderItem,
header,
sx,
...rest
} = this.props;
@@ -262,130 +487,262 @@ 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 total = list.length;
const explicitTotal = unfilteredCount ?? totalCount;
const total = explicitTotal != null ? Number(explicitTotal) : list.length;
const fullTotal = 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 showPagination = total > 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);
const racewayRightOffset = racewayRight != null ? racewayRight : -(RACEWAY_WIDTH - 8);
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);
return (
<>
<Box
// 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' }}
>
{needsPaginationContainer ? (
<Box
aria-hidden={!showPagination || undefined}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
gap: 1,
width: '100%',
height: 44,
visibility: showPagination ? 'visible' : 'hidden',
pointerEvents: showPagination ? 'auto' : 'none',
}}
// Fixed single-line bar: the header height must not depend on the
// filtered count so the content never shifts when filtering.
sx={{ mb: 1, height: 44, overflow: 'hidden' }}
>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{total === 0 ? '0' : `${offset + 1}–${Math.min(offset + pageSize, total)}`} von{' '}
{total}
</Typography>
<Pagination
count={pageCount}
page={safePage}
onChange={(_e, page) => this._goToPage(page)}
color="primary"
size="large"
onMouseDown={(e) => e.preventDefault()}
<Box
aria-hidden={!showPaginationControls || undefined}
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',
},
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
gap: 1,
width: '100%',
height: 44,
visibility: showPaginationControls ? 'visible' : 'hidden',
pointerEvents: showPaginationControls ? 'auto' : 'none',
}}
/>
>
<Typography
variant="body2"
color="text.secondary"
noWrap
sx={{ flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{total === 0 ? '0' : `${countStart}–${countEnd}`} von {total}
</Typography>
<Pagination
count={pageCount}
page={safePage}
onChange={(_e, page) => 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',
},
}}
/>
</Box>
</Box>
</Box>
) : null}
{header}
<Box
ref={(el) => {
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,
]}
>
<Box sx={{ width: '100%' }}>
<List dense disablePadding sx={{ width: '100%' }}>
{pageItems.map((item, i) => (
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}>
{renderRowElement(renderItem, item, offset + i)}
</Box>
))}
</List>
</Box>
{probe != null ? (
<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
ref={(el) => {
this.probeRef = el;
}}
aria-hidden
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
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,
}
: {}),
}}
>
<List dense disablePadding sx={{ width: '100%' }}>
<Box data-fill-row sx={{ width: '100%' }}>
{probe}
</Box>
<List ref={listRef} dense disablePadding sx={{ width: '100%' }}>
{renderedItems.map((item, i) => (
<Box key={item?.id ?? itemOffset + i} data-fill-row sx={{ width: '100%' }}>
{renderRowElement(renderItem, item, itemOffset + i)}
</Box>
))}
</List>
</Box>
{probe != null ? (
<Box
ref={(el) => {
this.probeRef = el;
}}
aria-hidden
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<List dense disablePadding sx={{ width: '100%' }}>
<Box data-fill-row sx={{ width: '100%' }}>
{probe}
</Box>
</List>
</Box>
) : null}
</Box>
{canScroll ? (
<Box
ref={(el) => {
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),
},
}}
>
<Box
ref={(el) => {
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),
},
}}
/>
</Box>
) : null}
</Box>
{debug ? (

View File

@@ -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 {
</Box>
</Paper>
{/* Example 4: Virtual Scrollbar */}
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Virtual Scrollbar (smooth continuous scroll with scrollbar)
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box
ref={this.exVirtual.header}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
>
<Typography variant="caption">Header</Typography>
</Box>
<FillSpace
aboveRef={this.exVirtual.header}
belowRef={this.exVirtual.footer}
items={SAMPLE_ITEMS}
renderItem={Row}
minPageSize={5}
virtualScrollbar
/>
<Box
ref={this.exVirtual.footer}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
>
<Typography variant="caption">Footer</Typography>
</Box>
</Box>
</Paper>
<ComponentDocs name="FillSpace" />
</Box>
);