Adds a reusable FillSpace component to dashgui that fills the remaining viewport height between an aboveRef and optional belowRef, with a scrollable middle section. Supports an optional measureElement that is repeated top-to-bottom to fill the leftover space below children (rows are rendered first, units fill height - contentHeight). Includes mock page, docs wiring, and screenshot references.
269 lines
8.9 KiB
JavaScript
269 lines
8.9 KiB
JavaScript
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(
|
||
<Box key={i} sx={{ width: '100%' }}>
|
||
{measureElement}
|
||
</Box>,
|
||
);
|
||
}
|
||
fill = (
|
||
<Box
|
||
sx={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: measureGap,
|
||
width: '100%',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{items}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Box
|
||
ref={(el) => {
|
||
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 ? (
|
||
<Box
|
||
ref={(el) => {
|
||
this.contentRef = el;
|
||
}}
|
||
sx={{ width: '100%' }}
|
||
>
|
||
{children}
|
||
</Box>
|
||
) : null}
|
||
{fill}
|
||
{measureElement != null ? (
|
||
<Box
|
||
ref={(el) => {
|
||
this.unitRef = el;
|
||
}}
|
||
aria-hidden
|
||
sx={{
|
||
position: 'absolute',
|
||
left: 0,
|
||
top: 0,
|
||
width: '100%',
|
||
visibility: 'hidden',
|
||
pointerEvents: 'none',
|
||
}}
|
||
>
|
||
{measureElement}
|
||
</Box>
|
||
) : null}
|
||
</Box>
|
||
{debug ? (
|
||
<Box
|
||
sx={(theme) => ({
|
||
position: 'fixed',
|
||
inset: 0,
|
||
pointerEvents: 'none',
|
||
background: alpha(theme.palette.primary.main, 0.08),
|
||
zIndex: 9999,
|
||
})}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
}
|
||
|
||
export default FillSpace;
|