diff --git a/src/automats/helpAutomat.js b/src/automats/helpAutomat.js
index 781bc81..4c25368 100644
--- a/src/automats/helpAutomat.js
+++ b/src/automats/helpAutomat.js
@@ -3,11 +3,13 @@ import { Automat } from 'react-automat';
/**
* @typedef {Object} HelpState
* @property {boolean} enabled Whether interactive help badges are enabled systemwide.
+ * @property {string|null} activeHelpId ID of the currently open help popper (only one can be open at a time).
*/
/** @type {HelpState} */
const initialState = {
enabled: false,
+ activeHelpId: null,
};
/**
@@ -24,14 +26,46 @@ const helpAutomat = new Automat(
* Toggles the systemwide help mode on or off.
*/
toggle() {
- helpAutomat.setState((prev) => ({ enabled: !prev?.enabled }));
+ helpAutomat.setState((prev) => {
+ const nextEnabled = !prev?.enabled;
+ return {
+ enabled: nextEnabled,
+ activeHelpId: nextEnabled ? prev?.activeHelpId ?? null : null,
+ };
+ });
},
/**
* Explicitly set whether help mode is enabled.
* @param {boolean} enabled
*/
setEnabled(enabled) {
- helpAutomat.setState({ enabled: Boolean(enabled) });
+ const nextEnabled = Boolean(enabled);
+ helpAutomat.setState((prev) => ({
+ enabled: nextEnabled,
+ activeHelpId: nextEnabled ? prev?.activeHelpId ?? null : null,
+ }));
+ },
+ /**
+ * Open a specific help popper, automatically closing any other currently open popper.
+ * @param {string} id
+ */
+ openPopper(id) {
+ helpAutomat.setState({ activeHelpId: id });
+ },
+ /**
+ * Close the currently active help popper.
+ */
+ closePopper() {
+ helpAutomat.setState({ activeHelpId: null });
+ },
+ /**
+ * Toggle a specific help popper open/closed.
+ * @param {string} id
+ */
+ togglePopper(id) {
+ helpAutomat.setState((prev) => ({
+ activeHelpId: prev?.activeHelpId === id ? null : id,
+ }));
},
},
{
@@ -40,6 +74,20 @@ const helpAutomat = new Automat(
}
);
+// Clear active popper on initial boot / persistence hydration
+void helpAutomat.ready.then(() => {
+ if (helpAutomat.state?.activeHelpId) {
+ helpAutomat.setState({ activeHelpId: null });
+ }
+});
+
export default helpAutomat;
-export const { toggle: toggleHelp, setEnabled: setHelpEnabled } = helpAutomat.actions;
+export const {
+ toggle: toggleHelp,
+ setEnabled: setHelpEnabled,
+ openPopper: openHelpPopper,
+ closePopper: closeHelpPopper,
+ togglePopper: toggleHelpPopper,
+} = helpAutomat.actions;
export const isHelpEnabled = (state = helpAutomat.state) => Boolean(state?.enabled);
+export const selectActiveHelpId = (state = helpAutomat.state) => state?.activeHelpId ?? null;
diff --git a/src/automats/index.js b/src/automats/index.js
index fabe0fa..0160b02 100644
--- a/src/automats/index.js
+++ b/src/automats/index.js
@@ -10,7 +10,15 @@ export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as helpAutomat } from './helpAutomat';
-export { toggleHelp, setHelpEnabled, isHelpEnabled } from './helpAutomat';
+export {
+ toggleHelp,
+ setHelpEnabled,
+ isHelpEnabled,
+ openHelpPopper,
+ closeHelpPopper,
+ toggleHelpPopper,
+ selectActiveHelpId,
+} from './helpAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as warehouseAutomat } from './warehouseAutomat';
diff --git a/src/components/HelpBadge.jsx b/src/components/HelpBadge.jsx
index 43df3ed..d11fa2c 100644
--- a/src/components/HelpBadge.jsx
+++ b/src/components/HelpBadge.jsx
@@ -9,34 +9,57 @@ import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import { helpAutomat } from '../automats';
+let nextHelpBadgeId = 0;
+
/**
* Reusable reactive badge that appears on UI elements (navigation icons, tabs, etc.)
* when systemwide Help mode is enabled via helpAutomat.
*
- * The red "?" badge does not move on hover.
+ * Can wrap an element (using MUI Badge) or render standalone with custom positioning.
+ * The badge is circular, red, and stays firmly in place.
* Clicking the "?" badge opens an explanatory Popper anchored to the badge.
- * When Help mode is disabled, it transparently renders its children with zero overhead.
+ * Only one help popper can be open at a time across the entire system.
+ * When Help mode is disabled, it transparently renders its children (or null) with zero overhead.
*/
class HelpBadge extends PureComponent {
constructor(props) {
super(props);
+ this.badgeRef = null;
+ this.helpId = props.id || `${props.title || 'help'}_${++nextHelpBadgeId}`;
+
+ const isInitiallyOpen = Boolean(
+ helpAutomat.state?.enabled &&
+ helpAutomat.state?.activeHelpId &&
+ helpAutomat.state.activeHelpId === this.helpId
+ );
+
this.state = {
enabled: helpAutomat.state?.enabled ?? false,
+ isOpen: isInitiallyOpen,
anchorEl: null,
};
+
this.handleBadgeClick = this.handleBadgeClick.bind(this);
this.handleBadgeKeyDown = this.handleBadgeKeyDown.bind(this);
this.handleClosePopper = this.handleClosePopper.bind(this);
}
componentDidMount() {
- this.unsub = helpAutomat.subscribe(this, (state) => ({
- enabled: Boolean(state?.enabled),
- anchorEl: state?.enabled ? this.state.anchorEl : null,
- }));
+ this.unsub = helpAutomat.subscribe(this, (state) => {
+ const enabled = Boolean(state?.enabled);
+ const isOpen = Boolean(enabled && state?.activeHelpId && state.activeHelpId === this.helpId);
+ return {
+ enabled,
+ isOpen,
+ anchorEl: isOpen ? (this.badgeRef || this.state.anchorEl) : null,
+ };
+ });
}
componentWillUnmount() {
+ if (this.state.isOpen) {
+ helpAutomat.actions.closePopper();
+ }
this.unsub?.();
this.unsub = null;
}
@@ -45,9 +68,10 @@ class HelpBadge extends PureComponent {
event.preventDefault();
event.stopPropagation();
const currentTarget = event.currentTarget;
- this.setState((prev) => ({
- anchorEl: prev.anchorEl ? null : currentTarget,
- }));
+ this.badgeRef = currentTarget;
+ this.setState({ anchorEl: currentTarget }, () => {
+ helpAutomat.actions.togglePopper(this.helpId);
+ });
}
handleBadgeKeyDown(event) {
@@ -55,25 +79,28 @@ class HelpBadge extends PureComponent {
event.preventDefault();
event.stopPropagation();
const currentTarget = event.currentTarget;
- this.setState((prev) => ({
- anchorEl: prev.anchorEl ? null : currentTarget,
- }));
- } else if (event.key === 'Escape' && this.state.anchorEl) {
+ this.badgeRef = currentTarget;
+ this.setState({ anchorEl: currentTarget }, () => {
+ helpAutomat.actions.togglePopper(this.helpId);
+ });
+ } else if (event.key === 'Escape' && this.state.isOpen) {
event.preventDefault();
event.stopPropagation();
- this.setState({ anchorEl: null });
+ helpAutomat.actions.closePopper();
}
}
handleClosePopper(event) {
- if (this.state.anchorEl && event?.target && this.state.anchorEl.contains(event.target)) {
+ if (this.badgeRef && event?.target && this.badgeRef.contains(event.target)) {
return;
}
- this.setState({ anchorEl: null });
+ if (this.state.isOpen) {
+ helpAutomat.actions.closePopper();
+ }
}
render() {
- const { enabled, anchorEl } = this.state;
+ const { enabled, isOpen, anchorEl } = this.state;
const {
children,
title,
@@ -89,16 +116,174 @@ class HelpBadge extends PureComponent {
const helpText = text || help;
if (!enabled || !helpText) {
- return children;
+ return children ?? null;
}
- const isOpen = Boolean(anchorEl);
+ const currentAnchor = anchorEl || this.badgeRef;
+ const isPopperVisible = Boolean(isOpen && currentAnchor);
+ const popperElement = (
+
+
+ {
+ if (e.key === 'Escape') this.handleClosePopper(e);
+ }}
+ sx={{
+ p: 1.5,
+ width: 260,
+ maxWidth: 'calc(100vw - 32px)',
+ bgcolor: 'background.paper',
+ borderRadius: 2,
+ border: '1px solid',
+ borderColor: 'divider',
+ boxShadow: '0 8px 28px rgba(0,0,0,0.2)',
+ }}
+ >
+
+
+
+ ?
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+
+
+
+
+
+ {helpText}
+
+
+
+
+ );
+
+ // Standalone badge (e.g. absolute corner placement inside a tab)
+ if (!children) {
+ return (
+ <>
+ {
+ this.badgeRef = el;
+ }}
+ role="button"
+ tabIndex={0}
+ aria-label={`${title ? `${title}: ` : ''}Hilfe anzeigen`}
+ title="Hilfe anzeigen"
+ onClick={this.handleBadgeClick}
+ onKeyDown={this.handleBadgeKeyDown}
+ sx={{
+ bgcolor: '#dc2626',
+ color: '#ffffff',
+ fontWeight: 800,
+ fontSize: '0.78rem',
+ width: 20,
+ height: 20,
+ minWidth: 20,
+ borderRadius: '50%',
+ boxShadow: '0 2px 5px rgba(0,0,0,0.35)',
+ cursor: 'pointer',
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ userSelect: 'none',
+ transition: 'background-color 0.15s ease',
+ '&:hover': {
+ bgcolor: '#b91c1c',
+ },
+ ...badgeSx,
+ ...sx,
+ }}
+ >
+ {badgeContent}
+
+ {popperElement}
+ >
+ );
+ }
+
+ // Wrapped element (e.g. icon in navigation sidebar)
return (
<>
{
+ this.badgeRef = el;
+ }}
role="button"
tabIndex={0}
aria-label={`${title ? `${title}: ` : ''}Hilfe anzeigen`}
@@ -113,6 +298,7 @@ class HelpBadge extends PureComponent {
height: '100%',
cursor: 'pointer',
userSelect: 'none',
+ borderRadius: '50%',
}}
>
{badgeContent}
@@ -125,10 +311,10 @@ class HelpBadge extends PureComponent {
bgcolor: '#dc2626',
color: '#ffffff',
fontWeight: 800,
- fontSize: '0.8rem',
- width: 22,
- height: 22,
- minWidth: 22,
+ fontSize: '0.78rem',
+ width: 20,
+ height: 20,
+ minWidth: 20,
borderRadius: '50%',
boxShadow: '0 2px 5px rgba(0,0,0,0.35)',
cursor: 'pointer',
@@ -144,113 +330,7 @@ class HelpBadge extends PureComponent {
>
{children}
-
-
-
- {
- if (e.key === 'Escape') this.handleClosePopper(e);
- }}
- sx={{
- p: 1.5,
- width: 260,
- maxWidth: 'calc(100vw - 32px)',
- bgcolor: 'background.paper',
- borderRadius: 2,
- border: '1px solid',
- borderColor: 'divider',
- boxShadow: '0 8px 28px rgba(0,0,0,0.2)',
- }}
- >
-
-
-
- ?
-
- {title ? (
-
- {title}
-
- ) : null}
-
-
-
-
-
-
- {helpText}
-
-
-
-
+ {popperElement}
>
);
}
diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx
index 4fdf834..5dfad09 100644
--- a/src/components/article/ArticleTileTab.jsx
+++ b/src/components/article/ArticleTileTab.jsx
@@ -282,19 +282,26 @@ class ArticleTileTab extends PureComponent {
key={row.id}
value={row.id}
aria-label={row.label}
- title={row.label}
icon={
-
+ <>
-
+
+ >
}
sx={{
minHeight: 56,
minWidth: 64,
+ position: 'relative',
py: 0.5,
px: 1.25,
border: 'none',