u
This commit is contained in:
@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { alpha } from '@mui/material/styles';
|
import { alpha } from '@mui/material/styles';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FillSpace — fills remaining viewport height; optionally paginates `items`.
|
* FillSpace — fills remaining viewport height; paginates `items`.
|
||||||
*
|
*
|
||||||
* @component FillSpace
|
* @component FillSpace
|
||||||
* @import dashgui/FillSpace
|
* @import dashgui/FillSpace
|
||||||
@@ -15,26 +15,21 @@ import { alpha } from '@mui/material/styles';
|
|||||||
* @prop {React.Ref} [aboveRef] - Element(s) above; re-measure when they resize.
|
* @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 {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 {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} [minHeight=0] - Minimum height in px.
|
||||||
* @prop {number} [minCount=0] - Minimum units that must fit (page scrolls instead of clipping).
|
* @prop {Array} items - Page size is measured from row height, wheel flips pages.
|
||||||
* @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 {React.ComponentType|Function} [renderItem] - Row renderer (component or callback).
|
|
||||||
* @prop {number} [minPageSize=10] - Minimum rows per page.
|
* @prop {number} [minPageSize=10] - Minimum rows per page.
|
||||||
* @prop {(page: number) => void} [onPageChange] - Called on page change.
|
* @prop {(page: number) => void} [onPageChange] - Called on page change.
|
||||||
* @prop {number} [maxHeight] - Maximum height in px.
|
* @prop {number} [maxHeight] - Maximum height in px.
|
||||||
* @prop {boolean} [debug=false] - Dashed outline for layout debugging.
|
* @prop {boolean} [debug=false] - Dashed outline for layout debugging.
|
||||||
* @prop {ReactNode} [children] - Legacy mode content rendered on top.
|
|
||||||
* @prop {object} [sx] - Extra styles.
|
* @prop {object} [sx] - Extra styles.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Minimum spacer height so at least `count` units fit (lets the page scroll on small windows). */
|
/** Minimum spacer height so at least `count` units fit (lets the page scroll on small windows). */
|
||||||
function minHeightFromCount(count, unitHeight, gap) {
|
function minHeightFromCount(count, unitHeight) {
|
||||||
const n = Number(count) || 0;
|
const n = Number(count) || 0;
|
||||||
if (n <= 0 || !(unitHeight > 0)) return 0;
|
if (n <= 0 || !(unitHeight > 0)) return 0;
|
||||||
return n * unitHeight + Math.max(0, n - 1) * (Number(gap) || 0);
|
return n * unitHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True for class components (e.g. `renderItem={ArtikelMarke}`). Rendered as element, not called. */
|
/** True for class components (e.g. `renderItem={ArtikelMarke}`). Rendered as element, not called. */
|
||||||
@@ -77,18 +72,12 @@ class FillSpace extends Component {
|
|||||||
// first visible frame is already full-size, no 0 → expanded jump.
|
// first visible frame is already full-size, no 0 → expanded jump.
|
||||||
height: null,
|
height: null,
|
||||||
unitHeight: 0,
|
unitHeight: 0,
|
||||||
contentHeight: 0,
|
// Page size comes from the fit count.
|
||||||
totalCount: 0,
|
pageSize: Number(props.minPageSize) || 10,
|
||||||
fillCount: 0,
|
|
||||||
// Paginated mode (`items`): page size comes from the fit count.
|
|
||||||
pageSize: Number(props.minPageSize ?? props.minCount) || 10,
|
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
measured: false,
|
measured: false,
|
||||||
};
|
};
|
||||||
this.lastTotalCount = null;
|
|
||||||
this.ref = null;
|
this.ref = null;
|
||||||
this.contentRef = null;
|
|
||||||
this.unitRef = null;
|
|
||||||
this.probeRef = null;
|
this.probeRef = null;
|
||||||
this.paginationRef = null;
|
this.paginationRef = null;
|
||||||
this.wheelAcc = 0;
|
this.wheelAcc = 0;
|
||||||
@@ -112,12 +101,10 @@ class FillSpace extends Component {
|
|||||||
prevProps.containerRef !== this.props.containerRef ||
|
prevProps.containerRef !== this.props.containerRef ||
|
||||||
prevProps.reserveBottom !== this.props.reserveBottom ||
|
prevProps.reserveBottom !== this.props.reserveBottom ||
|
||||||
prevProps.minHeight !== this.props.minHeight ||
|
prevProps.minHeight !== this.props.minHeight ||
|
||||||
prevProps.minCount !== this.props.minCount ||
|
|
||||||
prevProps.minPageSize !== this.props.minPageSize ||
|
prevProps.minPageSize !== this.props.minPageSize ||
|
||||||
prevProps.maxHeight !== this.props.maxHeight ||
|
prevProps.maxHeight !== this.props.maxHeight ||
|
||||||
prevProps.measureElement !== this.props.measureElement ||
|
prevProps.items !== this.props.items ||
|
||||||
prevProps.measureGap !== this.props.measureGap ||
|
prevProps.renderItem !== this.props.renderItem
|
||||||
prevProps.items !== this.props.items
|
|
||||||
) {
|
) {
|
||||||
this._observe();
|
this._observe();
|
||||||
this._measure();
|
this._measure();
|
||||||
@@ -166,11 +153,10 @@ class FillSpace extends Component {
|
|||||||
this.observer = new ResizeObserver(() => this._onViewportChange());
|
this.observer = new ResizeObserver(() => this._onViewportChange());
|
||||||
|
|
||||||
// Never observe the spacer's own parent: resizing the spacer would resize the parent.
|
// 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
|
// Skip content: every flip swaps all rows, which
|
||||||
// would fire the observer -> _measure -> setState -> render in a loop.
|
// would fire the observer -> _measure -> setState -> render in a loop.
|
||||||
// Unit height is read lazily in _measure, so no observer is needed for it.
|
// Unit height is read lazily in _measure, so no observer is needed for it.
|
||||||
const { aboveRef, belowRef, containerRef, items, renderItem } = this.props;
|
const { aboveRef, belowRef, containerRef } = this.props;
|
||||||
const paginated = Array.isArray(items) && typeof renderItem === 'function';
|
|
||||||
const aboveList = Array.isArray(aboveRef) ? aboveRef : [aboveRef];
|
const aboveList = Array.isArray(aboveRef) ? aboveRef : [aboveRef];
|
||||||
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
|
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
|
||||||
const containerTarget = containerRef && 'current' in containerRef ? containerRef.current : containerRef;
|
const containerTarget = containerRef && 'current' in containerRef ? containerRef.current : containerRef;
|
||||||
@@ -178,7 +164,6 @@ class FillSpace extends Component {
|
|||||||
...aboveList.map((r) => (r && 'current' in r ? r.current : r)),
|
...aboveList.map((r) => (r && 'current' in r ? r.current : r)),
|
||||||
...belowList.map((r) => (r && 'current' in r ? r.current : r)),
|
...belowList.map((r) => (r && 'current' in r ? r.current : r)),
|
||||||
containerTarget,
|
containerTarget,
|
||||||
...(paginated ? [] : [this.contentRef]),
|
|
||||||
this.paginationRef,
|
this.paginationRef,
|
||||||
];
|
];
|
||||||
for (const el of targets) {
|
for (const el of targets) {
|
||||||
@@ -199,15 +184,9 @@ class FillSpace extends Component {
|
|||||||
belowRef,
|
belowRef,
|
||||||
containerRef,
|
containerRef,
|
||||||
reserveBottom = 0,
|
reserveBottom = 0,
|
||||||
measureElement,
|
|
||||||
renderItem,
|
|
||||||
measureGap = 0,
|
|
||||||
minHeight = 0,
|
minHeight = 0,
|
||||||
minCount = 0,
|
|
||||||
minPageSize,
|
minPageSize,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
onMeasurement,
|
|
||||||
items,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
// Spacer top, document-relative: scrolling the page does not change the height.
|
// Spacer top, document-relative: scrolling the page does not change the height.
|
||||||
@@ -243,70 +222,26 @@ class FillSpace extends Component {
|
|||||||
maxHeight ?? Infinity,
|
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;
|
let unitHeight = this.state.unitHeight;
|
||||||
if (paginated) {
|
const probeH = this.probeRef?.offsetHeight;
|
||||||
const probeH = this.probeRef?.offsetHeight;
|
if (probeH > 0) unitHeight = probeH;
|
||||||
if (probeH > 0) {
|
const minSize = Math.max(1, Number(minPageSize) || 10);
|
||||||
unitHeight = probeH;
|
const minHeightForCount = minHeightFromCount(minSize, unitHeight);
|
||||||
} 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(
|
const finalHeight = Math.min(
|
||||||
Math.max(clamped, minHeightForCount),
|
Math.max(clamped, minHeightForCount),
|
||||||
maxHeight ?? Infinity,
|
maxHeight ?? Infinity,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fitting unit count (drives page size) and leftover fill count.
|
// Fitting unit count drives page size.
|
||||||
let totalCount = 0;
|
let totalCount = 0;
|
||||||
let fillCount = 0;
|
if (unitHeight > 0) {
|
||||||
if (unitHeight > 0 && (measureElement != null || paginated)) {
|
totalCount = Math.floor(finalHeight / unitHeight);
|
||||||
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
|
// Fit count drives page size (min minPageSize).
|
||||||
// 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 nextPageSize = this.state.pageSize;
|
||||||
let measured = this.state.measured;
|
let measured = this.state.measured;
|
||||||
if (paginated && totalCount > 0) {
|
if (totalCount > 0) {
|
||||||
nextPageSize = Math.max(minSize, totalCount);
|
nextPageSize = Math.max(minSize, totalCount);
|
||||||
measured = true;
|
measured = true;
|
||||||
}
|
}
|
||||||
@@ -314,18 +249,12 @@ class FillSpace extends Component {
|
|||||||
if (
|
if (
|
||||||
Math.abs(finalHeight - this.state.height) > 0.5 ||
|
Math.abs(finalHeight - this.state.height) > 0.5 ||
|
||||||
Math.abs(unitHeight - this.state.unitHeight) > 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 ||
|
nextPageSize !== this.state.pageSize ||
|
||||||
measured !== this.state.measured
|
measured !== this.state.measured
|
||||||
) {
|
) {
|
||||||
this.setState({
|
this.setState({
|
||||||
height: finalHeight,
|
height: finalHeight,
|
||||||
unitHeight,
|
unitHeight,
|
||||||
contentHeight,
|
|
||||||
totalCount,
|
|
||||||
fillCount,
|
|
||||||
pageSize: nextPageSize,
|
pageSize: nextPageSize,
|
||||||
measured,
|
measured,
|
||||||
});
|
});
|
||||||
@@ -369,136 +298,21 @@ class FillSpace extends Component {
|
|||||||
belowRef,
|
belowRef,
|
||||||
containerRef,
|
containerRef,
|
||||||
reserveBottom,
|
reserveBottom,
|
||||||
onMeasurement,
|
|
||||||
onWheel,
|
onWheel,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
minHeight = 0,
|
minHeight = 0,
|
||||||
minCount,
|
|
||||||
minPageSize,
|
minPageSize,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
debug = false,
|
debug = false,
|
||||||
children,
|
|
||||||
items,
|
items,
|
||||||
renderItem,
|
renderItem,
|
||||||
measureElement,
|
|
||||||
measureGap = 0,
|
|
||||||
sx,
|
sx,
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
// Paginated mode: FillSpace owns page size (from the rendered row
|
// FillSpace owns page size (from the rendered row height), current page,
|
||||||
// height), current page, wheel-pagination and the pagination header.
|
// wheel-pagination and the pagination header. Caller feeds `items` plus
|
||||||
// Caller feeds `items` plus one row element via `renderItem` (component
|
// one row element via `renderItem` (component type or callback).
|
||||||
// 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 { height, pageSize, currentPage, measured } = this.state;
|
||||||
const total = items.length;
|
const total = items.length;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
||||||
@@ -509,7 +323,7 @@ class FillSpace extends Component {
|
|||||||
const showPagination = measured && total > pageSize;
|
const showPagination = measured && total > pageSize;
|
||||||
// Hidden data-free probe: measures `renderItem` itself (no data), so
|
// Hidden data-free probe: measures `renderItem` itself (no data), so
|
||||||
// the first pass already uses the final row height — one pass, no flip.
|
// the first pass already uses the final row height — one pass, no flip.
|
||||||
const probe = renderProbeElement(renderItem) ?? measureElement;
|
const probe = renderProbeElement(renderItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -599,12 +413,7 @@ class FillSpace extends Component {
|
|||||||
sx,
|
sx,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Box
|
<Box sx={{ width: '100%' }}>
|
||||||
ref={(el) => {
|
|
||||||
this.contentRef = el;
|
|
||||||
}}
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
<List dense disablePadding sx={{ width: '100%' }}>
|
<List dense disablePadding sx={{ width: '100%' }}>
|
||||||
{pageItems.map((item, i) => (
|
{pageItems.map((item, i) => (
|
||||||
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}>
|
<Box key={item?.id ?? offset + i} data-fill-row sx={{ width: '100%' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user