fix(FillSpace): prevent aboveRef, belowRef, and onMeasurement from leaking to DOM

This commit is contained in:
seb
2026-09-16 10:06:05 +02:00
parent 07a3809f1b
commit 8ddca02e54
2 changed files with 80 additions and 14 deletions

View File

@@ -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(
<Box key={i} sx={{ width: '100%' }}>
{measureElement}

View File

@@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="body2" color="text.secondary">
@@ -54,6 +56,7 @@ class FillSpaceMockPage extends Component {
<Typography variant="body2">Einheit</Typography>
</Box>
}
onMeasurement={(count) => this.setState({ fitCount: count })}
>
<Box
sx={{
@@ -69,6 +72,29 @@ class FillSpaceMockPage extends Component {
Diese Zeilen stehen oben, darunter wiederholt sich die
Einheit bis zum Seitenende.
</Typography>
<Box
sx={{
mt: 1,
px: 1.5,
py: 0.75,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px dashed',
borderColor: 'divider',
}}
>
<Typography variant="caption" component="span">
📏 onMeasurement (würden ohne Inhalt passen):&nbsp;
</Typography>
<Typography
variant="caption"
component="span"
fontWeight={700}
color="primary"
>
{fitCount != null ? `${fitCount} Einheiten` : '…'}
</Typography>
</Box>
</Box>
</FillSpace>