import { Component } from 'react'; import Box from '@mui/material/Box'; 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. * * @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} [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 {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. * @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 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.ref = null; this.contentRef = null; this.unitRef = null; this.observer = null; this.raf = null; } componentDidMount() { requestAnimationFrame(() => { this._measure(); this._observe(); }); window.addEventListener('resize', this._onViewportChange); } componentDidUpdate(prevProps) { if ( prevProps.aboveRef !== this.props.aboveRef || prevProps.belowRef !== this.props.belowRef || prevProps.minHeight !== this.props.minHeight || prevProps.maxHeight !== this.props.maxHeight || prevProps.measureElement !== this.props.measureElement || prevProps.measureGap !== this.props.measureGap ) { this._observe(); this._measure(); } } componentWillUnmount() { window.removeEventListener('resize', this._onViewportChange); if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } if (this.observer) { this.observer.disconnect(); this.observer = null; } } _onViewportChange = () => { if (this.raf) return; this.raf = requestAnimationFrame(() => { this.raf = null; this._measure(); }); }; _observe() { if (this.observer) { this.observer.disconnect(); } this.observer = new ResizeObserver(() => this._measure()); // Only observe the referenced elements + the rendered content — never the // spacer's own parent, since resizing the spacer would resize the parent // and create a loop. const { aboveRef, belowRef } = this.props; const targets = [aboveRef?.current, belowRef?.current, this.contentRef]; for (const el of targets) { if (el && el.offsetParent !== null) { this.observer.observe(el); } } } _measure() { if (!this.ref) return; const { belowRef, measureElement, minHeight = 0, maxHeight } = 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"). const rect = this.ref.getBoundingClientRect(); const abovePx = rect.top + window.scrollY; // Height to reserve for the footer below (its box + top margin). const belowEl = belowRef?.current; let belowPx = 0; if (belowEl && belowEl.offsetParent !== null) { belowPx = belowEl.offsetHeight; const marginTop = parseFloat(window.getComputedStyle(belowEl).marginTop); belowPx += Number.isFinite(marginTop) ? marginTop : 0; } const available = Math.max(0, window.innerHeight - abovePx - belowPx); const clamped = Math.min( Math.max(available, minHeight), maxHeight ?? Infinity, ); // Height of the real rendered children (the top part of the spacer). let contentHeight = 0; if (this.contentRef) { contentHeight = this.contentRef.offsetHeight; } // Measure the height of one measurement unit from the hidden copy so the // repeats match the real rendered size. let unitHeight = this.state.unitHeight; if (measureElement != null && this.unitRef) { const h = this.unitRef.offsetHeight; if (h > 0) unitHeight = h; } 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 ) { this.setState({ height: clamped, unitHeight, contentHeight }); } } render() { const { minHeight = 0, maxHeight, debug = false, children, measureElement, measureGap = 0, sx, ...rest } = this.props; const { height, unitHeight, contentHeight } = 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. 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++) { items.push( {measureElement} , ); } fill = ( {items} ); } return ( <> { this.ref = el; }} {...rest} sx={[ { position: 'relative', height, minHeight, ...(maxHeight != null ? { maxHeight } : {}), }, debug ? (theme) => ({ outline: `2px dashed ${theme.palette.primary.main}`, }) : undefined, sx, ]} > {children != null ? ( { this.contentRef = el; }} sx={{ width: '100%' }} > {children} ) : null} {fill} {measureElement != null ? ( { this.unitRef = el; }} aria-hidden sx={{ position: 'absolute', left: 0, top: 0, width: '100%', visibility: 'hidden', pointerEvents: 'none', }} > {measureElement} ) : null} {debug ? ( ({ position: 'fixed', inset: 0, pointerEvents: 'none', background: alpha(theme.palette.primary.main, 0.08), zIndex: 9999, })} /> ) : null} ); } } export default FillSpace;