Files
dashgui/src/FillSpace.jsx
2026-10-09 23:27:49 +02:00

656 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `<Row brand item index />`, callbacks are called. */
function renderRowElement(renderItem, item, index) {
if (isRowComponent(renderItem)) {
const Row = renderItem;
return <Row brand={item} item={item} index={index} />;
}
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 <Row brand={undefined} item={undefined} index={0} disabled />;
}
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(
<Box key={i} sx={{ width: '100%' }}>
{measureElement}
</Box>,
);
}
fill = (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: measureGap,
width: '100%',
overflow: 'hidden',
}}
>
{fillItems}
</Box>
);
}
return (
<>
<Box
ref={(el) => {
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 ? (
<Box
ref={(el) => {
this.contentRef = el;
}}
sx={{ width: '100%', ...(measureElement == null ? { height: '100%' } : {}) }}
>
{children}
</Box>
) : null}
{fill}
{measureElement != null ? (
<Box
ref={(el) => {
this.unitRef = el;
}}
aria-hidden
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
visibility: 'hidden',
pointerEvents: 'none',
}}
>
{measureElement}
</Box>
) : null}
</Box>
{debug ? (
<Box
sx={(theme) => ({
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 (
<>
<Box
ref={(el) => {
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' }}
>
<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',
}}
>
<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()}
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
ref={(el) => {
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,
]}
>
<Box
ref={(el) => {
this.contentRef = el;
}}
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.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>
{debug ? (
<Box
sx={(theme) => ({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
background: alpha(theme.palette.primary.main, 0.08),
zIndex: 9999,
})}
/>
) : null}
</>
);
}
}
export default FillSpace;