diff --git a/README.md b/README.md
index f442e0a..c71f728 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ This package is source-only: consumers import directly from `src/` via the
| `dashgui/tiles` | `src/tiles/` | `Tile`, `TILE_SIZE`, `tileBaseSx` |
| `dashgui/ElevatedPill` | `src/ElevatedPill.jsx` | `ElevatedPill`, `ToggleElevatedPill`, `ActionElevatedPill` |
| `dashgui/SingleLineTextEdit` | `src/SingleLineTextEdit.jsx` | Centralized single-line text field |
+| `dashgui/FillSpace` | `src/FillSpace.jsx` | `FillSpace` — viewport-height spacer between header/footer refs; optional repeating `measureElement` |
## Design rules
diff --git a/package.json b/package.json
index 642bab9..c09889e 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"./tiles": "./src/tiles/index.js",
"./ElevatedPill": "./src/ElevatedPill.jsx",
"./SingleLineTextEdit": "./src/SingleLineTextEdit.jsx",
+ "./FillSpace": "./src/FillSpace.jsx",
"./package.json": "./package.json"
},
"files": [
diff --git a/src/FillSpace.jsx b/src/FillSpace.jsx
new file mode 100644
index 0000000..7001b1a
--- /dev/null
+++ b/src/FillSpace.jsx
@@ -0,0 +1,268 @@
+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;
diff --git a/src/dev/DevApp.jsx b/src/dev/DevApp.jsx
index 8275b51..198ef1c 100644
--- a/src/dev/DevApp.jsx
+++ b/src/dev/DevApp.jsx
@@ -8,6 +8,7 @@ import TilesMockPage from './mock-pages/TilesMockPage';
import PillsMockPage from './mock-pages/PillsMockPage';
import TextFieldsMockPage from './mock-pages/TextFieldsMockPage';
import ListsMockPage from './mock-pages/ListsMockPage';
+import FillSpaceMockPage from './mock-pages/FillSpaceMockPage';
const PAGES = [
{ id: 'panels', label: 'Panels', node: },
@@ -15,6 +16,7 @@ const PAGES = [
{ id: 'tiles', label: 'Tiles', node: },
{ id: 'pills', label: 'Pills', node: },
{ id: 'textfields', label: 'Text fields', node: },
+ { id: 'fillspace', label: 'FillSpace', node: },
];
/**
diff --git a/src/dev/mock-pages/FillSpaceMockPage.jsx b/src/dev/mock-pages/FillSpaceMockPage.jsx
new file mode 100644
index 0000000..50f21be
--- /dev/null
+++ b/src/dev/mock-pages/FillSpaceMockPage.jsx
@@ -0,0 +1,171 @@
+import { Component, createRef } from 'react';
+import Box from '@mui/material/Box';
+import Paper from '@mui/material/Paper';
+import Typography from '@mui/material/Typography';
+import FillSpace from '../../FillSpace';
+import { ComponentDocs } from '../ComponentDocs';
+
+/**
+ * Mock page exercising FillSpace: real content on top, and a repeating
+ * `measureElement` filling only the leftover space down to the end of the
+ * viewport.
+ */
+class FillSpaceMockPage extends Component {
+ constructor(props) {
+ super(props);
+ this.ex1 = { header: createRef(), footer: createRef() };
+ this.ex2 = { header: createRef(), footer: createRef() };
+ this.ex3 = { header: createRef(), footer: createRef() };
+ }
+
+ render() {
+ return (
+
+
+ FillSpace renders real content on top and repeats the measurement
+ element until the end of the page.
+
+
+ {/* Main example: content + repeating fill to end of page */}
+
+
+ Content + repeating element to end of page
+
+
+
+ Header (measured)
+
+
+
+ Einheit
+
+ }
+ >
+
+
+ Echte Inhalte (children)
+
+
+ Diese Zeilen stehen oben, darunter wiederholt sich die
+ Einheit bis zum Seitenende.
+
+
+
+
+
+ Footer (measured)
+
+
+
+
+ {/* Example 2: With minHeight */}
+
+
+ With minHeight constraint
+
+
+
+ Header
+
+
+
+
+
+ Minimum height of 200px enforced.
+
+
+
+
+
+ Footer
+
+
+
+
+ {/* Example 3: Debug mode */}
+
+
+ Debug mode overlay
+
+
+
+ Header
+
+
+
+
+
+ Green background shows actual spacer bounds; purple overlay
+ shows full viewport coverage.
+
+
+
+
+
+ Footer
+
+
+
+
+
+
+ );
+ }
+}
+
+export default FillSpaceMockPage;
diff --git a/src/dev/screenshots/ScreenshotHarness.jsx b/src/dev/screenshots/ScreenshotHarness.jsx
index fbc5cdc..4636e0a 100644
--- a/src/dev/screenshots/ScreenshotHarness.jsx
+++ b/src/dev/screenshots/ScreenshotHarness.jsx
@@ -1,4 +1,4 @@
-import { Component } from 'react';
+import { Component, createRef } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
@@ -15,6 +15,7 @@ import ElevatedPill, {
ToggleElevatedPill,
} from '../../ElevatedPill';
import SingleLineTextEdit from '../../SingleLineTextEdit';
+import FillSpace from '../../FillSpace';
const MOCK_ROWS = [
{
@@ -292,6 +293,128 @@ class ScreenshotHarness extends Component {
color="#e2e8f0"
/>
+
+
+
+
+
+ );
+ }
+}
+
+/**
+ * Deterministic FillSpace example for the screenshot harness. Bound with a
+ * `maxHeight` so the reference is stable regardless of the viewport height.
+ */
+class FillSpaceExample extends Component {
+ constructor(props) {
+ super(props);
+ this.headerRef = createRef();
+ this.footerRef = createRef();
+ }
+
+ render() {
+ return (
+
+
+ Header (gemessen)
+
+
+
+
+
+ FillSpace füllt den Platz zwischen Header und Footer.
+
+
+
+
+
+ Footer (gemessen)
+
+
+ );
+ }
+}
+
+/**
+ * FillSpace with a repeating measurement element plus real children on top,
+ * for the screenshot harness. Bound with maxHeight so the reference is stable.
+ */
+class FillSpaceMeasureExample extends Component {
+ constructor(props) {
+ super(props);
+ this.headerRef = createRef();
+ this.footerRef = createRef();
+ }
+
+ render() {
+ return (
+
+
+ Header
+
+
+
+ Einheit
+
+ }
+ measureGap={8}
+ minHeight={60}
+ maxHeight={260}
+ >
+
+ Echte Inhalte
+
+
+
+
+ Footer
+
);
}
diff --git a/src/index.js b/src/index.js
index 1f01774..2a019e6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,3 +14,4 @@ export * from './lists';
export * from './tiles';
export { default as ElevatedPill } from './ElevatedPill';
export { default as SingleLineTextEdit } from './SingleLineTextEdit';
+export { default as FillSpace } from './FillSpace';
diff --git a/tests/screenshot.spec.js b/tests/screenshot.spec.js
index 9039368..85d8746 100644
--- a/tests/screenshot.spec.js
+++ b/tests/screenshot.spec.js
@@ -21,6 +21,8 @@ const STATIC_SECTIONS = [
'lists-loading',
'lists-failed',
'lists-no-actions',
+ 'fillspace',
+ 'fillspace-measure',
];
test.beforeEach(async ({ page }) => {
diff --git a/tests/screenshots/reference/fillspace-measure.png b/tests/screenshots/reference/fillspace-measure.png
new file mode 100644
index 0000000..d308506
Binary files /dev/null and b/tests/screenshots/reference/fillspace-measure.png differ
diff --git a/tests/screenshots/reference/fillspace.png b/tests/screenshots/reference/fillspace.png
new file mode 100644
index 0000000..5eed14f
Binary files /dev/null and b/tests/screenshots/reference/fillspace.png differ
diff --git a/vite-plugin-component-docs.js b/vite-plugin-component-docs.js
index 18ee22b..5311771 100644
--- a/vite-plugin-component-docs.js
+++ b/vite-plugin-component-docs.js
@@ -32,6 +32,7 @@ const SOURCES = [
{ file: 'src/panels/EditablePanelTile.jsx', importPath: 'dashgui/panels' },
{ file: 'src/ElevatedPill.jsx', importPath: 'dashgui/ElevatedPill' },
{ file: 'src/SingleLineTextEdit.jsx', importPath: 'dashgui/SingleLineTextEdit' },
+ { file: 'src/FillSpace.jsx', importPath: 'dashgui/FillSpace' },
{ file: 'src/tiles/Tile.jsx', importPath: 'dashgui/tiles' },
{ file: 'src/lists/RecordList.jsx', importPath: 'dashgui/lists' },
];