Genesis
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
48
README.md
Normal file
48
README.md
Normal 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
12
index.html
Normal 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
1960
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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
125
src/ElevatedPill.jsx
Normal 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;
|
||||||
86
src/SingleLineTextEdit.jsx
Normal file
86
src/SingleLineTextEdit.jsx
Normal 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
178
src/dev/ComponentDocs.jsx
Normal 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
55
src/dev/DevApp.jsx
Normal 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
17
src/dev/main.jsx
Normal 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>,
|
||||||
|
);
|
||||||
154
src/dev/mock-pages/ListsMockPage.jsx
Normal file
154
src/dev/mock-pages/ListsMockPage.jsx
Normal 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;
|
||||||
128
src/dev/mock-pages/PanelsMockPage.jsx
Normal file
128
src/dev/mock-pages/PanelsMockPage.jsx
Normal 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;
|
||||||
55
src/dev/mock-pages/PillsMockPage.jsx
Normal file
55
src/dev/mock-pages/PillsMockPage.jsx
Normal 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;
|
||||||
48
src/dev/mock-pages/TextFieldsMockPage.jsx
Normal file
48
src/dev/mock-pages/TextFieldsMockPage.jsx
Normal 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;
|
||||||
76
src/dev/mock-pages/TilesMockPage.jsx
Normal file
76
src/dev/mock-pages/TilesMockPage.jsx
Normal 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
16
src/index.js
Normal 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
196
src/lists/RecordList.jsx
Normal 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
1
src/lists/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as RecordList } from './RecordList';
|
||||||
300
src/panels/EditablePanelTile.jsx
Normal file
300
src/panels/EditablePanelTile.jsx
Normal 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
156
src/panels/PanelTile.jsx
Normal 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
4
src/panels/index.js
Normal 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';
|
||||||
13
src/panels/injectMuiTheme.jsx
Normal file
13
src/panels/injectMuiTheme.jsx
Normal 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;
|
||||||
|
}
|
||||||
74
src/panels/panelTileLayout.js
Normal file
74
src/panels/panelTileLayout.js
Normal 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
128
src/tiles/Tile.jsx
Normal 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
1
src/tiles/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TILE_SIZE, Tile, tileBaseSx } from './Tile';
|
||||||
185
vite-plugin-component-docs.js
Normal file
185
vite-plugin-component-docs.js
Normal 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
18
vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user