This commit is contained in:
seb
2026-10-09 22:37:07 +02:00
parent 649239a930
commit f997a954ee

View File

@@ -6,37 +6,28 @@ import Typography from '@mui/material/Typography';
import { alpha } from '@mui/material/styles';
/**
* Vertical spacer that fills the remaining viewport height between the content
* above it and (optionally) a footer below it — useful for creating scrollable
* middle sections in apps with fixed headers/footers.
* FillSpace — fills remaining viewport height; optionally paginates `items`.
*
* @component FillSpace
* @import dashgui/FillSpace
* @defaultExport
*
* @prop {React.Ref} [aboveRef] - Ref to the element immediately above the spacer. It is observed (not strictly required) so the spacer re-measures when that element's height changes.
* @prop {React.Ref | React.Ref[]} [belowRef] - Ref(s) to footer element(s) below. Their heights (plus top margins) are reserved so footers stay visible without page scrolling.
* @prop {number} [reserveBottom=0] - Extra px to reserve below (e.g. app-shell bottom padding, tile padding) so the spacer never overfills the viewport.
* @prop {ReactNode} [measureElement] - When given, this element is used as the measurement unit of the spacer: its height is read from a hidden copy and it is repeated top-to-bottom to fill only the leftover space below `children` (like a repeating ruler/row pattern). When omitted, only `children` are rendered.
* @prop {number} [measureGap=0] - Vertical gap (px) between repeated `measureElement` units.
* @prop {(count: number) => void} [onMeasurement] - Called whenever the number of `measureElement` units that would fit in the whole spacer height changes, as if there were no `children` (i.e. the total row count the space could hold). Fires once after mount with the initial count.
* @prop {number} [minHeight=0] - Minimum spacer height in px.
* @prop {number} [minCount=0] - Minimum number of `measureElement` units the spacer must fit. When the viewport is too small, the spacer grows beyond the viewport so the page scrolls instead of clipping.
* @prop {Array} [items] - When provided with `renderItem`, FillSpace paginates: it measures the page size from the rendered row height, slices `items`, renders the current page as a list, and owns wheel-pagination plus the pagination header. Omit for legacy children mode.
* @prop {React.ComponentType|((item: any, index: number) => ReactNode)} [renderItem] - Row renderer. Either a component type (e.g. `renderItem={ArtikelMarke}`, rendered as `<ArtikelMarke brand={item} item={item} index={index} />`) or a callback `(item, index) => element`. The first rendered row doubles as the measure element, so no separate `measureElement` is needed in paginated mode.
* @prop {number} [minPageSize=10] - Minimum rows per page in paginated mode.
* @prop {(page: number) => void} [onPageChange] - Called when the current page changes (wheel or header).
* @prop {number} [maxHeight] - Optional maximum spacer height in px.
* @prop {boolean} [debug=false] - When true, draws a dashed outline around the spacer and a translucent overlay over the whole viewport so the measured bounds are visible.
* @prop {ReactNode} [children] - Real content rendered at the top of the spacer. The repeated `measureElement` units (if any) fill the remaining space below it.
* @prop {object} [sx] - Extra styles merged into the Box.
*
* @behaviour Fills to the bottom of the initial viewport: height = viewport height − spacer's distance from the document top − reserved below height.
* @behaviour The distance-from-top is read from the spacer's own position (document-relative), so every element above (including their margins and the parent's padding) is accounted for, and scrolling the page does not change the computed height.
* @behaviour When `measureElement` is set, `children` are rendered on top and the units fill only `height − contentHeight`, so no units are shown once the content already fills the space.
* @behaviour `onMeasurement` fires with the count of units that would fit in the whole spacer height (as if `children` were absent) — not the number actually rendered below the content — whenever that count changes.
* @behaviour Re-measures on window resize and whenever the observed refs or the content change size (ResizeObserver), throttled with requestAnimationFrame.
* @behaviour `debug` draws a dashed primary outline around the spacer plus a translucent full-viewport overlay.
* @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). */
@@ -156,11 +147,14 @@ class FillSpace extends Component {
this.observer.disconnect();
}
this.observer = new ResizeObserver(() => this._measure());
this.observer = new ResizeObserver(() => this._onViewportChange());
// Only observe the referenced elements + the rendered content — never the
// spacer's own parent, since resizing the spacer would resize the parent
const { aboveRef, belowRef, containerRef } = this.props;
// 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;
@@ -168,7 +162,7 @@ class FillSpace extends Component {
...aboveList.map((r) => (r && 'current' in r ? r.current : r)),
...belowList.map((r) => (r && 'current' in r ? r.current : r)),
containerTarget,
this.contentRef,
...(paginated ? [] : [this.contentRef]),
this.paginationRef,
];
for (const el of targets) {
@@ -200,14 +194,11 @@ class FillSpace extends Component {
items,
} = this.props;
// Distance from the document top to the spacer top — includes every
// element above the spacer plus their margins and the parent's padding.
// Document-relative (rect.top + scrollY), so scrolling the page does not
// change the computed height (fixes "grows with scrolling").
// Spacer top, document-relative: scrolling the page does not change the height.
const rect = this.ref.getBoundingClientRect();
const abovePx = rect.top + window.scrollY;
// Height to reserve for the footer below (its box + top margin).
// Footer below (box + top margin) plus extra trailing space.
const belowList = Array.isArray(belowRef) ? belowRef : [belowRef];
let belowPx = 0;
for (const ref of belowList) {
@@ -218,8 +209,7 @@ class FillSpace extends Component {
belowPx += Number.isFinite(marginTop) ? marginTop : 0;
}
}
// Extra trailing space (app-shell padding, tile padding) that must stay
// visible below the spacer so the page never scrolls.
// 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;
@@ -237,15 +227,12 @@ class FillSpace extends Component {
maxHeight ?? Infinity,
);
// Height of the real rendered children (the top part of the spacer).
let contentHeight = 0;
if (this.contentRef) {
contentHeight = this.contentRef.offsetHeight;
}
// Unit height: in paginated mode the first rendered row is the measure
// element — no separate hidden sample needed. Legacy children mode
// still uses the hidden `measureElement` copy.
// Paginated: first rendered row is the measure element. Legacy: hidden copy.
const paginated = Array.isArray(items) && typeof renderItem === 'function';
let unitHeight = this.state.unitHeight;
if (paginated) {
@@ -268,12 +255,8 @@ class FillSpace extends Component {
maxHeight ?? Infinity,
);
// How many units fit in the spacer height — drives the page size in
// paginated mode and the `onMeasurement` callback in legacy mode.
// Fitting unit count (drives page size) and leftover fill count.
let totalCount = 0;
// How many units actually fit in the leftover space below the children —
// this is what gets rendered. Starts at 0: if the content already fills
// the space, none fit.
let fillCount = 0;
if (unitHeight > 0 && (measureElement != null || paginated)) {
totalCount = Math.floor((finalHeight + measureGap) / (unitHeight + measureGap));
@@ -281,9 +264,7 @@ class FillSpace extends Component {
fillCount = Math.floor((fillHeight + measureGap) / (unitHeight + measureGap));
}
// Fire onMeasurement with the TOTAL count whenever it changes — including
// the first measurement. lastTotalCount starts as null so the initial
// value (even 0) is always reported once.
// Report total fitting count once per change (legacy mode).
if (
measureElement != null &&
typeof onMeasurement === 'function' &&
@@ -293,8 +274,7 @@ class FillSpace extends Component {
onMeasurement(totalCount);
}
// Paginated mode: fit count drives the page size (min minPageSize).
// Legacy mode only forwards the count via onMeasurement.
// Paginated: fit count drives page size (min minPageSize).
let nextPageSize = this.state.pageSize;
let measured = this.state.measured;
if (paginated && totalCount > 0) {
@@ -324,19 +304,15 @@ class FillSpace extends Component {
}
_handleWheel = (event) => {
const { items, onWheel } = this.props;
if (!Array.isArray(items)) {
onWheel?.(event);
return;
}
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)));
if (pageCount <= 1) return;
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;
// Few pages → long scroll per page (slow); many pages → fast flipping.
const threshold = Math.max(50, 800 / pageCount);
if (Math.abs(this.wheelAcc) < threshold) return;
const dir = this.wheelAcc > 0 ? 1 : -1;
@@ -345,7 +321,6 @@ class FillSpace extends Component {
? Math.min(pageCount, currentPage + 1)
: Math.max(1, currentPage - 1);
if (next === currentPage) return;
event.preventDefault();
this._goToPage(next);
};
@@ -398,12 +373,7 @@ class FillSpace extends Component {
}
const { height, fillCount } = this.state;
// Before the first measurement, let content size naturally (auto)
// instead of forcing height 0 — avoids the small-box flash.
// Repeated measurement units fill only the leftover space below the real
// children. fillCount (computed in _measure) starts at 0: if the content
// already fills the space, no units are rendered.
let fill = null;
if (measureElement != null) {
const fillItems = [];
@@ -497,12 +467,7 @@ class FillSpace extends Component {
);
}
/**
* Paginated mode: pagination header (hidden until measured) + exactly one
* page of `items`, each row rendered via `renderItem`. The first row
* doubles as the measure element. Wheel over the list flips pages;
* effort scales with page count (slow for few pages).
*/
/** Pagination header + one page of rows. Wheel over the list flips pages. */
renderPaginated({ minHeight, maxHeight, debug, measureGap, sx, rest }) {
const { items, renderItem, onWheel } = this.props;
const { height, pageSize, currentPage, measured } = this.state;
@@ -574,6 +539,9 @@ class FillSpace extends Component {
...(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) => ({