This commit is contained in:
seb
2026-08-08 16:58:48 +02:00
commit 253d0f95ee
27 changed files with 4083 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# dashgui
Reusable custom **GUI class components** (React + MUI), shared across the
`dash*` apps. This is the "outsourced" home for custom GUI primitives that
previously lived inside `dashfront/src/components`.
This package is source-only: consumers import directly from `src/` via the
`exports` map, so there is no build step for the library itself.
## Contents
| Export | Source | Description |
| --- | --- | --- |
| `dashgui/panels` | `src/panels/` | `PanelTile`, `PanelTileGrid`, `EditablePanelTile`, `injectMuiTheme`, layout helpers |
| `dashgui/lists` | `src/lists/` | `RecordList` — generic presentational data list (loading/error/empty states + table with edit/delete) |
| `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 |
## Design rules
- **No app coupling.** No Redux, router, notistack, storage manager, or backend
error imports. Error surfaces are injected via props (e.g. `EditablePanelTile`'s
`onError`, `RecordList`'s `error`). Data and actions are always passed in.
- **Class components** for the base primitives (matches the existing codebase).
- Only `react`, MUI, and MUI icons as (peer) dependencies.
## Consuming from dashfront
`dashfront` declares `"dashgui": "file:../dashgui"` (npm workspace / symlink).
Source imports keep changes instantly visible in the app.
## Development harness
Run `npm run dev` to render the components on **mock pages** (`dev/`) without a
backend. This is where test cases with mock pages will live.
```sh
npm install
npm run dev # http://localhost:5199
npm run build # bundle the dev harness (dist/)
npm run lint
```
## Future
- Test cases (unit + component tests) with mock pages will be added under
`dev/` / `src/__tests__/` as they are written.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>dashgui dev harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dev/main.jsx"></script>
</body>
</html>

1960
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "dashgui",
"version": "0.1.0",
"description": "Reusable custom GUI class components shared across dash* apps. Outsource target for dashfront's GUI primitives.",
"type": "module",
"main": "src/index.js",
"module": "src/index.js",
"exports": {
".": "./src/index.js",
"./panels": "./src/panels/index.js",
"./panels/panelTileLayout": "./src/panels/panelTileLayout.js",
"./lists": "./src/lists/index.js",
"./tiles": "./src/tiles/index.js",
"./ElevatedPill": "./src/ElevatedPill.jsx",
"./SingleLineTextEdit": "./src/SingleLineTextEdit.jsx",
"./package.json": "./package.json"
},
"files": [
"src"
],
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"peerDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@vitejs/plugin-react": "6.0.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"vite": "^8.2.0"
}
}

125
src/ElevatedPill.jsx Normal file
View File

@@ -0,0 +1,125 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Switch from '@mui/material/Switch';
import Typography from '@mui/material/Typography';
import { alpha } from '@mui/material/styles';
/**
* Base pill with elevation, shared styling, and an overridable trailing element.
* Root class for ToggleElevatedPill and ActionElevatedPill.
*
* @component ElevatedPill
* @import dashgui/ElevatedPill
* @defaultExport
*
* @prop {ReactNode} [label] - Text shown inside the pill.
* @prop {boolean} [active=false] - When true, uses a stronger primary gradient background.
* @prop {() => void} [onClick] - Called when the pill is clicked.
*
* @behaviour Rendered as a pill-shaped (borderRadius 999) inline-flex Box with a subtle primary-tinted gradient and shadow 1.
* @behaviour `renderTrailing()` may be overridden by subclasses to append an element (switch / icon).
* @behaviour Clicking toggles via onClick — the pill itself is not a form control.
*/
class ElevatedPill extends Component {
renderTrailing() {
return null;
}
render() {
const { label, active = false, onClick } = this.props;
return (
<Box
component="label"
onClick={onClick}
sx={(theme) => ({
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.25,
borderRadius: 999,
background: active
? `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.28)}, ${alpha(
theme.palette.primary.main,
0.22,
)})`
: `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.16)}, ${alpha(
theme.palette.primary.main,
0.10,
)})`,
color: 'text.primary',
cursor: 'pointer',
userSelect: 'none',
boxShadow: theme.shadows[1],
transition: 'background 150ms ease, box-shadow 150ms ease',
})}
>
<Typography variant="body2" sx={{ lineHeight: 1 }}>
{label}
</Typography>
{this.renderTrailing()}
</Box>
);
}
}
/**
* ElevatedPill with a trailing MUI Switch bound to `checked` / `onToggle` —
* a labelled on/off toggle.
*
* @component ToggleElevatedPill
* @import dashgui/ElevatedPill
*
* @prop {ReactNode} [label] - Text shown next to the switch.
* @prop {boolean} checked - Controlled switch state.
* @prop {(next: boolean) => void} onToggle - Called with the new checked value when the switch is toggled.
*
* @behaviour The switch is styled with a success color when on, grey when off.
* @behaviour Controlled: the parent owns `checked` and updates it via `onToggle`.
*/
export class ToggleElevatedPill extends ElevatedPill {
renderTrailing() {
const { checked, onToggle } = this.props;
return (
<Switch
size="small"
checked={checked}
onChange={(event) => onToggle(event.target.checked)}
sx={(theme) => ({
'& .MuiSwitch-switchBase': {
color: checked ? 'success.main' : 'grey.300',
},
'& .Mui-checked': {
color: 'success.main',
},
'& .Mui-checked + .MuiSwitch-track': {
backgroundColor: alpha(theme.palette.success.main, 0.35),
},
})}
/>
);
}
}
/**
* ElevatedPill whose trailing slot is a custom icon — used for clickable pill
* buttons.
*
* @component ActionElevatedPill
* @import dashgui/ElevatedPill
*
* @prop {ReactNode} [label] - Text shown next to the icon.
* @prop {ReactNode} [icon] - Icon node rendered as the trailing element.
* @prop {boolean} [active=false] - When true, uses the stronger primary gradient background.
* @prop {() => void} [onClick] - Called when the pill is clicked.
*
* @behaviour Clicking the whole pill fires `onClick` — the icon is decorative, not a separate control.
* @behaviour `active` gives a pressed/selected look via the stronger gradient.
*/
export class ActionElevatedPill extends ElevatedPill {
renderTrailing() {
return this.props.icon;
}
}
export default ElevatedPill;

View File

@@ -0,0 +1,86 @@
import { Component } from 'react';
import TextField from '@mui/material/TextField';
/**
* Centralized single-line text field with the same outlined look everywhere.
* Wraps MUI TextField; `value`/`onChange` follow the `(nextValue)` signature
* used across the app.
*
* @component SingleLineTextEdit
* @import dashgui/SingleLineTextEdit
* @defaultExport
*
* @prop {string} value - Controlled field value.
* @prop {(nextValue: string) => void} onChange - Called with the new string value (not an event). Not called while `readOnly`.
* @prop {React.Ref} [inputRef] - Ref forwarded to the inner input (used by EditablePanelTile to focus on edit).
* @prop {ReactNode} [label=Name] - Outlined field label.
* @prop {boolean} [readOnly=false] - When true, the input is read-only: non-editable, not tabbable, spellcheck off, and onChange is suppressed.
* @prop {boolean} [disabled=false] - Passed to TextField — disables the field.
* @prop {boolean} [required=false] - Passed to TextField — marks the field required.
* @prop {boolean} [fullWidth=true] - TextField fullWidth prop.
* @prop {boolean} [autoFocus=false] - TextField autoFocus prop.
* @prop {string} [autoComplete=off] - TextField autoComplete prop.
* @prop {'small' | 'medium'} [size] - TextField size prop.
* @prop {object} [slotProps] - MUI slotProps, merged (input/htmlInput are augmented for readOnly/spellCheck/tabIndex).
* @prop {object} [sx] - Extra styles passed to TextField.
* @prop {any} [otherProps] - Any remaining TextField props (helperText, etc.) are spread onto TextField.
*
* @behaviour Always renders `variant="outlined"` for a consistent look.
* @behaviour readOnly inputs get `spellCheck=false` and `tabIndex=-1`, so they are visually editable but not interactive.
* @behaviour Because it wraps TextField, styling is theme-consistent across the app.
*/
class SingleLineTextEdit extends Component {
render() {
const {
value,
onChange,
inputRef,
label = 'Name',
disabled = false,
readOnly = false,
autoFocus = false,
autoComplete = 'off',
size,
fullWidth = true,
required = false,
sx,
slotProps,
...rest
} = this.props;
return (
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
required={required}
size={size}
variant="outlined"
label={label}
value={value}
disabled={disabled}
onChange={(event) => {
if (!readOnly) onChange(event.target.value);
}}
autoFocus={autoFocus}
autoComplete={autoComplete}
slotProps={{
...slotProps,
input: {
readOnly,
...slotProps?.input,
},
htmlInput: {
spellCheck: false,
readOnly,
tabIndex: readOnly ? -1 : undefined,
...slotProps?.htmlInput,
},
}}
sx={sx}
{...rest}
/>
);
}
}
export default SingleLineTextEdit;

178
src/dev/ComponentDocs.jsx Normal file
View File

@@ -0,0 +1,178 @@
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { componentDocs } from 'virtual:component-docs';
/**
* Look up a component's docs from the registry extracted by the Vite plugin.
*/
export function getComponentDoc(name) {
return componentDocs[name];
}
/**
* Props documentation table.
* `props`: [{ name, type, required?, default?, description }]
*/
export function PropsTable({ props }) {
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{ bgcolor: 'background.paper' }}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: '18%' }}>Prop</TableCell>
<TableCell sx={{ width: '24%' }}>Type</TableCell>
<TableCell sx={{ width: '12%' }}>Default</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.map((p) => (
<TableRow
key={p.name}
sx={{ '&:last-child td, &:last-child th': { borderBottom: 0 } }}
>
<TableCell sx={{ py: 0.75 }}>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ flexWrap: 'wrap' }}
>
<Box
component="code"
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: 13 }}
>
{p.name}
</Box>
{p.required ? (
<Chip
label="required"
size="small"
color="error"
sx={{ height: 18, fontSize: 10 }}
/>
) : null}
</Stack>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
<Box
component="code"
sx={{
fontFamily: 'monospace',
fontSize: 12,
color: 'primary.main',
}}
>
{p.type}
</Box>
</TableCell>
<TableCell sx={{ py: 0.75 }}>
{p.default != null ? (
<Box component="code" sx={{ fontFamily: 'monospace', fontSize: 12 }}>
{p.default}
</Box>
) : (
'—'
)}
</TableCell>
<TableCell sx={{ py: 0.75, color: 'text.secondary' }}>
{p.description}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
/** Behaviour notes rendered as a bulleted list. */
export function BehaviourList({ items }) {
return (
<Box component="ul" sx={{ m: 0, pl: 2.5 }}>
{items.map((item, i) => (
<Box
component="li"
key={i}
sx={{ typography: 'body2', color: 'text.secondary', mb: 0.5 }}
>
{item}
</Box>
))}
</Box>
);
}
/**
* Full documentation block for one component. Reads the component's docs from
* the registry extracted by the Vite plugin (from JSDoc in the source files).
*
* name — component name as tagged with `@component` in the source JSDoc
*
* Optionally accepts overrides for any field (e.g. `props`/`behaviour`) when a
* mock page wants to add context on top of the source docs.
*/
export function ComponentDocs({ name, ...overrides }) {
const doc = { ...getComponentDoc(name), ...overrides };
const { importPath, defaultExport, description, props = [], behaviour = [] } = doc;
const importLine = importPath
? defaultExport
? `import ${name} from '${importPath}'`
: `import { ${name} } from '${importPath}'`
: null;
return (
<Paper variant="outlined" sx={{ mt: 3, p: 2.5 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>
{name}
</Typography>
{importLine ? (
<Typography
variant="caption"
component="code"
color="text.secondary"
sx={{ display: 'block', fontFamily: 'monospace', mb: 1 }}
>
{importLine}
</Typography>
) : null}
{description ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
) : null}
{props.length > 0 ? (
<>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Props
</Typography>
<PropsTable props={props} />
</>
) : null}
{behaviour.length > 0 ? (
<>
<Typography variant="subtitle2" sx={{ mt: 2.5, mb: 1 }}>
Behaviour
</Typography>
<BehaviourList items={behaviour} />
</>
) : null}
</Paper>
);
}
export default ComponentDocs;

55
src/dev/DevApp.jsx Normal file
View File

@@ -0,0 +1,55 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import PanelsMockPage from './mock-pages/PanelsMockPage';
import TilesMockPage from './mock-pages/TilesMockPage';
import PillsMockPage from './mock-pages/PillsMockPage';
import TextFieldsMockPage from './mock-pages/TextFieldsMockPage';
import ListsMockPage from './mock-pages/ListsMockPage';
const PAGES = [
{ id: 'panels', label: 'Panels', node: <PanelsMockPage /> },
{ id: 'lists', label: 'Listen', node: <ListsMockPage /> },
{ id: 'tiles', label: 'Tiles', node: <TilesMockPage /> },
{ id: 'pills', label: 'Pills', node: <PillsMockPage /> },
{ id: 'textfields', label: 'Text fields', node: <TextFieldsMockPage /> },
];
/**
* Dev harness shell. Each tab renders a "mock page" exercising the library's
* components with fake data — no backend, no redux. Future test cases will be
* written against the same mock pages.
*/
class DevApp extends Component {
constructor(props) {
super(props);
this.state = { tab: 'panels' };
}
render() {
const { tab } = this.state;
const page = PAGES.find((p) => p.id === tab);
return (
<Box sx={{ p: 3, maxWidth: 1100, mx: 'auto' }}>
<Typography variant="h4" gutterBottom>
dashgui — component gallery
</Typography>
<Tabs
value={tab}
onChange={(_e, next) => this.setState({ tab: next })}
sx={{ mb: 3 }}
>
{PAGES.map((p) => (
<Tab key={p.id} value={p.id} label={p.label} />
))}
</Tabs>
{page?.node}
</Box>
);
}
}
export { DevApp };

17
src/dev/main.jsx Normal file
View File

@@ -0,0 +1,17 @@
import { createRoot } from 'react-dom/client';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { DevApp } from './DevApp';
// Standalone harness — mocks the consuming app's theme/bootstrap so the GUI
// primitives can be inspected without a backend.
const theme = createTheme({
shape: { borderRadius: 4 },
});
createRoot(document.getElementById('root')).render(
<ThemeProvider theme={theme}>
<CssBaseline />
<DevApp />
</ThemeProvider>,
);

View File

@@ -0,0 +1,154 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import RecordList from '../../lists/RecordList';
import { ComponentDocs } from '../ComponentDocs';
const MOCK_ROWS = [
{
id: 1,
number: 'L-1001',
date: '2026-08-01',
party: 'Müller GmbH',
status: 'fertig',
},
{
id: 2,
number: 'L-1002',
date: '2026-08-03',
party: 'Schmidt & Co',
status: 'offen',
},
{
id: 3,
number: 'L-1003',
date: '2026-08-05',
party: 'Weber OHG',
status: 'offen',
},
];
const COLUMNS = [
{ key: 'number', label: 'Nummer' },
{ key: 'date', label: 'Lieferdatum' },
{ key: 'party', label: 'Lieferant' },
{
key: 'status',
label: 'Status',
render: (row) => (
<Chip
size="small"
label={row.status}
color={row.status === 'fertig' ? 'success' : 'default'}
/>
),
},
];
/**
* Mock page exercising RecordList with fake in-memory rows — no storage
* manager, no router, no toaster. Mirrors the shape the four dashfront list
* pages share (Lieferungen / Angebote / Aufträge / Kunden).
*/
class ListsMockPage extends Component {
constructor(props) {
super(props);
this.state = {
status: 'ready',
rows: MOCK_ROWS,
log: [],
};
}
simulateLoading = () => {
this.setState({ status: 'loading' });
setTimeout(() => this.setState({ status: 'ready' }), 800);
};
simulateFailure = () => {
this.setState({ status: 'failed' });
};
handleCreate = () => {
this.log('create');
};
handleEdit = (row) => {
this.log(`edit ${row.number}`);
};
handleDelete = (row) => {
this.log(`delete ${row.number}`);
this.setState((s) => ({ rows: s.rows.filter((r) => r.id !== row.id) }));
};
log(msg) {
this.setState((s) => ({ log: [msg, ...s.log].slice(0, 6) }));
}
render() {
const { status, rows, log } = this.state;
const failedNode = status === 'failed' ? (
<Typography variant="body2" color="error">
Mock-Fehlerzustand (hier würde BackendError stehen).
</Typography>
) : null;
return (
<Box>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button size="small" variant="outlined" onClick={this.simulateLoading}>
Laden
</Button>
<Button size="small" variant="outlined" color="error" onClick={this.simulateFailure}>
Fehler
</Button>
<Button
size="small"
variant="outlined"
onClick={() =>
this.setState({ status: 'ready', rows: MOCK_ROWS, log: [] })
}
>
Reset
</Button>
</Box>
<RecordList
status={status}
error={failedNode}
rows={rows}
columns={COLUMNS}
createLabel="Neue Lieferung"
onCreate={this.handleCreate}
onEdit={this.handleEdit}
onDelete={this.handleDelete}
deleteDisabled={false}
emptyMessage="Noch keine Lieferungen."
color="#fed7aa"
getEditLabel={(r) => `„${r.number}“ bearbeiten`}
getDeleteLabel={(r) => `„${r.number}“ löschen`}
/>
{log.length > 0 ? (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
Aktionen:
</Typography>
{log.map((entry, i) => (
<Typography key={i} variant="caption" display="block">
• {entry}
</Typography>
))}
</Box>
) : null}
<ComponentDocs name="RecordList" />
</Box>
);
}
}
export default ListsMockPage;

View File

@@ -0,0 +1,128 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../../panels';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { ComponentDocs } from '../ComponentDocs';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Mock page exercising PanelTile / EditablePanelTile with fake in-memory data.
* `onError` is passed to simulate the consuming app's error snackbar.
*/
class PanelsMockPage extends Component {
constructor(props) {
super(props);
this.state = {
companyName: 'ACME GmbH',
productName: 'Erdnussbutter 350g',
lastSaved: null,
errors: [],
};
}
handleSave = async (next) => {
// Simulated latency so the save spinner is visible.
await sleep(600);
this.setState({ companyName: next, lastSaved: next });
};
handleProductSave = async (next) => {
await sleep(600);
this.setState({ productName: next, lastSaved: next });
};
// Mirrors the consuming app's notifyError: returns a normalized { message }.
handleError = (err) => {
const message = String(err?.message ?? err ?? 'Unbekannter Fehler');
this.setState((prev) => ({ errors: [message, ...prev.errors].slice(0, 5) }));
return { message };
};
render() {
const { companyName, productName, lastSaved, errors } = this.state;
return (
<Box>
<PanelTileGrid>
<EditablePanelTile
wide={2}
title="Firma"
value={companyName}
onSave={this.handleSave}
onError={this.handleError}
display={
<Typography variant="body1">{companyName || '—'}</Typography>
}
edit={({ draft, onChange, inputRef }) => (
<SingleLineTextEdit
label="Name"
value={draft}
onChange={onChange}
inputRef={inputRef}
/>
)}
/>
<EditablePanelTile
wide={1}
title="Produkt"
color="#e2e8f0"
value={productName}
onSave={this.handleProductSave}
onError={this.handleError}
display={
<Typography variant="body1">{productName || '—'}</Typography>
}
edit={({ draft, onChange, inputRef }) => (
<SingleLineTextEdit
label="Name"
value={draft}
onChange={onChange}
inputRef={inputRef}
/>
)}
/>
<PanelTile wide={1} title="Nur anzeigen" color="#fef3c7">
<Typography variant="body2">
PanelTile ohne action — read-only.
</Typography>
</PanelTile>
</PanelTileGrid>
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="text.secondary">
{lastSaved != null
? `Zuletzt gespeichert: ${lastSaved}`
: 'Noch nichts gespeichert.'}
</Typography>
</Box>
{errors.length > 0 ? (
<Box sx={{ mt: 2 }}>
<Typography variant="caption" color="error">
Mock-Fehler (würde als Snackbar erscheinen):
</Typography>
{errors.map((e, i) => (
<Typography key={i} variant="caption" display="block" color="error">
• {e}
</Typography>
))}
</Box>
) : null}
<ComponentDocs name="PanelTile" />
<ComponentDocs name="PanelTileGrid" />
<ComponentDocs name="EditablePanelTile" />
</Box>
);
}
}
export default PanelsMockPage;

View File

@@ -0,0 +1,55 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import {
ActionElevatedPill,
ToggleElevatedPill,
} from '../../ElevatedPill';
import ElevatedPill from '../../ElevatedPill';
import { ComponentDocs } from '../ComponentDocs';
class PillsMockPage extends Component {
constructor(props) {
super(props);
this.state = { enabled: true, active: true };
}
render() {
const { enabled, active } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Toggle:
</Typography>
<ToggleElevatedPill
label="Sichtbar"
checked={enabled}
onToggle={(next) => this.setState({ enabled: next })}
/>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Action:
</Typography>
<ActionElevatedPill
label="Aktiv"
active={active}
onClick={() => this.setState((s) => ({ active: !s.active }))}
icon={<ToggleOnIcon sx={{ fontSize: 18 }} />}
/>
<ElevatedPill label="Statisch" />
</Box>
<ComponentDocs name="ElevatedPill" />
<ComponentDocs name="ToggleElevatedPill" />
<ComponentDocs name="ActionElevatedPill" />
</Box>
);
}
}
export default PillsMockPage;

View File

@@ -0,0 +1,48 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../../SingleLineTextEdit';
import { ComponentDocs } from '../ComponentDocs';
class TextFieldsMockPage extends Component {
constructor(props) {
super(props);
this.state = { name: '', note: 'Schreibgeschützt' };
}
render() {
const { name, note } = this.state;
return (
<Box>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
maxWidth: 480,
}}
>
<SingleLineTextEdit
label="Name"
value={name}
onChange={(next) => this.setState({ name: next })}
helperText="Gemeinsames Einzeilen-Feld"
/>
<SingleLineTextEdit
label="Nur lesen"
value={note}
readOnly
onChange={() => {}}
/>
<Typography variant="caption" color="text.secondary">
Aktueller Wert: {JSON.stringify(name)}
</Typography>
</Box>
<ComponentDocs name="SingleLineTextEdit" />
</Box>
);
}
}
export default TextFieldsMockPage;

View File

@@ -0,0 +1,76 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { Tile, tileBaseSx, TILE_SIZE } from '../../tiles';
import FolderIcon from '@mui/icons-material/FolderOutlined';
import AddIcon from '@mui/icons-material/Add';
import { ComponentDocs } from '../ComponentDocs';
class MockFolderTile extends Tile {
renderIcon() {
return <FolderIcon color="action" sx={{ fontSize: 40 }} />;
}
}
class MockAddTile extends Tile {
renderIcon() {
return <AddIcon color="primary" sx={{ fontSize: 40 }} />;
}
}
/**
* Mock page exercising the base Tile primitive with subclasses, mirroring how
* dashfront's ActionTile/CategoryTile/etc. extend it.
*/
class TilesMockPage extends Component {
constructor(props) {
super(props);
this.state = { clicks: 0 };
}
render() {
const { clicks } = this.state;
return (
<Box>
<Typography variant="body2" sx={{ mb: 2 }}>
Klicks: {clicks}
</Typography>
<Box
sx={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_SIZE}px, 1fr))`,
gap: 2,
maxWidth: 640,
}}
>
<MockFolderTile
label="Ordner"
secondary="3 Artikel"
onClick={() => this.setState((s) => ({ clicks: s.clicks + 1 }))}
/>
<MockAddTile
label="Neu"
secondary="anlegen"
onClick={() => this.setState((s) => ({ clicks: s.clicks + 1 }))}
/>
<MockFolderTile
label="Deaktiviert"
secondary="nicht klickbar"
disabled
/>
<Box sx={{ ...tileBaseSx, bgcolor: 'primary.main', color: 'white' }}>
<Typography variant="body2" fontWeight={600}>
tileBaseSx direkt
</Typography>
</Box>
</Box>
<ComponentDocs name="Tile" />
<ComponentDocs name="tileBaseSx" />
</Box>
);
}
}
export default TilesMockPage;

