From ada455ea4f667f59323cad554c2c48fc5a638822 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 29 Sep 2026 10:51:34 +0200 Subject: [PATCH] u --- src/automats/helpAutomat.js | 54 +++- src/automats/index.js | 10 +- src/components/HelpBadge.jsx | 340 +++++++++++++--------- src/components/article/ArticleTileTab.jsx | 21 +- 4 files changed, 284 insertions(+), 141 deletions(-) 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',