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

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 };