fix(FillSpace): prevent aboveRef, belowRef, and onMeasurement from leaking to DOM
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="span"
|
||||
fontWeight={700}
|
||||
color="primary"
|
||||
>
|
||||
{fitCount != null ? `${fitCount} Einheiten` : '…'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</FillSpace>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user