Genesis
This commit is contained in:
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 };
|
||||
Reference in New Issue
Block a user