From f997a954ee3fcc6f50359e749ffd5d503511c496 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 9 Oct 2026 22:37:07 +0200 Subject: [PATCH] u --- src/FillSpace.jsx | 110 ++++++++++++++++------------------------------ 1 file changed, 39 insertions(+), 71 deletions(-) diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx index 9fc2b11..afadf94 100644 --- a/src/FillSpace.jsx +++ b/src/FillSpace.jsx @@ -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 ``) 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) => ({