From 8ddca02e54298dd8d9b82a1e99ed583736b1a1ae Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 16 Sep 2026 10:06:05 +0200 Subject: [PATCH] fix(FillSpace): prevent aboveRef, belowRef, and onMeasurement from leaking to DOM --- src/FillSpace.jsx | 68 +++++++++++++++++++----- src/dev/mock-pages/FillSpaceMockPage.jsx | 26 +++++++++ 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx index 7001b1a..84e7de3 100644 --- a/src/FillSpace.jsx +++ b/src/FillSpace.jsx @@ -15,6 +15,7 @@ import { alpha } from '@mui/material/styles'; * @prop {React.Ref} [belowRef] - Ref to the footer element below. Its height (plus top margin) is reserved so the footer stays visible without page scrolling. * @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} [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. @@ -24,13 +25,21 @@ import { alpha } from '@mui/material/styles'; * @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. */ class FillSpace extends Component { constructor(props) { super(props); - this.state = { height: 0, unitHeight: 0, contentHeight: 0 }; + this.state = { + height: 0, + unitHeight: 0, + contentHeight: 0, + totalCount: 0, + fillCount: 0, + }; + this.lastTotalCount = null; this.ref = null; this.contentRef = null; this.unitRef = null; @@ -102,7 +111,14 @@ class FillSpace extends Component { _measure() { if (!this.ref) return; - const { belowRef, measureElement, minHeight = 0, maxHeight } = this.props; + const { + belowRef, + measureElement, + measureGap = 0, + minHeight = 0, + maxHeight, + onMeasurement, + } = 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. @@ -140,17 +156,47 @@ class FillSpace extends Component { if (h > 0) unitHeight = h; } + // How many units would fit in the WHOLE spacer height, as if there were + // no children — this is what onMeasurement reports. + 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 (measureElement != null && unitHeight > 0) { + totalCount = Math.floor((clamped + measureGap) / (unitHeight + measureGap)); + const fillHeight = Math.max(0, clamped - contentHeight); + 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. + if ( + measureElement != null && + typeof onMeasurement === 'function' && + totalCount !== this.lastTotalCount + ) { + this.lastTotalCount = totalCount; + onMeasurement(totalCount); + } + if ( Math.abs(clamped - this.state.height) > 0.5 || Math.abs(unitHeight - this.state.unitHeight) > 0.5 || - Math.abs(contentHeight - this.state.contentHeight) > 0.5 + Math.abs(contentHeight - this.state.contentHeight) > 0.5 || + totalCount !== this.state.totalCount || + fillCount !== this.state.fillCount ) { - this.setState({ height: clamped, unitHeight, contentHeight }); + this.setState({ height: clamped, unitHeight, contentHeight, totalCount, fillCount }); } } render() { const { + aboveRef, + belowRef, + onMeasurement, minHeight = 0, maxHeight, debug = false, @@ -161,21 +207,15 @@ class FillSpace extends Component { ...rest } = this.props; - const { height, unitHeight, contentHeight } = this.state; + const { height, fillCount } = this.state; // Repeated measurement units fill only the leftover space below the real - // children. Count starts at 0: if the content already fills the space, - // no units are rendered. + // 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 unit = unitHeight > 0 ? unitHeight : 0; - const fillHeight = Math.max(0, height - contentHeight); - const count = - unit > 0 - ? Math.floor((fillHeight + measureGap) / (unit + measureGap)) - : 0; const items = []; - for (let i = 0; i < count; i++) { + for (let i = 0; i < fillCount; i++) { items.push( {measureElement} diff --git a/src/dev/mock-pages/FillSpaceMockPage.jsx b/src/dev/mock-pages/FillSpaceMockPage.jsx index 50f21be..caf8d16 100644 --- a/src/dev/mock-pages/FillSpaceMockPage.jsx +++ b/src/dev/mock-pages/FillSpaceMockPage.jsx @@ -16,9 +16,11 @@ class FillSpaceMockPage extends Component { this.ex1 = { header: createRef(), footer: createRef() }; this.ex2 = { header: createRef(), footer: createRef() }; this.ex3 = { header: createRef(), footer: createRef() }; + this.state = { fitCount: null }; } render() { + const { fitCount } = this.state; return ( @@ -54,6 +56,7 @@ class FillSpaceMockPage extends Component { Einheit } + onMeasurement={(count) => this.setState({ fitCount: count })} > + + + 📏 onMeasurement (würden ohne Inhalt passen):  + + + {fitCount != null ? `${fitCount} Einheiten` : '…'} + +