16
src/index.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* dashgui — reusable custom GUI class components.
*
* Source-only package; consumers import via the `exports` map in package.json.
* Sub-path exports keep the component groups addressable:
*
* import { PanelTile, EditablePanelTile } from 'dashgui/panels';
* import { Tile } from 'dashgui/tiles';
* import ElevatedPill from 'dashgui/ElevatedPill';
* import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
*/
export * from './panels';
export * from './lists';
export * from './tiles';
export { default as ElevatedPill } from './ElevatedPill';
export { default as SingleLineTextEdit } from './SingleLineTextEdit';

196
src/lists/RecordList.jsx Normal file
View File

@@ -0,0 +1,196 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { PanelTile, PanelTileGrid } from '../panels';
/**
* Generic data-list GUI component — pure presentation, no data layer.
*
* Renders a PanelTileGrid > PanelTile with an optional create button, an empty
* state, and a table of rows with per-row edit/delete actions. Everything data
* related is injected via props, so this component never touches a storage
* manager, router, toaster, or backend error formatter.
*
* @component RecordList
* @import dashgui/lists
*
* @prop {'loading' | 'ready' | 'failed'} status - Controls which state renders: a centered spinner, the table, or the `error` node.
* @prop {object[]} rows - Row objects to render. Each row needs the `rowKey` field for the React key.
* @prop {Array<{key: string, label: ReactNode, render?: (row) => ReactNode, align?: string, padding?: string}>} columns - Column definitions. `key` is the row field (or used with `render`); `render(row)` customizes the cell; `align`/`padding` map to TableCell.
* @prop {ReactNode} [error] - Node rendered when `status === "failed"` (e.g. a formatted backend error).
* @prop {string} [rowKey=id] - Field used as the row identity / React key.
* @prop {string} [createLabel] - Label of the top-right create button. Omit to hide the button entirely.
* @prop {() => void} [onCreate] - Called when the create button is clicked.
* @prop {(row) => void} [onEdit] - Called when a row is clicked or its edit button is pressed. Omit to hide the edit button and row-click affordance.
* @prop {(row) => void} [onDelete] - Called when a row's delete button is pressed. Omit to hide the delete button.
* @prop {boolean} [deleteDisabled=false] - Disables the delete buttons — e.g. `busyId != null` while a delete is in flight.
* @prop {ReactNode} [emptyMessage] - Shown instead of the table when `rows` is empty. Omit to render nothing.
* @prop {string} [color] - Background color of the wrapping PanelTile.
* @prop {number | string} [minHeight=240] - Minimum height of the wrapping PanelTile.
* @prop {(row) => string} [getEditLabel] - aria-label for each edit button (defaults to `„<id>“ bearbeiten`).
* @prop {(row) => string} [getDeleteLabel] - aria-label for each delete button (defaults to `„<id>“ löschen`).
* @prop {object} [sx] - Extra styles passed to the wrapping PanelTile.
*
* @behaviour `status === "loading"` renders only a centered CircularProgress (no panel chrome).
* @behaviour `status === "failed"` renders only the `error` node — or nothing if `error` is omitted.
* @behaviour Clicking a row calls `onEdit(row)` (only when `onEdit` is provided); the action buttons stop propagation so they do not double-trigger the row click.
* @behaviour The actions column appears only if `onEdit` or `onDelete` is provided.
* @behaviour No data layer: this component never touches storage, routing, toasters, or backend error formatting.
*/
class RecordList extends Component {
handleRowClick(row) {
this.props.onEdit?.(row);
}
renderLoading() {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
renderCreateButton() {
const { createLabel, onCreate } = this.props;
if (!createLabel) return null;
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="button"
variant="contained"
size="small"
onClick={onCreate}
>
{createLabel}
</Button>
</Box>
);
}
renderEmpty() {
const { emptyMessage } = this.props;
if (!emptyMessage) return null;
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
}
renderTable() {
const {
rows,
columns,
rowKey = 'id',
onEdit,
onDelete,
deleteDisabled,
getEditLabel,
getDeleteLabel,
} = this.props;
const hasActions = Boolean(onEdit || onDelete);
return (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.key} align={col.align} padding={col.padding}>
{col.label}
</TableCell>
))}
{hasActions ? <TableCell padding="checkbox" /> : null}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => {
const id = row[rowKey];
return (
<TableRow
key={id}
hover
sx={{ cursor: onEdit ? 'pointer' : undefined }}
onClick={() => this.handleRowClick(row)}
>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
padding={col.padding}
>
{col.render ? col.render(row) : row[col.key]}
</TableCell>
))}
{hasActions ? (
<TableCell
padding="checkbox"
onClick={(e) => e.stopPropagation()}
>
{onEdit ? (
<IconButton
size="small"
aria-label={
getEditLabel?.(row) ?? `„${id}“ bearbeiten`
}
onClick={() => onEdit(row)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
) : null}
{onDelete ? (
<IconButton
size="small"
aria-label={
getDeleteLabel?.(row) ?? `„${id}“ löschen`
}
disabled={deleteDisabled}
onClick={() => onDelete(row)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
) : null}
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
render() {
const { status, error, rows, color, minHeight = 240, sx } = this.props;
if (status === 'loading') return this.renderLoading();
if (status === 'failed') return error ?? null;
return (
<PanelTileGrid>
<PanelTile wide={3} minHeight={minHeight} color={color} sx={sx}>
<Stack spacing={2}>
{this.renderCreateButton()}
{rows.length === 0 ? this.renderEmpty() : this.renderTable()}
</Stack>
</PanelTile>
</PanelTileGrid>
);
}
}
export default RecordList;

1
src/lists/index.js Normal file
View File

@@ -0,0 +1 @@
export { default as RecordList } from './RecordList';

View File

@@ -0,0 +1,300 @@
import { Component, createRef } from 'react';
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { injectMuiTheme } from './injectMuiTheme';
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
function valuesEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* Panel tile with centralized inline-edit chrome: edit/save/cancel actions,
* dirty detection, keyboard handling, and an injected error surface. Pass
* `onSave` to enable editing; omit it for a read-only tile.
*
* @component EditablePanelTile
* @import dashgui/panels
*
* @prop {any} [value] - The saved value shown in read mode and used as the baseline for dirty detection.
* @prop {ReactNode} [title] - Overline label shown above the body.
* @prop {ReactNode} [display] - Node rendered while not editing.
* @prop {ReactNode | function} [edit] - Node rendered while editing, or a render function receiving `{ draft, onChange, inputRef }`.
* @prop {(draft) => Promise<void>} [onSave] - Async save handler. The tile exits edit mode only after this resolves; if it rejects, the error is surfaced via `onError` and shown inline.
* @prop {(err) => void} [onError] - Injected error surface (e.g. snackbar). Called when saving rejects. Omit to swallow save errors silently.
* @prop {(draft, saved) => boolean} [isDirty] - Optional custom dirty check. Defaults to JSON deep equality between draft and saved value.
* @prop {(draft, saved) => boolean} [canSave] - Optional extra validation gate on top of the default rules (dirty, non-null draft, not already saving).
* @prop {number} [wide=1] - Grid column span (1–3), same semantics as PanelTile.
* @prop {string} [color] - Background color for the paper.
* @prop {number | string} [minHeight=120] - Minimum height of the paper.
* @prop {string} [contentAlignItems=center] - CSS `align-items` for the body content row (e.g. "flex-start").
* @prop {function} [overlay] - Optional `({ editing, draft, onChange }) => ReactNode` rendered above the title, e.g. a full-tile overlay while editing.
* @prop {object} [sx] - Extra styles merged into the paper.
*
* @behaviour Clicking the edit icon clones the saved value into a draft and focuses the first input (via `inputRef`).
* @behaviour Enter (without Shift) saves when allowed; Escape cancels. Keys are handled on the body row while editing.
* @behaviour A dirty tile shows a rose inset ring; an editing-but-clean tile shows a slate ring.
* @behaviour While saving, the save button becomes a spinner and both buttons are disabled.
* @behaviour On save failure the tile stays in edit mode, shows an inline error Alert, and forwards the error to `onError`.
* @behaviour Read-only tiles (no `onSave`) render no action buttons at all.
*/
class EditablePanelTileBase extends Component {
constructor(props) {
super(props);
this.inputRef = createRef();
this.state = {
editing: false,
draft: null,
saving: false,
error: null,
};
}
componentDidUpdate(_prevProps, prevState) {
if (!prevState.editing && this.state.editing) {
this.inputRef.current?.focus();
}
}
getSavedValue() {
return this.props.value;
}
isDirty() {
const { draft, editing } = this.state;
if (!editing || draft == null) return false;
if (this.props.isDirty) {
return this.props.isDirty(draft, this.getSavedValue());
}
return !valuesEqual(draft, this.getSavedValue());
}
canSave() {
const { draft, saving } = this.state;
if (saving || !this.isDirty() || draft == null) return false;
if (this.props.canSave) {
return this.props.canSave(draft, this.getSavedValue());
}
return true;
}
handleStartEdit = (event) => {
event.stopPropagation();
this.setState({
editing: true,
draft: structuredClone(this.getSavedValue()),
error: null,
});
};
handleCancel = (event) => {
event?.stopPropagation();
this.setState({
editing: false,
draft: null,
error: null,
});
};
handleDraftChange = (nextDraft) => {
this.setState({ draft: nextDraft, error: null });
};
handleEditKeyDown = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.handleCancel();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
if (this.canSave()) {
void this.handleSave(event);
}
}
};
handleSave = async (event) => {
event?.stopPropagation();
if (!this.canSave()) return;
const { onSave, onError } = this.props;
const { draft } = this.state;
try {
await onSave(draft);
this.setState({ editing: false, draft: null, saving: false, error: null });
} catch (err) {
const message = String(err?.message ?? err ?? 'Unbekannter Fehler');
onError?.(err);
this.setState({ saving: false, error: message });
}
};
renderTitle() {
const { title, onSave } = this.props;
if (!title) return null;
return (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1, pr: onSave ? 9 : 0 }}
>
{title}
</Typography>
);
}
renderActions() {
const { onSave } = this.props;
if (!onSave) return null;
const { editing, saving } = this.state;
const iconSx = tileActionIconSx;
if (!editing) {
return (
<>
<IconButton
disabled
aria-hidden
tabIndex={-1}
sx={{
...tileActionSx,
right: 48,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<SaveOutlinedIcon sx={iconSx} />
</IconButton>
<IconButton
onClick={this.handleStartEdit}
aria-label="Bearbeiten"
sx={{ ...tileActionSx, right: 4 }}
>
<EditOutlinedIcon sx={iconSx} />
</IconButton>
</>
);
}
return (
<>
<IconButton
onClick={this.handleSave}
disabled={!this.canSave()}
aria-label="Speichern"
color={this.canSave() ? 'primary' : 'default'}
sx={{ ...tileActionSx, right: 48 }}
>
{saving ? (
<CircularProgress size={22} color="inherit" />
) : (
<SaveOutlinedIcon sx={iconSx} />
)}
</IconButton>
<IconButton
onClick={this.handleCancel}
disabled={saving}
aria-label="Abbrechen"
sx={{ ...tileActionSx, right: 4 }}
>
<CloseOutlinedIcon sx={iconSx} />
</IconButton>
</>
);
}
renderBody() {
const { display, edit } = this.props;
const { editing, draft } = this.state;
if (editing && draft != null) {
if (typeof edit === 'function') {
return edit({
draft,
onChange: this.handleDraftChange,
inputRef: this.inputRef,
});
}
return edit;
}
return display;
}
renderOverlayNode() {
const { overlay } = this.props;
const { editing, draft } = this.state;
if (typeof overlay !== 'function') return null;
return overlay({
editing,
draft,
onChange: this.handleDraftChange,
});
}
render() {
const {
onSave,
theme,
wide = 1,
sx,
minHeight = 120,
color,
contentAlignItems = 'center',
} = this.props;
const { editing, error } = this.state;
const pending = this.isDirty();
const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
return (
<Paper
elevation={4}
sx={panelTilePaperSx({
wide,
sx,
minHeight,
editing,
pending,
color,
theme,
})}
>
{this.renderActions()}
{this.renderOverlayNode()}
{this.renderTitle()}
<Box
sx={{
flex: 1,
minWidth: 0,
pr: actionPadding,
display: 'flex',
alignItems: contentAlignItems,
}}
onKeyDown={editing ? this.handleEditKeyDown : undefined}
>
{this.renderBody()}
</Box>
{error ? (
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
{error}
</Alert>
) : null}
</Paper>
);
}
}
export const EditablePanelTile = injectMuiTheme(EditablePanelTileBase);
export default EditablePanelTile;

156
src/panels/PanelTile.jsx Normal file
View File

@@ -0,0 +1,156 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { injectMuiTheme } from './injectMuiTheme';
import {
GRID_COLUMNS,
TILE_ACTION_PADDING,
panelTilePaperSx,
tileActionIconSx,
tileActionSx,
} from './panelTileLayout';
/**
* Read-only content panel with a title and an optional corner action (icon
* button with busy spinner). It is a styled MUI Paper; children fill the body.
*
* @component PanelTile
* @import dashgui/panels
*
* @prop {ReactNode} [title] - Overline label shown above the body. Omit to render no header.
* @prop {ReactNode} children - Body content filling the tile between title and edges.
* @prop {number} [wide=1] - Grid column span (1–3). On sm the span is capped at 2; on xs tiles always span the full row.
* @prop {string} [color] - Background color for the paper (e.g. a hex like "#fef3c7").
* @prop {number | string} [minHeight=120] - Minimum height of the paper in px.
* @prop {number} [elevation=4] - MUI Paper elevation (shadow depth).
* @prop {object} [sx] - Extra styles merged into the paper (last, so they win).
* @prop {object} [action] - Optional corner action `{ label, icon, onClick, busy? }`. While `busy` is true the icon is replaced by a spinner and clicks are disabled.
*
* @behaviour The action button sits absolutely in the top-right corner.
* @behaviour A hidden placeholder button reserves space so the title never shifts when an action appears.
* @behaviour Title, action, and children stack vertically; children flex to fill remaining height.
*/
class PanelTileBase extends Component {
renderTitle() {
const { title, action } = this.props;
if (!title) return null;
return (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1, pr: action ? 9 : 0 }}
>
{title}
</Typography>
);
}
renderAction() {
const { action } = this.props;
if (!action) return null;
const { label, icon: Icon, onClick, busy } = action;
return (
<>
<IconButton
disabled
aria-hidden
tabIndex={-1}
sx={{
...tileActionSx,
right: 48,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<SaveOutlinedIcon sx={tileActionIconSx} />
</IconButton>
<IconButton
type="button"
onClick={onClick}
aria-label={label}
disabled={busy}
sx={{ ...tileActionSx, right: 4 }}
>
{busy ? (
<CircularProgress size={22} color="inherit" />
) : (
<Icon sx={tileActionIconSx} />
)}
</IconButton>
</>
);
}
render() {
const {
theme,
wide = 1,
children,
sx,
minHeight = 120,
color,
action,
elevation = 4,
} = this.props;
const actionPadding = action ? TILE_ACTION_PADDING : 0;
return (
<Paper
elevation={elevation}
sx={panelTilePaperSx({
wide,
sx,
minHeight,
color,
theme,
})}
>
{this.renderAction()}
{this.renderTitle()}
<Box sx={{ flex: 1, minWidth: 0, pr: actionPadding }}>{children}</Box>
</Paper>
);
}
}
export const PanelTile = injectMuiTheme(PanelTileBase);
/**
* Responsive grid container for panel tiles. One column on xs, two on sm,
* GRID_COLUMNS (4) on md and up.
*
* @component PanelTileGrid
* @import dashgui/panels
*
* @prop {ReactNode} children - The tiles to lay out (typically PanelTile / EditablePanelTile).
* @prop {object} [sx] - Extra styles merged into the grid container.
*
* @behaviour Uses a CSS grid with `gap: 2` (theme spacing).
* @behaviour Children position themselves via the `wide` prop of each tile.
*/
export function PanelTileGrid({ children, sx }) {
return (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: `repeat(${GRID_COLUMNS}, 1fr)`,
},
gap: 2,
...sx,
}}
>
{children}
</Box>
);
}
export { GRID_COLUMNS };

