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 {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 {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 {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} [minHeight=0] - Minimum spacer height in px.
|
||||||
* @prop {number} [maxHeight] - Optional maximum 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.
|
* @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 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 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 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 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.
|
* @behaviour `debug` draws a dashed primary outline around the spacer plus a translucent full-viewport overlay.
|
||||||
*/
|
*/
|
||||||
class FillSpace extends Component {
|
class FillSpace extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(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.ref = null;
|
||||||
this.contentRef = null;
|
this.contentRef = null;
|
||||||
this.unitRef = null;
|
this.unitRef = null;
|
||||||
@@ -102,7 +111,14 @@ class FillSpace extends Component {
|
|||||||
_measure() {
|
_measure() {
|
||||||
if (!this.ref) return;
|
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
|
// Distance from the document top to the spacer top — includes every
|
||||||
// element above the spacer plus their margins and the parent's padding.
|
// 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;
|
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 (
|
if (
|
||||||
Math.abs(clamped - this.state.height) > 0.5 ||
|
Math.abs(clamped - this.state.height) > 0.5 ||
|
||||||
Math.abs(unitHeight - this.state.unitHeight) > 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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
|
aboveRef,
|
||||||
|
belowRef,
|
||||||
|
onMeasurement,
|
||||||
minHeight = 0,
|
minHeight = 0,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
debug = false,
|
debug = false,
|
||||||
@@ -161,21 +207,15 @@ class FillSpace extends Component {
|
|||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const { height, unitHeight, contentHeight } = this.state;
|
const { height, fillCount } = this.state;
|
||||||
|
|
||||||
// Repeated measurement units fill only the leftover space below the real
|
// Repeated measurement units fill only the leftover space below the real
|
||||||
// children. Count starts at 0: if the content already fills the space,
|
// children. fillCount (computed in _measure) starts at 0: if the content
|
||||||
// no units are rendered.
|
// already fills the space, no units are rendered.
|
||||||
let fill = null;
|
let fill = null;
|
||||||
if (measureElement != 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 = [];
|
const items = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < fillCount; i++) {
|
||||||
items.push(
|
items.push(
|
||||||
<Box key={i} sx={{ width: '100%' }}>
|
<Box key={i} sx={{ width: '100%' }}>
|
||||||
{measureElement}
|
{measureElement}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ class FillSpaceMockPage extends Component {
|
|||||||
this.ex1 = { header: createRef(), footer: createRef() };
|
this.ex1 = { header: createRef(), footer: createRef() };
|
||||||
this.ex2 = { header: createRef(), footer: createRef() };
|
this.ex2 = { header: createRef(), footer: createRef() };
|
||||||
this.ex3 = { header: createRef(), footer: createRef() };
|
this.ex3 = { header: createRef(), footer: createRef() };
|
||||||
|
this.state = { fitCount: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { fitCount } = this.state;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
@@ -54,6 +56,7 @@ class FillSpaceMockPage extends Component {
|
|||||||
<Typography variant="body2">Einheit</Typography>
|
<Typography variant="body2">Einheit</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
|
onMeasurement={(count) => this.setState({ fitCount: count })}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -69,6 +72,29 @@ class FillSpaceMockPage extends Component {
|
|||||||
Diese Zeilen stehen oben, darunter wiederholt sich die
|
Diese Zeilen stehen oben, darunter wiederholt sich die
|
||||||
Einheit bis zum Seitenende.
|
Einheit bis zum Seitenende.
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
</FillSpace>
|
</FillSpace>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user