u
This commit is contained in:
@@ -6,32 +6,20 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { alpha } from '@mui/material/styles';
|
import { alpha } from '@mui/material/styles';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FillSpace — fills remaining viewport height; paginates `items`.
|
* FillSpace — paginates `items`.
|
||||||
*
|
*
|
||||||
* @component FillSpace
|
* @component FillSpace
|
||||||
* @import dashgui/FillSpace
|
* @import dashgui/FillSpace
|
||||||
* @defaultExport
|
* @defaultExport
|
||||||
*
|
*
|
||||||
* @prop {React.Ref} [aboveRef] - Element(s) above; re-measure when they resize.
|
* @prop {Array} items - Wheel flips pages.
|
||||||
* @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} [minHeight=0] - Minimum height in px.
|
|
||||||
* @prop {Array} items - 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] - 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 {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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Minimum spacer height so at least `count` units fit (lets the page scroll on small windows). */
|
|
||||||
function minHeightFromCount(count, unitHeight) {
|
|
||||||
const n = Number(count) || 0;
|
|
||||||
if (n <= 0 || !(unitHeight > 0)) return 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. */
|
||||||
function isRowComponent(renderItem) {
|
function isRowComponent(renderItem) {
|
||||||
return (
|
return (
|
||||||
@@ -68,46 +56,41 @@ class FillSpace extends Component {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
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,
|
unitHeight: 0,
|
||||||
// Page size comes from the fit count.
|
viewportHeight: null,
|
||||||
pageSize: Number(props.minPageSize) || 10,
|
pageSize: Number(props.minPageSize) || 10,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
measured: false,
|
|
||||||
};
|
};
|
||||||
this.ref = null;
|
this.ref = null;
|
||||||
this.probeRef = null;
|
this.probeRef = null;
|
||||||
this.paginationRef = null;
|
|
||||||
this.wheelAcc = 0;
|
this.wheelAcc = 0;
|
||||||
this.observer = null;
|
this.observer = null;
|
||||||
this.raf = null;
|
this.raf = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
// Measure synchronously (no rAF): setState here is batched before the
|
this._probeMeasure();
|
||||||
// browser paints, so the first visible frame already has the full
|
this._viewportMeasure();
|
||||||
// viewport height — no small-box → expanded-box layout shift.
|
|
||||||
this._measure();
|
|
||||||
this._observe();
|
this._observe();
|
||||||
window.addEventListener('resize', this._onViewportChange);
|
window.addEventListener('resize', this._onViewportChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.renderItem !== this.props.renderItem) {
|
||||||
|
this._probeMeasure();
|
||||||
|
}
|
||||||
|
if (prevProps.minPageSize !== this.props.minPageSize) {
|
||||||
|
this.setState({ pageSize: Number(this.props.minPageSize) || 10 });
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
prevProps.aboveRef !== this.props.aboveRef ||
|
prevProps.aboveRef !== this.props.aboveRef ||
|
||||||
prevProps.belowRef !== this.props.belowRef ||
|
prevProps.belowRef !== this.props.belowRef ||
|
||||||
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.items !== this.props.items
|
||||||
prevProps.minPageSize !== this.props.minPageSize ||
|
|
||||||
prevProps.maxHeight !== this.props.maxHeight ||
|
|
||||||
prevProps.items !== this.props.items ||
|
|
||||||
prevProps.renderItem !== this.props.renderItem
|
|
||||||
) {
|
) {
|
||||||
this._observe();
|
this._observe();
|
||||||
this._measure();
|
this._viewportMeasure();
|
||||||
}
|
}
|
||||||
// Clamp current page when the item list shrinks (e.g. brand deleted).
|
// Clamp current page when the item list shrinks (e.g. brand deleted).
|
||||||
if (
|
if (
|
||||||
@@ -141,8 +124,7 @@ class FillSpace extends Component {
|
|||||||
if (this.raf) return;
|
if (this.raf) return;
|
||||||
this.raf = requestAnimationFrame(() => {
|
this.raf = requestAnimationFrame(() => {
|
||||||
this.raf = null;
|
this.raf = null;
|
||||||
console.log('vp')
|
this._viewportMeasure();
|
||||||
this._measure();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,18 +132,16 @@ class FillSpace extends Component {
|
|||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.observer = new ResizeObserver(() => this._onViewportChange());
|
this.observer = new ResizeObserver(() => this._onViewportChange());
|
||||||
|
|
||||||
const { aboveRef, belowRef, containerRef } = this.props;
|
const { aboveRef, belowRef, containerRef } = this.props;
|
||||||
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;
|
||||||
const targets = [
|
const targets = [
|
||||||
...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,
|
||||||
this.paginationRef,
|
|
||||||
];
|
];
|
||||||
for (const el of targets) {
|
for (const el of targets) {
|
||||||
if (el && el.offsetParent !== null) {
|
if (el && el.offsetParent !== null) {
|
||||||
@@ -170,87 +150,61 @@ class FillSpace extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_measure() {
|
probeMeasure = () => {
|
||||||
|
this._probeMeasure();
|
||||||
|
};
|
||||||
|
|
||||||
|
viewportMeasure = () => {
|
||||||
|
this._viewportMeasure();
|
||||||
|
};
|
||||||
|
|
||||||
|
measure = () => {
|
||||||
|
this._probeMeasure();
|
||||||
|
this._viewportMeasure();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only measures the height of the `renderItem` probe. Nothing else. */
|
||||||
|
_probeMeasure() {
|
||||||
|
const h = this.probeRef?.offsetHeight;
|
||||||
|
if (h > 0 && h !== this.state.unitHeight) {
|
||||||
|
this.setState({ unitHeight: h }, () => this._viewportMeasure());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Viewport height minus above/below refs: free space for the list. */
|
||||||
|
_viewportMeasure() {
|
||||||
if (!this.ref) return;
|
if (!this.ref) return;
|
||||||
|
const { belowRef, containerRef, reserveBottom = 0, minPageSize } = this.props;
|
||||||
const {
|
|
||||||
belowRef,
|
|
||||||
containerRef,
|
|
||||||
reserveBottom = 0,
|
|
||||||
minHeight = 0,
|
|
||||||
minPageSize,
|
|
||||||
maxHeight,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
// Spacer top, document-relative: scrolling the page does not change the height.
|
|
||||||
const rect = this.ref.getBoundingClientRect();
|
const rect = this.ref.getBoundingClientRect();
|
||||||
const abovePx = rect.top + window.scrollY;
|
let belowPx = Number(reserveBottom) || 0;
|
||||||
|
|
||||||
// Footer below (box + top margin) plus extra trailing space.
|
|
||||||
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
|
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
|
||||||
let belowPx = 0;
|
|
||||||
for (const ref of belowList) {
|
for (const ref of belowList) {
|
||||||
const belowEl = ref?.current;
|
const el = ref && 'current' in ref ? ref.current : ref;
|
||||||
if (belowEl && belowEl.offsetParent !== null) {
|
if (el && el.offsetParent !== null) {
|
||||||
belowPx += belowEl.offsetHeight;
|
belowPx += el.offsetHeight;
|
||||||
const marginTop = parseFloat(window.getComputedStyle(belowEl).marginTop);
|
const mt = parseFloat(window.getComputedStyle(el).marginTop);
|
||||||
belowPx += Number.isFinite(marginTop) ? marginTop : 0;
|
if (Number.isFinite(mt)) belowPx += mt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Extra trailing space (app-shell padding, tile padding) that must stay visible.
|
const containerEl =
|
||||||
belowPx += Number(reserveBottom) || 0;
|
containerRef && 'current' in containerRef ? containerRef.current : containerRef;
|
||||||
|
|
||||||
const containerEl = containerRef && 'current' in containerRef ? containerRef.current : containerRef;
|
|
||||||
let available;
|
let available;
|
||||||
if (containerEl && typeof containerEl.getBoundingClientRect === 'function') {
|
if (containerEl && typeof containerEl.getBoundingClientRect === 'function') {
|
||||||
const cRect = containerEl.getBoundingClientRect();
|
const cRect = containerEl.getBoundingClientRect();
|
||||||
const paddingBottom = parseFloat(window.getComputedStyle(containerEl).paddingBottom) || 0;
|
const paddingBottom = parseFloat(window.getComputedStyle(containerEl).paddingBottom) || 0;
|
||||||
available = Math.max(0, cRect.bottom - rect.top - belowPx - paddingBottom);
|
available = Math.max(0, cRect.bottom - rect.top - belowPx - paddingBottom);
|
||||||
} else {
|
} else {
|
||||||
available = Math.max(0, window.innerHeight - abovePx - belowPx);
|
available = Math.max(0, window.innerHeight - rect.top - belowPx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamped = Math.min(
|
|
||||||
Math.max(available, minHeight),
|
|
||||||
maxHeight ?? Infinity,
|
|
||||||
);
|
|
||||||
|
|
||||||
let unitHeight = this.state.unitHeight;
|
|
||||||
const probeH = this.probeRef?.offsetHeight;
|
|
||||||
if (probeH > 0) unitHeight = probeH;
|
|
||||||
const minSize = Math.max(1, Number(minPageSize) || 10);
|
const minSize = Math.max(1, Number(minPageSize) || 10);
|
||||||
const minHeightForCount = minHeightFromCount(minSize, unitHeight);
|
const { unitHeight } = this.state;
|
||||||
const finalHeight = Math.min(
|
const nextPageSize =
|
||||||
Math.max(clamped, minHeightForCount),
|
unitHeight > 0 ? Math.max(minSize, Math.floor(available / unitHeight)) : minSize;
|
||||||
maxHeight ?? Infinity,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fitting unit count drives page size.
|
|
||||||
let totalCount = 0;
|
|
||||||
if (unitHeight > 0) {
|
|
||||||
totalCount = Math.floor(finalHeight / unitHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fit count drives page size (min minPageSize).
|
|
||||||
let nextPageSize = this.state.pageSize;
|
|
||||||
let measured = this.state.measured;
|
|
||||||
if (totalCount > 0) {
|
|
||||||
nextPageSize = Math.max(minSize, totalCount);
|
|
||||||
measured = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Math.abs(finalHeight - this.state.height) > 0.5 ||
|
available !== this.state.viewportHeight ||
|
||||||
Math.abs(unitHeight - this.state.unitHeight) > 0.5 ||
|
nextPageSize !== this.state.pageSize
|
||||||
nextPageSize !== this.state.pageSize ||
|
|
||||||
measured !== this.state.measured
|
|
||||||
) {
|
) {
|
||||||
this.setState({
|
this.setState({ viewportHeight: available, pageSize: nextPageSize });
|
||||||
height: finalHeight,
|
|
||||||
unitHeight,
|
|
||||||
pageSize: nextPageSize,
|
|
||||||
measured,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,15 +241,16 @@ class FillSpace extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
|
// Discarded legacy layout props (kept here so they don't leak to the DOM).
|
||||||
aboveRef,
|
aboveRef,
|
||||||
belowRef,
|
belowRef,
|
||||||
containerRef,
|
containerRef,
|
||||||
reserveBottom,
|
reserveBottom,
|
||||||
onWheel,
|
minHeight,
|
||||||
onPageChange,
|
|
||||||
minHeight = 0,
|
|
||||||
minPageSize,
|
|
||||||
maxHeight,
|
maxHeight,
|
||||||
|
minPageSize,
|
||||||
|
onPageChange,
|
||||||
|
onWheel,
|
||||||
debug = false,
|
debug = false,
|
||||||
items,
|
items,
|
||||||
renderItem,
|
renderItem,
|
||||||
@@ -303,27 +258,22 @@ class FillSpace extends Component {
|
|||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
// FillSpace owns page size (from the rendered row height), current page,
|
// FillSpace owns 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).
|
||||||
const { height, pageSize, currentPage, measured } = this.state;
|
const { pageSize, currentPage, viewportHeight } = 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)));
|
||||||
const safePage = Math.min(Math.max(1, currentPage), pageCount);
|
const safePage = Math.min(Math.max(1, currentPage), pageCount);
|
||||||
const offset = (safePage - 1) * pageSize;
|
const offset = (safePage - 1) * pageSize;
|
||||||
const pageItems = items.slice(offset, offset + pageSize);
|
const pageItems = items.slice(offset, offset + pageSize);
|
||||||
// Hidden until the fit is measured — no "1–10" flash on load.
|
const showPagination = total > pageSize;
|
||||||
const showPagination = measured && total > pageSize;
|
// Hidden data-free probe: measures `renderItem` itself (no data).
|
||||||
// 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);
|
const probe = renderProbeElement(renderItem);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
ref={(el) => {
|
|
||||||
this.paginationRef = el;
|
|
||||||
}}
|
|
||||||
// Fixed single-line bar: the header height must not depend on the
|
// Fixed single-line bar: the header height must not depend on the
|
||||||
// page count. Otherwise the first fit (e.g. "1-17") pushes the
|
// page count. Otherwise the first fit (e.g. "1-17") pushes the
|
||||||
// spacer down, the available height shrinks, and a second pass
|
// spacer down, the available height shrinks, and a second pass
|
||||||
@@ -389,9 +339,7 @@ class FillSpace extends Component {
|
|||||||
sx={[
|
sx={[
|
||||||
{
|
{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
height: height ?? 'auto',
|
height: viewportHeight ?? 'auto',
|
||||||
minHeight,
|
|
||||||
...(maxHeight != null ? { maxHeight } : {}),
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
// Keep wheel ticks from scrolling the page behind the list.
|
// Keep wheel ticks from scrolling the page behind the list.
|
||||||
|
|||||||
Reference in New Issue
Block a user