4
src/panels/index.js Normal file
View File

@@ -0,0 +1,4 @@
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
export { EditablePanelTile } from './EditablePanelTile';
export { injectMuiTheme } from './injectMuiTheme';
export { PENDING_EDIT_BORDER } from './panelTileLayout';

View File

@@ -0,0 +1,13 @@
import { useTheme } from '@mui/material/styles';
/** Injects MUI theme into class components (replaces removed withTheme HOC). */
export function injectMuiTheme(Wrapped) {
function ComponentWithTheme(props) {
const theme = useTheme();
return <Wrapped {...props} theme={theme} />;
}
const name = Wrapped.displayName || Wrapped.name || 'Component';
ComponentWithTheme.displayName = `injectMuiTheme(${name})`;
return ComponentWithTheme;
}

View File

@@ -0,0 +1,74 @@
const GRID_COLUMNS = 4;
export const PENDING_EDIT_BORDER = '#e11d48';
export const EDITING_TILE_BORDER = '#94a3b8';
function clampWide(wide) {
return Math.min(Math.max(wide, 1), 3);
}
export function responsiveColumnSpan(wide) {
const span = clampWide(wide);
return {
xs: '1 / -1',
sm: span >= 2 ? 'span 2' : 'span 1',
md: `span ${span}`,
};
}
export const PENDING_EDIT_RING_PX = 4;
export const TILE_ACTION_PADDING = 12;
export const tileActionSx = {
position: 'absolute',
top: 4,
color: 'text.secondary',
};
export const tileActionIconSx = {
fontSize: 28,
};
export const PANEL_TILE_BORDER_RADIUS = 4;
function tileRingBoxShadow(ringColor, elevationShadow) {
const inset = `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${ringColor}`;
return elevationShadow ? `${inset}, ${elevationShadow}` : inset;
}
export function panelTilePaperSx({
wide,
sx,
minHeight = 120,
editing = false,
pending = false,
color,
theme,
}) {
const elevationShadow = theme?.shadows?.[4];
const ringColor = pending
? PENDING_EDIT_BORDER
: editing
? EDITING_TILE_BORDER
: null;
return {
gridColumn: responsiveColumnSpan(wide),
position: 'relative',
p: 2,
minHeight,
display: 'flex',
flexDirection: 'column',
border: 'none',
borderRadius: PANEL_TILE_BORDER_RADIUS,
...(color ? { bgcolor: color } : {}),
...(ringColor
? {
boxShadow: tileRingBoxShadow(ringColor, elevationShadow),
}
: {}),
...sx,
};
}
export { GRID_COLUMNS };

