Files
dashgui/src/FillSpace.jsx
seb 5f676ca43f feat: add FillSpace viewport-height spacer with repeating measureElement
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.
2026-08-12 02:42:38 +02:00

269 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;