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' }, ];