128
src/tiles/Tile.jsx Normal file
View File

@@ -0,0 +1,128 @@
import { Component } from 'react';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
/**
* Square side length (px) used as the basis for the tile grid in consuming
* apps.
*
* @component TILE_SIZE
* @import dashgui/tiles
*
* @behaviour Constant (140) exported for building grids: `repeat(auto-fill, minmax(TILE_SIZEpx, 1fr))`.
*/
export const TILE_SIZE = 140;
/** Matches panel tile rounding (theme.shape.borderRadius × 4 → typically 16px). */
export const TILE_BORDER_RADIUS = 4;
/**
* Shared tile styling, exported for reuse — lets consumers render a tile-like
* box without extending the Tile class (see the blue box in the gallery).
*
* @component tileBaseSx
* @import dashgui/tiles
*
* @behaviour Sets a square aspect ratio, centered flex column layout, rounded corners, background.paper fill, shadow 3, and hover states (background hover + shadow 6).
* @behaviour Not tied to a click handler — add your own onClick when used directly. Spread additional styles after it to override (e.g. bgcolor, color).
*/
export const tileBaseSx = {
aspectRatio: '1 / 1',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
p: 1.5,
cursor: 'pointer',
userSelect: 'none',
textAlign: 'center',
border: 'none',
borderRadius: TILE_BORDER_RADIUS,
overflow: 'hidden',
bgcolor: 'background.paper',
boxShadow: 3,
transition: 'box-shadow 0.15s, background-color 0.15s',
'&:hover': {
bgcolor: 'action.hover',
boxShadow: 6,
},
};
/**
* Base square tile (ButtonBase) meant to be subclassed — subclasses override
* the render hooks (e.g. `renderIcon`) and pass label/secondary text via props.
* Renders the shared `tileBaseSx` styling.
*
* @component Tile
* @import dashgui/tiles
*
* @prop {ReactNode} [label] - Primary text shown under the icon.
* @prop {ReactNode} [secondary] - Small secondary caption shown under the label.
* @prop {() => void} [onClick] - Called when the tile is clicked (disabled tiles are inert).
* @prop {boolean} [disabled=false] - Fades the tile to 50% opacity and disables pointer events.
* @prop {object} [sx] - Extra styles merged on top of `tileBaseSx` (win over it).
*
* @behaviour Renders inside a `ButtonBase` with `type="button"` and the shared `tileBaseSx` square look (aspect-ratio 1:1, rounded, shadow, hover feedback).
* @behaviour Subclasses override `renderIcon()`, `renderLabel()`, `renderSecondary()`, or `renderExtra()` to compose the tile body.
* @behaviour `TILE_SIZE` (140) is the basis for sizing the containing grid in the consuming app.
*/
export class Tile extends Component {
handleClick = () => {
this.props.onClick?.();
};
renderIcon() {
return null;
}
renderLabel() {
const { label } = this.props;
if (!label) return null;
return (
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
{label}
</Typography>
);
}
renderSecondary() {
const { secondary } = this.props;
if (!secondary) return null;
return (
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.2 }}>
{secondary}
</Typography>
);
}
renderExtra() {
return null;
}
render() {
const { sx, disabled } = this.props;
return (
<ButtonBase
component="button"
type="button"
disabled={disabled}
onClick={this.handleClick}
sx={{
...tileBaseSx,
position: 'relative',
...(disabled
? { opacity: 0.5, pointerEvents: 'none' }
: {}),
...sx,
}}
>
{this.renderIcon()}
{this.renderLabel()}
{this.renderSecondary()}
{this.renderExtra()}
</ButtonBase>
);
}
}

