This commit is contained in:
seb
2026-10-10 02:37:41 +02:00
parent 6e23c34bd3
commit bc49139b1e
4 changed files with 96 additions and 148 deletions

View File

@@ -16,7 +16,7 @@ This package is source-only: consumers import directly from `src/` via the
| `dashgui/tiles` | `src/tiles/` | `Tile`, `TILE_SIZE`, `tileBaseSx` | | `dashgui/tiles` | `src/tiles/` | `Tile`, `TILE_SIZE`, `tileBaseSx` |
| `dashgui/ElevatedPill` | `src/ElevatedPill.jsx` | `ElevatedPill`, `ToggleElevatedPill`, `ActionElevatedPill` | | `dashgui/ElevatedPill` | `src/ElevatedPill.jsx` | `ElevatedPill`, `ToggleElevatedPill`, `ActionElevatedPill` |
| `dashgui/SingleLineTextEdit` | `src/SingleLineTextEdit.jsx` | Centralized single-line text field | | `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` | | `dashgui/FillSpace` | `src/FillSpace.jsx` | `FillSpace` — paginates `items` via `renderItem`, filling the viewport space between `aboveRef`/`belowRef`; wheel flips pages |
## Design rules ## Design rules

View File

@@ -260,13 +260,14 @@ class FillSpace extends Component {
// FillSpace owns current page, wheel-pagination and the pagination header. // FillSpace owns current page, wheel-pagination and the pagination header.
// Caller feeds `items` plus one row element via `renderItem` (component // Caller feeds `items` plus one row element via `renderItem` (component
// type or callback). // type or callback). `items` may be undefined while data is loading.
const { pageSize, currentPage, viewportHeight } = this.state; const { pageSize, currentPage, viewportHeight } = this.state;
const total = items.length; const list = Array.isArray(items) ? items : [];
const total = list.length;
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const safePage = Math.min(Math.max(1, currentPage), pageCount); const safePage = Math.min(Math.max(1, currentPage), pageCount);
const offset = (safePage - 1) * pageSize; const offset = (safePage - 1) * pageSize;
const pageItems = items.slice(offset, offset + pageSize); const pageItems = list.slice(offset, offset + pageSize);
const showPagination = total > pageSize; const showPagination = total > pageSize;
// Hidden data-free probe: measures `renderItem` itself (no data). // Hidden data-free probe: measures `renderItem` itself (no data).
const probe = renderProbeElement(renderItem); const probe = renderProbeElement(renderItem);

View File

@@ -1,14 +1,32 @@
import { Component, createRef } from 'react'; import { Component, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import FillSpace from '../../FillSpace'; import FillSpace from '../../FillSpace';
import { ComponentDocs } from '../ComponentDocs'; import { ComponentDocs } from '../ComponentDocs';
/** Row renderer: component types are rendered as elements by FillSpace. */
class Row extends Component {
render() {
const { item } = this.props;
return (
<ListItemButton divider disabled={!item}>
<ListItemText primary={item ? item.name : '\u00A0'} />
</ListItemButton>
);
}
}
const SAMPLE_ITEMS = Array.from({ length: 40 }, (_, i) => ({
id: i + 1,
name: `Eintrag ${i + 1}`,
}));
/** /**
* Mock page exercising FillSpace: real content on top, and a repeating * Mock page exercising FillSpace: header/footer refs, `items` + `renderItem`,
* `measureElement` filling only the leftover space down to the end of the * wheel pagination and the built-in pagination header.
* viewport.
*/ */
class FillSpaceMockPage extends Component { class FillSpaceMockPage extends Component {
constructor(props) { constructor(props) {
@@ -16,27 +34,26 @@ class FillSpaceMockPage extends Component {
this.ex1 = { header: createRef(), footer: createRef() }; this.ex1 = { header: createRef(), footer: createRef() };
this.ex2 = { header: createRef(), footer: createRef() }; this.ex2 = { header: createRef(), footer: createRef() };
this.ex3 = { header: createRef(), footer: createRef() }; this.ex3 = { header: createRef(), footer: createRef() };
this.state = { fitCount: null };
} }
render() { render() {
const { fitCount } = this.state;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
FillSpace renders real content on top and repeats the measurement FillSpace paginates <code>items</code> via a <code>renderItem</code>{' '}
element until the end of the page. row, measures the row height, fills the leftover viewport space and
flips pages on wheel scroll.
</Typography> </Typography>
{/* Main example: content + repeating fill to end of page */} {/* Main example: items + renderItem to end of page */}
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="subtitle2" gutterBottom> <Typography variant="subtitle2" gutterBottom>
Content + repeating element to end of page Items + renderItem, filled to end of page
</Typography> </Typography>
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box <Box
ref={this.ex1.header} ref={this.ex1.header}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
> >
<Typography variant="caption">Header (measured)</Typography> <Typography variant="caption">Header (measured)</Typography>
</Box> </Box>
@@ -44,65 +61,17 @@ class FillSpaceMockPage extends Component {
<FillSpace <FillSpace
aboveRef={this.ex1.header} aboveRef={this.ex1.header}
belowRef={this.ex1.footer} belowRef={this.ex1.footer}
measureElement={ items={SAMPLE_ITEMS}
<Box renderItem={Row}
sx={{ reserveBottom={32}
bgcolor: '#dbeafe', minPageSize={5}
p: 2, />
borderRadius: 1,
textAlign: 'center',
}}
>
<Typography variant="body2">Einheit</Typography>
</Box>
}
onMeasurement={(count) => this.setState({ fitCount: count })}
>
<Box
sx={{
bgcolor: '#fef3c7',
p: 2,
borderRadius: 1,
}}
>
<Typography variant="body2" fontWeight={600}>
Echte Inhalte (children)
</Typography>
<Typography variant="caption" color="text.secondary">
Diese Zeilen stehen oben, darunter wiederholt sich die
Einheit bis zum Seitenende.
</Typography>
<Box
sx={{
mt: 1,
px: 1.5,
py: 0.75,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px dashed',
borderColor: 'divider',
}}
>
<Typography variant="caption" component="span">
📏 onMeasurement (würden ohne Inhalt passen):&nbsp;
</Typography>
<Typography
variant="caption"
component="span"
fontWeight={700}
color="primary"
>
{fitCount != null ? `${fitCount} Einheiten` : '…'}
</Typography>
</Box>
</Box>
</FillSpace>
<Box <Box
ref={this.ex1.footer} ref={this.ex1.footer}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
> >
<Typography variant="caption">Footer (measured)</Typography> <Typography variant="caption">Footer (reserved)</Typography>
</Box> </Box>
</Box> </Box>
</Paper> </Paper>
@@ -115,7 +84,7 @@ class FillSpaceMockPage extends Component {
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box <Box
ref={this.ex2.header} ref={this.ex2.header}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
> >
<Typography variant="caption">Header</Typography> <Typography variant="caption">Header</Typography>
</Box> </Box>
@@ -123,24 +92,15 @@ class FillSpaceMockPage extends Component {
<FillSpace <FillSpace
aboveRef={this.ex2.header} aboveRef={this.ex2.header}
belowRef={this.ex2.footer} belowRef={this.ex2.footer}
items={SAMPLE_ITEMS.slice(0, 6)}
renderItem={Row}
minPageSize={4}
minHeight={200} minHeight={200}
> />
<Box
sx={{
bgcolor: '#fef3c7',
p: 2,
borderRadius: 1,
}}
>
<Typography variant="body2">
Minimum height of 200px enforced.
</Typography>
</Box>
</FillSpace>
<Box <Box
ref={this.ex2.footer} ref={this.ex2.footer}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
> >
<Typography variant="caption">Footer</Typography> <Typography variant="caption">Footer</Typography>
</Box> </Box>
@@ -155,7 +115,7 @@ class FillSpaceMockPage extends Component {
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box <Box
ref={this.ex3.header} ref={this.ex3.header}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
> >
<Typography variant="caption">Header</Typography> <Typography variant="caption">Header</Typography>
</Box> </Box>
@@ -163,25 +123,15 @@ class FillSpaceMockPage extends Component {
<FillSpace <FillSpace
aboveRef={this.ex3.header} aboveRef={this.ex3.header}
belowRef={this.ex3.footer} belowRef={this.ex3.footer}
items={SAMPLE_ITEMS}
renderItem={Row}
minPageSize={5}
debug debug
> />
<Box
sx={{
bgcolor: '#dcfce7',
p: 2,
borderRadius: 1,
}}
>
<Typography variant="body2">
Green background shows actual spacer bounds; purple overlay
shows full viewport coverage.
</Typography>
</Box>
</FillSpace>
<Box <Box
ref={this.ex3.footer} ref={this.ex3.footer}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
> >
<Typography variant="caption">Footer</Typography> <Typography variant="caption">Footer</Typography>
</Box> </Box>

View File

@@ -2,6 +2,8 @@ import { Component, createRef } from 'react';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import FolderIcon from '@mui/icons-material/FolderOutlined'; import FolderIcon from '@mui/icons-material/FolderOutlined';
@@ -307,8 +309,8 @@ class ScreenshotHarness extends Component {
} }
/** /**
* Deterministic FillSpace example for the screenshot harness. Bound with a * Deterministic FillSpace example for the screenshot harness:
* `maxHeight` so the reference is stable regardless of the viewport height. * `items` + a component `renderItem`, bounded by above/below refs.
*/ */
class FillSpaceExample extends Component { class FillSpaceExample extends Component {
constructor(props) { constructor(props) {
@@ -322,7 +324,7 @@ class FillSpaceExample extends Component {
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box <Box
ref={this.headerRef} ref={this.headerRef}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
> >
<Typography variant="caption">Header (gemessen)</Typography> <Typography variant="caption">Header (gemessen)</Typography>
</Box> </Box>
@@ -330,26 +332,15 @@ class FillSpaceExample extends Component {
<FillSpace <FillSpace
aboveRef={this.headerRef} aboveRef={this.headerRef}
belowRef={this.footerRef} belowRef={this.footerRef}
items={FILL_SAMPLE_ITEMS}
renderItem={FillRow}
minPageSize={4}
minHeight={80} minHeight={80}
maxHeight={220} />
>
<Box
sx={{
bgcolor: '#dbeafe',
p: 2,
borderRadius: 1,
height: '100%',
}}
>
<Typography variant="body2">
FillSpace füllt den Platz zwischen Header und Footer.
</Typography>
</Box>
</FillSpace>
<Box <Box
ref={this.footerRef} ref={this.footerRef}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
> >
<Typography variant="caption">Footer (gemessen)</Typography> <Typography variant="caption">Footer (gemessen)</Typography>
</Box> </Box>
@@ -358,9 +349,36 @@ class FillSpaceExample extends Component {
} }
} }
/** Row renderer used by the FillSpace harness examples. */
class FillRow extends Component {
render() {
const { item } = this.props;
return (
<ListItemButton divider disabled={!item}>
<ListItemText
primary={
item ? (
`${item.name}`
) : (
<Typography variant="body2" sx={{ visibility: 'hidden' }}>
Platzhalter
</Typography>
)
}
/>
</ListItemButton>
);
}
}
const FILL_SAMPLE_ITEMS = Array.from({ length: 24 }, (_, i) => ({
id: i + 1,
name: `Eintrag ${i + 1}`,
}));
/** /**
* FillSpace with a repeating measurement element plus real children on top, * FillSpace with `items` + a component `renderItem`, for the screenshot
* for the screenshot harness. Bound with maxHeight so the reference is stable. * harness. Bounded by above/below refs so the reference is stable.
*/ */
class FillSpaceMeasureExample extends Component { class FillSpaceMeasureExample extends Component {
constructor(props) { constructor(props) {
@@ -374,7 +392,7 @@ class FillSpaceMeasureExample extends Component {
<Box sx={{ display: 'flex', flexDirection: 'column' }}> <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box <Box
ref={this.headerRef} ref={this.headerRef}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mb: 1 }}
> >
<Typography variant="caption">Header</Typography> <Typography variant="caption">Header</Typography>
</Box> </Box>
@@ -382,36 +400,15 @@ class FillSpaceMeasureExample extends Component {
<FillSpace <FillSpace
aboveRef={this.headerRef} aboveRef={this.headerRef}
belowRef={this.footerRef} belowRef={this.footerRef}
measureElement={ items={FILL_SAMPLE_ITEMS}
<Box renderItem={FillRow}
sx={{ minPageSize={4}
bgcolor: '#c7d2fe',
p: 1,
borderRadius: 1,
textAlign: 'center',
}}
>
<Typography variant="caption">Einheit</Typography>
</Box>
}
measureGap={8}
minHeight={60} minHeight={60}
maxHeight={260} />
>
<Box
sx={{
bgcolor: '#fef3c7',
p: 1,
borderRadius: 1,
}}
>
<Typography variant="body2">Echte Inhalte</Typography>
</Box>
</FillSpace>
<Box <Box
ref={this.footerRef} ref={this.footerRef}
sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1 }} sx={{ bgcolor: '#e2e8f0', p: 1, borderRadius: 1, mt: 1 }}
> >
<Typography variant="caption">Footer</Typography> <Typography variant="caption">Footer</Typography>
</Box> </Box>