1
src/tiles/index.js Normal file
View File

@@ -0,0 +1 @@
export { TILE_SIZE, Tile, tileBaseSx } from './Tile';

View File

@@ -0,0 +1,185 @@
import { parse } from '@babel/parser';
import fs from 'node:fs';
import path from 'node:path';
/**
* Vite plugin — "component docs" virtual module.
*
* Reads JSDoc blocks tagged `@component` from the source files listed in
* SOURCES, parses them into a registry, and exposes it to the dev gallery as
* `import { componentDocs } from 'virtual:component-docs'`.
*
* The JSDoc format (kept next to the implementation):
*
* Block comment: "/**" ... "*​/" (see parseDoc for tag handling)
*
* @component PanelTile ← component name used in the gallery
* @import dashgui/panels ← sub-path export for the import snippet
* @defaultExport ← optional: `import X from '…'` (default export)
*
* @prop {type} [name=default] - Description ← optional props use [name=default]
* @prop {type} requiredProp - Description ← props without brackets are required
* @prop {type} [optionalProp] - Description ← optional without a default
*
* @behaviour A behaviour note.
* @behaviour Another behaviour note.
*/
const VIRTUAL_ID = 'virtual:component-docs';
const RESOLVED_VIRTUAL_ID = '\0' + VIRTUAL_ID;
const SOURCES = [
{ file: 'src/panels/PanelTile.jsx', importPath: 'dashgui/panels' },
{ file: 'src/panels/EditablePanelTile.jsx', importPath: 'dashgui/panels' },
{ file: 'src/ElevatedPill.jsx', importPath: 'dashgui/ElevatedPill' },
{ file: 'src/SingleLineTextEdit.jsx', importPath: 'dashgui/SingleLineTextEdit' },
{ file: 'src/tiles/Tile.jsx', importPath: 'dashgui/tiles' },
{ file: 'src/lists/RecordList.jsx', importPath: 'dashgui/lists' },
];
/** Parse a single `@prop {type} [name=default] - description` line. */
function parseProp(text) {
const typeMatch = text.match(/^\{(.*?)\}\s+(.*)$/s);
const type = typeMatch ? typeMatch[1].trim() : '';
const rest = typeMatch ? typeMatch[2].trim() : text.trim();
let name;
let defaultValue;
let required = true;
let description = '';
const optionalMatch = rest.match(/^\[([^\]=]*)(?:=([^\]]*))?\]\s*(?:-\s*)?(.*)$/s);
if (optionalMatch) {
required = false;
name = optionalMatch[1].trim();
defaultValue = optionalMatch[2] !== undefined ? optionalMatch[2].trim() : undefined;
description = optionalMatch[3].trim();
} else {
const reqMatch = rest.match(/^([^\s-]+)(?:\s*-\s*)?(.*)$/s);
if (reqMatch) {
name = reqMatch[1].trim();
description = reqMatch[2].trim();
}
}
return { name, type, required, default: defaultValue, description };
}
/** Parse a `/** … *​/` comment value (as provided by @babel/parser). */
function parseDoc(commentValue, fallback) {
const lines = commentValue
.split('\n')
.map((l) => l.replace(/^\s*\*\s?/, '').trim())
.filter((l, idx, arr) => !(l === '' && (idx === 0 || idx === arr.length - 1 || arr[idx - 1] === '')));
const descLines = [];
const props = [];
const behaviour = [];
let name = fallback.name || '';
let importPath = fallback.importPath || '';
let defaultExport = false;
let i = 0;
while (i < lines.length && !lines[i].startsWith('@')) {
if (lines[i] !== '') descLines.push(lines[i]);
i++;
}
for (; i < lines.length; i++) {
const m = lines[i].match(/^@(\w+)\s*(.*)$/);
if (!m) continue;
const [, tag, rest] = m;
switch (tag) {
case 'component':
name = rest.trim();
break;
case 'import':
importPath = rest.trim();
break;
case 'defaultExport':
defaultExport = true;
break;
case 'prop':
props.push(parseProp(rest.trim()));
break;
case 'behaviour':
behaviour.push(rest.trim());
break;
default:
break;
}
}
return { name, importPath, defaultExport, description: descLines.join(' '), props, behaviour };
}
/** Walk a source file and collect every `@component`-tagged JSDoc block. */
function extractFromFile(root, source) {
const fullPath = path.join(root, source.file);
let code;
try {
code = fs.readFileSync(fullPath, 'utf8');
} catch (err) {
console.warn(`[component-docs] cannot read ${source.file}: ${err.message}`);
return {};
}
let ast;
try {
ast = parse(code, {
sourceType: 'module',
plugins: ['jsx', 'classProperties'],
errorRecovery: true,
});
} catch (err) {
console.warn(`[component-docs] cannot parse ${source.file}: ${err.message}`);
return {};
}
const found = {};
for (const stmt of ast.program.body) {
const comments = stmt.leadingComments || [];
const doc = comments[comments.length - 1];
if (!doc || doc.type !== 'CommentBlock' || !doc.value.includes('@component')) continue;
const parsed = parseDoc(doc.value, { name: '', importPath: source.importPath });
if (!parsed.name) continue;
found[parsed.name] = parsed;
}
return found;
}
function buildRegistry(root) {
const registry = {};
for (const source of SOURCES) {
Object.assign(registry, extractFromFile(root, source));
}
return registry;
}
export function componentDocsPlugin() {
const documentedFiles = SOURCES.map((s) => s.file);
return {
name: 'component-docs',
enforce: 'pre',
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID;
return null;
},
load(id) {
if (id !== RESOLVED_VIRTUAL_ID) return null;
const registry = buildRegistry(process.cwd());
return `export const componentDocs = ${JSON.stringify(registry)};`;
},
handleHotUpdate(ctx) {
const { file, server } = ctx;
if (!documentedFiles.some((f) => file.endsWith(f))) return;
const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID);
if (mod) server.moduleGraph.invalidateModule(mod);
server.ws.send({ type: 'full-reload' });
},
};
}

18
vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { componentDocsPlugin } from './vite-plugin-component-docs.js';
// Library dev server — renders the mock pages under /dev so components can be
// inspected in isolation, and doubles as a Vite config for consumers that want
// to preview this package standalone.
export default defineConfig({
plugins: [react(), componentDocsPlugin()],
server: {
port: 5199,
},
build: {
// This build is only used for the standalone dev/preview harness.
// Consumers (dashfront) import source via the package `exports` map.
outDir: 'dist',
},
});