From 5261aa11439afcf8c924af2de1c067ae3ec47ff7 Mon Sep 17 00:00:00 2001 From: Wilhelm Herbrich Date: Thu, 11 Jan 2024 14:18:24 +0100 Subject: [PATCH 1/8] Move window topbar content to menu on small screens --- src/components/WindowTopBar.js | 43 +------ src/components/WindowTopBarMenu.js | 156 +++++++++++++++++++++++ src/components/WindowTopBarPluginMenu.js | 125 ++++++++---------- src/containers/WindowTopBarMenu.js | 11 ++ 4 files changed, 224 insertions(+), 111 deletions(-) create mode 100644 src/components/WindowTopBarMenu.js create mode 100644 src/containers/WindowTopBarMenu.js diff --git a/src/components/WindowTopBar.js b/src/components/WindowTopBar.js index 5e32f8cd07..538ef65626 100644 --- a/src/components/WindowTopBar.js +++ b/src/components/WindowTopBar.js @@ -2,18 +2,11 @@ import { Component } from 'react'; import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; import MenuIcon from '@mui/icons-material/MenuSharp'; -import CloseIcon from '@mui/icons-material/CloseSharp'; import Toolbar from '@mui/material/Toolbar'; import AppBar from '@mui/material/AppBar'; import classNames from 'classnames'; -import WindowTopMenuButton from '../containers/WindowTopMenuButton'; -import WindowTopBarPluginArea from '../containers/WindowTopBarPluginArea'; -import WindowTopBarPluginMenu from '../containers/WindowTopBarPluginMenu'; -import WindowTopBarTitle from '../containers/WindowTopBarTitle'; +import WindowTopBarMenu from '../containers/WindowTopBarMenu'; import MiradorMenuButton from '../containers/MiradorMenuButton'; -import FullScreenButton from '../containers/FullScreenButton'; -import WindowMaxIcon from './icons/WindowMaxIcon'; -import WindowMinIcon from './icons/WindowMinIcon'; import ns from '../config/css-ns'; const Root = styled(AppBar, { name: 'WindowTopBar', slot: 'root' })(() => ({ @@ -42,10 +35,7 @@ export class WindowTopBar extends Component { */ render() { const { - removeWindow, windowId, toggleWindowSideBar, t, - maximizeWindow, maximized, minimizeWindow, allowClose, allowMaximize, - focusWindow, allowFullscreen, allowTopMenuButton, allowWindowSideBar, - component, + windowId, toggleWindowSideBar, t, focusWindow, allowWindowSideBar, component, } = this.props; return ( @@ -66,35 +56,10 @@ export class WindowTopBar extends Component { )} - - {allowTopMenuButton && ( - - )} - - - {allowMaximize && ( - - {(maximized ? : )} - - )} - {allowFullscreen && ( - - )} - {allowClose && ( - - - - )} ); diff --git a/src/components/WindowTopBarMenu.js b/src/components/WindowTopBarMenu.js new file mode 100644 index 0000000000..b117a174fc --- /dev/null +++ b/src/components/WindowTopBarMenu.js @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/CloseSharp'; +import classNames from 'classnames'; +import ResizeObserver from 'react-resize-observer'; +import { Portal } from '@mui/material'; +import WindowTopMenuButton from '../containers/WindowTopMenuButton'; +import WindowTopBarPluginArea from '../containers/WindowTopBarPluginArea'; +import WindowTopBarPluginMenu from '../containers/WindowTopBarPluginMenu'; +import WindowTopBarTitle from '../containers/WindowTopBarTitle'; +import MiradorMenuButton from '../containers/MiradorMenuButton'; +import FullScreenButton from '../containers/FullScreenButton'; +import WindowMaxIcon from './icons/WindowMaxIcon'; +import WindowMinIcon from './icons/WindowMinIcon'; +import ns from '../config/css-ns'; +import PluginContext from '../extend/PluginContext'; + +const IconButtonsWrapper = styled('div', ({}))(({}) => ({ + display: 'flex', +})); + +const InvisibleIconButtonsWrapper = styled(IconButtonsWrapper)(() => ({ + visibility: 'hidden', +})); + +/** + * WindowTopBarMenu + */ +export function WindowTopBarMenu(props) { + const { + removeWindow, windowId, t, maximizeWindow, maximized, minimizeWindow, + allowClose, allowMaximize, allowFullscreen, allowTopMenuButton, + } = props; + + const [outerW, setOuterW] = React.useState(); + const [visibleButtonsNum, setVisibleButtonsNum] = React.useState(0); + const iconButtonsWrapperRef = React.useRef(); + const pluginMap = React.useContext(PluginContext); + const portalRef = React.useRef(); + + const buttons = []; + if (pluginMap.WindowTopBarPluginArea?.add?.length > 0 + || pluginMap.WindowTopBarPluginArea?.wrap?.length > 0) { + buttons.push( + , + ); + } + + allowTopMenuButton && buttons.push( + , + ); + + allowMaximize && buttons.push( + + {(maximized ? : )} + , + ); + allowFullscreen && buttons.push( + , + ); + + const visibleButtons = buttons.slice(0, visibleButtonsNum); + const moreButtons = buttons.slice(visibleButtonsNum); + const moreButtonAlwaysShowing = pluginMap.WindowTopBarPluginMenu?.add?.length > 0 + || pluginMap.WindowTopBarPluginMenu?.wrap?.length > 0; + React.useEffect(() => { + if (!outerW || !portalRef?.current) { + return; + } + const children = Array.from(portalRef.current.childNodes ?? []); + let accWidth = 0; + // sum widths of top bar elements until wider than half of the available space + let newVisibleButtonsNum = children.reduce((acc, child) => { + const width = child?.offsetWidth; + accWidth += width; + if (accWidth < (0.5 * outerW)) { + return acc + 1; + } + return acc; + }, 0); + if (!moreButtonAlwaysShowing && children.length - newVisibleButtonsNum === 1) { + // when the WindowTopBarPluginMenu button is not always visible (== there are no WindowTopBarPluginMenu plugins) + // and only the first button would be hidden away on the next render + // (not changing the width, as the more button takes it's place), hide the first two buttons + newVisibleButtonsNum = Math.max(children.length - 2, 0); + } + setVisibleButtonsNum(newVisibleButtonsNum); + }, [outerW, moreButtonAlwaysShowing]); + + const showMoreButtons = moreButtonAlwaysShowing || moreButtons.length > 0; + + return ( + <> + + + {buttons} + + + { + // 96 to compensate for the burger menu button on the left and the close window button on the right + setOuterW(rect.width - 96); + }} + /> + + + {visibleButtons} + {showMoreButtons && ( + + )} + {allowClose && ( + + + + )} + + + ); +} + +WindowTopBarMenu.propTypes = { + allowClose: PropTypes.bool, + allowFullscreen: PropTypes.bool, + allowMaximize: PropTypes.bool, + allowTopMenuButton: PropTypes.bool, + container: PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + maximized: PropTypes.bool, + maximizeWindow: PropTypes.func, + minimizeWindow: PropTypes.func, + removeWindow: PropTypes.func.isRequired, + t: PropTypes.func, + windowId: PropTypes.string.isRequired, +}; + +WindowTopBarMenu.defaultProps = { + allowClose: true, + allowFullscreen: false, + allowMaximize: true, + allowTopMenuButton: true, + container: null, + maximized: false, + maximizeWindow: () => {}, + minimizeWindow: () => {}, + t: key => key, +}; diff --git a/src/components/WindowTopBarPluginMenu.js b/src/components/WindowTopBarPluginMenu.js index 972cbf544b..29cab7a5e9 100644 --- a/src/components/WindowTopBarPluginMenu.js +++ b/src/components/WindowTopBarPluginMenu.js @@ -1,96 +1,76 @@ -import { Component } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import MoreVertIcon from '@mui/icons-material/MoreVertSharp'; import Menu from '@mui/material/Menu'; -import MiradorMenuButton from '../containers/MiradorMenuButton'; +import MoreVertIcon from '@mui/icons-material/MoreVertSharp'; import { PluginHook } from './PluginHook'; +import MiradorMenuButton from '../containers/MiradorMenuButton'; /** * */ -export class WindowTopBarPluginMenu extends Component { - /** - * constructor - - */ - constructor(props) { - super(props); - this.state = { - anchorEl: null, - open: false, - }; - this.handleMenuClick = this.handleMenuClick.bind(this); - this.handleMenuClose = this.handleMenuClose.bind(this); - } - +export function WindowTopBarPluginMenu(props) { + const [anchorEl, setAnchorEl] = React.useState(null); + const [open, setOpen] = React.useState(false); /** * Set the anchorEl state to the click target - */ - handleMenuClick(event) { - this.setState({ - anchorEl: event.currentTarget, - open: true, - }); - } + */ + const handleMenuClick = (event) => { + setAnchorEl(event.currentTarget); + setOpen(true); + }; /** * Set the anchorEl state to null (closing the menu) */ - handleMenuClose() { - this.setState({ - anchorEl: null, - open: false, - }); - } - - /** - * render component - */ - render() { - const { - container, PluginComponents, t, windowId, menuIcon, - } = this.props; - const { anchorEl, open } = this.state; - const windowPluginMenuId = `window-plugin-menu_${windowId}`; - if (!PluginComponents || PluginComponents.length === 0) return null; + const handleMenuClose = () => { + setAnchorEl(null); + setOpen(false); + }; - return ( - <> - - {menuIcon} - + const { + windowId, t, menuIcon, container, moreButtons, + } = props; - this.handleMenuClose()} - > - this.handleMenuClose()} {...this.props} /> - - - ); - } + const windowPluginMenuId = `window-plugin-menu_${windowId}`; + return ( + <> + + {menuIcon} + + + {moreButtons} + + + + ); } +// "/**/__tests__/integration/mirador/plugins/add.test.js" WindowTopBarPluginMenu.propTypes = { anchorEl: PropTypes.object, // eslint-disable-line react/forbid-prop-types container: PropTypes.shape({ current: PropTypes.instanceOf(Element) }), menuIcon: PropTypes.element, + moreButtons: PropTypes.element, open: PropTypes.bool, PluginComponents: PropTypes.arrayOf( PropTypes.node, @@ -103,6 +83,7 @@ WindowTopBarPluginMenu.defaultProps = { anchorEl: null, container: null, menuIcon: , + moreButtons: null, open: false, PluginComponents: [], }; diff --git a/src/containers/WindowTopBarMenu.js b/src/containers/WindowTopBarMenu.js new file mode 100644 index 0000000000..d6df154576 --- /dev/null +++ b/src/containers/WindowTopBarMenu.js @@ -0,0 +1,11 @@ +import { compose } from 'redux'; +import { withTranslation } from 'react-i18next'; +import { withPlugins } from '../extend/withPlugins'; +import { WindowTopBarMenu } from '../components/WindowTopBarMenu'; + +const enhance = compose( + withTranslation(), + withPlugins('WindowTopBarMenu'), +); + +export default enhance(WindowTopBarMenu); From d056de11737c709b277b6b2be74ae7e676275697 Mon Sep 17 00:00:00 2001 From: Wilhelm Herbrich Date: Thu, 11 Jan 2024 14:30:15 +0100 Subject: [PATCH 2/8] Fix reading from undefined error --- src/components/WindowTopBarMenu.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/WindowTopBarMenu.js b/src/components/WindowTopBarMenu.js index b117a174fc..6b71b9fdfd 100644 --- a/src/components/WindowTopBarMenu.js +++ b/src/components/WindowTopBarMenu.js @@ -40,8 +40,8 @@ export function WindowTopBarMenu(props) { const portalRef = React.useRef(); const buttons = []; - if (pluginMap.WindowTopBarPluginArea?.add?.length > 0 - || pluginMap.WindowTopBarPluginArea?.wrap?.length > 0) { + if (pluginMap?.WindowTopBarPluginArea?.add?.length > 0 + || pluginMap?.WindowTopBarPluginArea?.wrap?.length > 0) { buttons.push( , ); @@ -66,8 +66,8 @@ export function WindowTopBarMenu(props) { const visibleButtons = buttons.slice(0, visibleButtonsNum); const moreButtons = buttons.slice(visibleButtonsNum); - const moreButtonAlwaysShowing = pluginMap.WindowTopBarPluginMenu?.add?.length > 0 - || pluginMap.WindowTopBarPluginMenu?.wrap?.length > 0; + const moreButtonAlwaysShowing = pluginMap?.WindowTopBarPluginMenu?.add?.length > 0 + || pluginMap?.WindowTopBarPluginMenu?.wrap?.length > 0; React.useEffect(() => { if (!outerW || !portalRef?.current) { return; From 58fd689b5fb156190f80cc1bef38648d173c1ff7 Mon Sep 17 00:00:00 2001 From: Wilhelm Herbrich Date: Thu, 11 Jan 2024 17:43:29 +0100 Subject: [PATCH 3/8] Fix WindowTopBarPluginMenu test --- .../components/WindowTopBarPluginMenu.test.js | 3 ++- src/components/WindowTopBarMenu.js | 22 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/__tests__/src/components/WindowTopBarPluginMenu.test.js b/__tests__/src/components/WindowTopBarPluginMenu.test.js index 2d887db324..0333724a94 100644 --- a/__tests__/src/components/WindowTopBarPluginMenu.test.js +++ b/__tests__/src/components/WindowTopBarPluginMenu.test.js @@ -2,6 +2,7 @@ import { render, screen } from 'test-utils'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { WindowTopBarMenu } from '../../../src/components/WindowTopBarMenu'; import { WindowTopBarPluginMenu } from '../../../src/components/WindowTopBarPluginMenu'; /** create wrapper */ @@ -28,7 +29,7 @@ class mockComponentA extends React.Component { describe('WindowTopBarPluginMenu', () => { describe('when there are no plugins present', () => { it('renders nothing (and no Button/Menu/PluginHook)', () => { - render(); + render(); expect(screen.queryByTestId('testA')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'windowPluginMenu' })).not.toBeInTheDocument(); }); diff --git a/src/components/WindowTopBarMenu.js b/src/components/WindowTopBarMenu.js index 6b71b9fdfd..0fcd6bbceb 100644 --- a/src/components/WindowTopBarMenu.js +++ b/src/components/WindowTopBarMenu.js @@ -43,16 +43,21 @@ export function WindowTopBarMenu(props) { if (pluginMap?.WindowTopBarPluginArea?.add?.length > 0 || pluginMap?.WindowTopBarPluginArea?.wrap?.length > 0) { buttons.push( - , + , ); } allowTopMenuButton && buttons.push( - , + , ); allowMaximize && buttons.push( , ); allowFullscreen && buttons.push( - , + , ); const visibleButtons = buttons.slice(0, visibleButtonsNum); @@ -69,16 +77,16 @@ export function WindowTopBarMenu(props) { const moreButtonAlwaysShowing = pluginMap?.WindowTopBarPluginMenu?.add?.length > 0 || pluginMap?.WindowTopBarPluginMenu?.wrap?.length > 0; React.useEffect(() => { - if (!outerW || !portalRef?.current) { + if (outerW === undefined || !portalRef?.current) { return; } const children = Array.from(portalRef.current.childNodes ?? []); let accWidth = 0; // sum widths of top bar elements until wider than half of the available space - let newVisibleButtonsNum = children.reduce((acc, child) => { + let newVisibleButtonsNum = children.reduce((acc, child, index) => { const width = child?.offsetWidth; accWidth += width; - if (accWidth < (0.5 * outerW)) { + if (accWidth <= (0.5 * outerW)) { return acc + 1; } return acc; @@ -104,7 +112,7 @@ export function WindowTopBarMenu(props) { { // 96 to compensate for the burger menu button on the left and the close window button on the right - setOuterW(rect.width - 96); + setOuterW(Math.max(rect.width - 96, 0)); }} /> Date: Tue, 23 Jan 2024 13:46:06 +0100 Subject: [PATCH 4/8] Fix WindowTopBarPluginMenu test --- src/components/WindowTopBarMenu.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/WindowTopBarMenu.js b/src/components/WindowTopBarMenu.js index 0fcd6bbceb..84e7059a08 100644 --- a/src/components/WindowTopBarMenu.js +++ b/src/components/WindowTopBarMenu.js @@ -24,6 +24,18 @@ const InvisibleIconButtonsWrapper = styled(IconButtonsWrapper)(() => ({ visibility: 'hidden', })); +/** + * removeAttributes + */ +const removeAttributes = (attributeList = [], node) => { + /* remove the named attributes */ + if (node.removeAttribute) { + attributeList.map(attr => node.removeAttribute(attr)); + } + /* call this function for each child node recursively */ + if (node.childNodes) node.childNodes.forEach(child => removeAttributes(attributeList, child)); +}; + /** * WindowTopBarMenu */ @@ -80,6 +92,7 @@ export function WindowTopBarMenu(props) { if (outerW === undefined || !portalRef?.current) { return; } + removeAttributes(['data-testid'], portalRef.current); const children = Array.from(portalRef.current.childNodes ?? []); let accWidth = 0; // sum widths of top bar elements until wider than half of the available space From 029bb2f746b87c54c7244ee06c951f6e18a44a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerd=20M=C3=BCller?= Date: Tue, 7 Jan 2025 16:27:49 +0100 Subject: [PATCH 5/8] Adjust to functional components --- src/components/WindowTopBar.js | 71 ++++++++++-------------- src/components/WindowTopBarMenu.js | 32 ++++------- src/components/WindowTopBarPluginMenu.js | 37 ++++++------ 3 files changed, 56 insertions(+), 84 deletions(-) diff --git a/src/components/WindowTopBar.js b/src/components/WindowTopBar.js index 07d216c0b9..64c9cafc20 100644 --- a/src/components/WindowTopBar.js +++ b/src/components/WindowTopBar.js @@ -28,57 +28,44 @@ const StyledToolbar = styled(Toolbar, { name: 'WindowTopBar', slot: 'toolbar' }) /** * WindowTopBar */ -export class WindowTopBar extends Component { - /** - * render - * @return - */ - render() { - const { - windowId, toggleWindowSideBar, t, focusWindow, allowWindowSideBar, component, - } = this.props; +export function WindowTopBar({ + windowId, toggleWindowSideBar, focusWindow = () => {}, allowWindowSideBar = true, component = 'nav', +}) { + const { t } = useTranslation(); + const ownerState = arguments[0]; // eslint-disable-line prefer-rest-params - return ( - - + + {allowWindowSideBar && ( + - {allowWindowSideBar && ( - - - - )} - - - - ); - } + + + )} + + + + ); } WindowTopBar.propTypes = { - allowClose: PropTypes.bool, - allowFullscreen: PropTypes.bool, - allowMaximize: PropTypes.bool, - allowTopMenuButton: PropTypes.bool, allowWindowSideBar: PropTypes.bool, component: PropTypes.elementType, focused: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types focusWindow: PropTypes.func, - maximized: PropTypes.bool, - maximizeWindow: PropTypes.func, - minimizeWindow: PropTypes.func, - removeWindow: PropTypes.func.isRequired, toggleWindowSideBar: PropTypes.func.isRequired, windowDraggable: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types windowId: PropTypes.string.isRequired, diff --git a/src/components/WindowTopBarMenu.js b/src/components/WindowTopBarMenu.js index 84e7059a08..d182ef5ae3 100644 --- a/src/components/WindowTopBarMenu.js +++ b/src/components/WindowTopBarMenu.js @@ -5,6 +5,7 @@ import CloseIcon from '@mui/icons-material/CloseSharp'; import classNames from 'classnames'; import ResizeObserver from 'react-resize-observer'; import { Portal } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import WindowTopMenuButton from '../containers/WindowTopMenuButton'; import WindowTopBarPluginArea from '../containers/WindowTopBarPluginArea'; import WindowTopBarPluginMenu from '../containers/WindowTopBarPluginMenu'; @@ -16,9 +17,9 @@ import WindowMinIcon from './icons/WindowMinIcon'; import ns from '../config/css-ns'; import PluginContext from '../extend/PluginContext'; -const IconButtonsWrapper = styled('div', ({}))(({}) => ({ +const IconButtonsWrapper = styled('div')({ display: 'flex', -})); +}); const InvisibleIconButtonsWrapper = styled(IconButtonsWrapper)(() => ({ visibility: 'hidden', @@ -39,11 +40,13 @@ const removeAttributes = (attributeList = [], node) => { /** * WindowTopBarMenu */ -export function WindowTopBarMenu(props) { - const { - removeWindow, windowId, t, maximizeWindow, maximized, minimizeWindow, - allowClose, allowMaximize, allowFullscreen, allowTopMenuButton, - } = props; +export function WindowTopBarMenu({ + removeWindow, windowId, + maximizeWindow = () => {}, maximized = false, minimizeWindow = () => {}, allowClose = true, allowMaximize = true, + allowFullscreen = false, allowTopMenuButton = true + , +}) { + const { t } = useTranslation(); const [outerW, setOuterW] = React.useState(); const [visibleButtonsNum, setVisibleButtonsNum] = React.useState(0); @@ -96,7 +99,7 @@ export function WindowTopBarMenu(props) { const children = Array.from(portalRef.current.childNodes ?? []); let accWidth = 0; // sum widths of top bar elements until wider than half of the available space - let newVisibleButtonsNum = children.reduce((acc, child, index) => { + let newVisibleButtonsNum = children.reduce((acc, child) => { const width = child?.offsetWidth; accWidth += width; if (accWidth <= (0.5 * outerW)) { @@ -160,18 +163,5 @@ WindowTopBarMenu.propTypes = { maximizeWindow: PropTypes.func, minimizeWindow: PropTypes.func, removeWindow: PropTypes.func.isRequired, - t: PropTypes.func, windowId: PropTypes.string.isRequired, }; - -WindowTopBarMenu.defaultProps = { - allowClose: true, - allowFullscreen: false, - allowMaximize: true, - allowTopMenuButton: true, - container: null, - maximized: false, - maximizeWindow: () => {}, - minimizeWindow: () => {}, - t: key => key, -}; diff --git a/src/components/WindowTopBarPluginMenu.js b/src/components/WindowTopBarPluginMenu.js index 7c5bb2629b..4408bbb923 100644 --- a/src/components/WindowTopBarPluginMenu.js +++ b/src/components/WindowTopBarPluginMenu.js @@ -1,16 +1,23 @@ -import React from 'react'; +import { useContext, useState } from 'react'; import PropTypes from 'prop-types'; -import Menu from '@mui/material/Menu'; import MoreVertIcon from '@mui/icons-material/MoreVertSharp'; -import { PluginHook } from './PluginHook'; +import Menu from '@mui/material/Menu'; +import { useTranslation } from 'react-i18next'; import MiradorMenuButton from '../containers/MiradorMenuButton'; +import { PluginHook } from './PluginHook'; +import WorkspaceContext from '../contexts/WorkspaceContext'; /** * */ -export function WindowTopBarPluginMenu(props) { - const [anchorEl, setAnchorEl] = React.useState(null); - const [open, setOpen] = React.useState(false); +export function WindowTopBarPluginMenu({ + PluginComponents = [], windowId, menuIcon = , moreButtons = null, +}) { + const { t } = useTranslation(); + const container = useContext(WorkspaceContext); + const pluginProps = arguments[0]; // eslint-disable-line prefer-rest-params + const [anchorEl, setAnchorEl] = useState(null); + const [open, setOpen] = useState(false); /** * Set the anchorEl state to the click target */ @@ -27,11 +34,9 @@ export function WindowTopBarPluginMenu(props) { setOpen(false); }; - const { - windowId, t, menuIcon, container, moreButtons, - } = props; - const windowPluginMenuId = `window-plugin-menu_${windowId}`; + if (!PluginComponents || PluginComponents.length === 0) return null; + return ( <> {moreButtons} - + ); } -// "/**/__tests__/integration/mirador/plugins/add.test.js" WindowTopBarPluginMenu.propTypes = { anchorEl: PropTypes.object, // eslint-disable-line react/forbid-prop-types @@ -77,12 +81,3 @@ WindowTopBarPluginMenu.propTypes = { ), windowId: PropTypes.string.isRequired, }; - -WindowTopBarPluginMenu.defaultProps = { - anchorEl: null, - container: null, - menuIcon: , - moreButtons: null, - open: false, - PluginComponents: [], -}; From f245ff2f719405b25b4594e68b8f2abb24bf5025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerd=20M=C3=BCller?= Date: Thu, 20 Mar 2025 15:11:38 +0100 Subject: [PATCH 6/8] Refactor menu component --- src/components/WindowTopBarMenu.js | 167 ---------------------- src/components/WindowTopBarMenu.jsx | 134 +++++++++++++++++ src/components/WindowTopBarPluginMenu.jsx | 2 - src/containers/WindowTopBar.js | 8 -- src/containers/WindowTopBarMenu.js | 28 ++++ 5 files changed, 162 insertions(+), 177 deletions(-) delete mode 100644 src/components/WindowTopBarMenu.js create mode 100644 src/components/WindowTopBarMenu.jsx diff --git a/src/components/WindowTopBarMenu.js b/src/components/WindowTopBarMenu.js deleted file mode 100644 index d182ef5ae3..0000000000 --- a/src/components/WindowTopBarMenu.js +++ /dev/null @@ -1,167 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { styled } from '@mui/material/styles'; -import CloseIcon from '@mui/icons-material/CloseSharp'; -import classNames from 'classnames'; -import ResizeObserver from 'react-resize-observer'; -import { Portal } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import WindowTopMenuButton from '../containers/WindowTopMenuButton'; -import WindowTopBarPluginArea from '../containers/WindowTopBarPluginArea'; -import WindowTopBarPluginMenu from '../containers/WindowTopBarPluginMenu'; -import WindowTopBarTitle from '../containers/WindowTopBarTitle'; -import MiradorMenuButton from '../containers/MiradorMenuButton'; -import FullScreenButton from '../containers/FullScreenButton'; -import WindowMaxIcon from './icons/WindowMaxIcon'; -import WindowMinIcon from './icons/WindowMinIcon'; -import ns from '../config/css-ns'; -import PluginContext from '../extend/PluginContext'; - -const IconButtonsWrapper = styled('div')({ - display: 'flex', -}); - -const InvisibleIconButtonsWrapper = styled(IconButtonsWrapper)(() => ({ - visibility: 'hidden', -})); - -/** - * removeAttributes - */ -const removeAttributes = (attributeList = [], node) => { - /* remove the named attributes */ - if (node.removeAttribute) { - attributeList.map(attr => node.removeAttribute(attr)); - } - /* call this function for each child node recursively */ - if (node.childNodes) node.childNodes.forEach(child => removeAttributes(attributeList, child)); -}; - -/** - * WindowTopBarMenu - */ -export function WindowTopBarMenu({ - removeWindow, windowId, - maximizeWindow = () => {}, maximized = false, minimizeWindow = () => {}, allowClose = true, allowMaximize = true, - allowFullscreen = false, allowTopMenuButton = true - , -}) { - const { t } = useTranslation(); - - const [outerW, setOuterW] = React.useState(); - const [visibleButtonsNum, setVisibleButtonsNum] = React.useState(0); - const iconButtonsWrapperRef = React.useRef(); - const pluginMap = React.useContext(PluginContext); - const portalRef = React.useRef(); - - const buttons = []; - if (pluginMap?.WindowTopBarPluginArea?.add?.length > 0 - || pluginMap?.WindowTopBarPluginArea?.wrap?.length > 0) { - buttons.push( - , - ); - } - - allowTopMenuButton && buttons.push( - , - ); - - allowMaximize && buttons.push( - - {(maximized ? : )} - , - ); - allowFullscreen && buttons.push( - , - ); - - const visibleButtons = buttons.slice(0, visibleButtonsNum); - const moreButtons = buttons.slice(visibleButtonsNum); - const moreButtonAlwaysShowing = pluginMap?.WindowTopBarPluginMenu?.add?.length > 0 - || pluginMap?.WindowTopBarPluginMenu?.wrap?.length > 0; - React.useEffect(() => { - if (outerW === undefined || !portalRef?.current) { - return; - } - removeAttributes(['data-testid'], portalRef.current); - const children = Array.from(portalRef.current.childNodes ?? []); - let accWidth = 0; - // sum widths of top bar elements until wider than half of the available space - let newVisibleButtonsNum = children.reduce((acc, child) => { - const width = child?.offsetWidth; - accWidth += width; - if (accWidth <= (0.5 * outerW)) { - return acc + 1; - } - return acc; - }, 0); - if (!moreButtonAlwaysShowing && children.length - newVisibleButtonsNum === 1) { - // when the WindowTopBarPluginMenu button is not always visible (== there are no WindowTopBarPluginMenu plugins) - // and only the first button would be hidden away on the next render - // (not changing the width, as the more button takes it's place), hide the first two buttons - newVisibleButtonsNum = Math.max(children.length - 2, 0); - } - setVisibleButtonsNum(newVisibleButtonsNum); - }, [outerW, moreButtonAlwaysShowing]); - - const showMoreButtons = moreButtonAlwaysShowing || moreButtons.length > 0; - - return ( - <> - - - {buttons} - - - { - // 96 to compensate for the burger menu button on the left and the close window button on the right - setOuterW(Math.max(rect.width - 96, 0)); - }} - /> - - - {visibleButtons} - {showMoreButtons && ( - - )} - {allowClose && ( - - - - )} - - - ); -} - -WindowTopBarMenu.propTypes = { - allowClose: PropTypes.bool, - allowFullscreen: PropTypes.bool, - allowMaximize: PropTypes.bool, - allowTopMenuButton: PropTypes.bool, - container: PropTypes.shape({ current: PropTypes.instanceOf(Element) }), - maximized: PropTypes.bool, - maximizeWindow: PropTypes.func, - minimizeWindow: PropTypes.func, - removeWindow: PropTypes.func.isRequired, - windowId: PropTypes.string.isRequired, -}; diff --git a/src/components/WindowTopBarMenu.jsx b/src/components/WindowTopBarMenu.jsx new file mode 100644 index 0000000000..ab66791b1f --- /dev/null +++ b/src/components/WindowTopBarMenu.jsx @@ -0,0 +1,134 @@ +import React, { + useState, useRef, useEffect, useContext, +} from 'react'; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/CloseSharp'; +import classNames from 'classnames'; +import ResizeObserver from 'react-resize-observer'; +import { Portal } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import WindowTopMenuButton from '../containers/WindowTopMenuButton'; +import WindowTopBarPluginArea from '../containers/WindowTopBarPluginArea'; +import WindowTopBarPluginMenu from '../containers/WindowTopBarPluginMenu'; +import WindowTopBarTitle from '../containers/WindowTopBarTitle'; +import MiradorMenuButton from '../containers/MiradorMenuButton'; +import FullScreenButton from '../containers/FullScreenButton'; +import WindowMaxIcon from './icons/WindowMaxIcon'; +import WindowMinIcon from './icons/WindowMinIcon'; +import ns from '../config/css-ns'; +import PluginContext from '../extend/PluginContext'; + +const IconButtonsWrapper = styled('div')({ display: 'flex' }); +const InvisibleIconButtonsWrapper = styled(IconButtonsWrapper)({ visibility: 'hidden' }); + +/** + * removeAttributes + */ +const removeAttributes = (attributes = [], node) => { + if (!node) return; + attributes.forEach(attr => node.removeAttribute?.(attr)); + node.childNodes?.forEach(child => removeAttributes(attributes, child)); +}; + +/** + * WindowTopBarMenu + */ +export function WindowTopBarMenu({ + removeWindow, windowId, + maximizeWindow = () => {}, maximized = false, minimizeWindow = () => {}, + allowClose = true, allowMaximize = true, allowFullscreen = false, allowTopMenuButton = true, +}) { + const { t } = useTranslation(); + const [outerW, setOuterW] = useState(); + const [visibleButtonsNum, setVisibleButtonsNum] = useState(0); + const iconButtonsWrapperRef = useRef(); + const pluginMap = useContext(PluginContext); + const portalRef = useRef(); + + const buttons = [ + (pluginMap?.WindowTopBarPluginArea?.add?.length > 0 || pluginMap?.WindowTopBarPluginArea?.wrap?.length > 0) + && , + allowTopMenuButton + && , + allowMaximize + && ( + + {maximized ? : } + + ), + allowFullscreen + && , + ].filter(Boolean); + + const visibleButtons = buttons.slice(0, visibleButtonsNum); + const moreButtons = buttons.slice(visibleButtonsNum); + const moreButtonAlwaysShowing = pluginMap?.WindowTopBarPluginMenu?.add?.length > 0 + || pluginMap?.WindowTopBarPluginMenu?.wrap?.length > 0; + + useEffect(() => { + if (!portalRef.current || outerW === undefined) return; + removeAttributes(['data-testid'], portalRef.current); + const children = Array.from(portalRef.current.childNodes || []); + let accWidth = 0; + // sum widths of top bar elements until wider than half of the available space + let newVisibleButtonsNum = children.reduce((count, child) => { + const width = child?.offsetWidth || 0; + accWidth += width; + return accWidth <= outerW * 0.5 ? count + 1 : count; + }, 0); + + if (!moreButtonAlwaysShowing && children.length - newVisibleButtonsNum === 1) { + // when the WindowTopBarPluginMenu button is not always visible (== there are no WindowTopBarPluginMenu plugins) + // and only the first button would be hidden away on the next render + // (not changing the width, as the more button takes it's place), hide the first two buttons + newVisibleButtonsNum = Math.max(children.length - 2, 0); + } + setVisibleButtonsNum(newVisibleButtonsNum); + }, [outerW, moreButtonAlwaysShowing]); + + return ( + <> + + {buttons} + + setOuterW(Math.max(width - 96, 0))} + /> + + + {visibleButtons} + {(moreButtonAlwaysShowing || moreButtons.length > 0) && ( + + )} + {allowClose && ( + + + + )} + + + ); +} + +WindowTopBarMenu.propTypes = { + allowClose: PropTypes.bool, + allowFullscreen: PropTypes.bool, + allowMaximize: PropTypes.bool, + allowTopMenuButton: PropTypes.bool, + maximized: PropTypes.bool, + maximizeWindow: PropTypes.func, + minimizeWindow: PropTypes.func, + removeWindow: PropTypes.func.isRequired, + windowId: PropTypes.string.isRequired, +}; diff --git a/src/components/WindowTopBarPluginMenu.jsx b/src/components/WindowTopBarPluginMenu.jsx index b6919d0937..890548b476 100644 --- a/src/components/WindowTopBarPluginMenu.jsx +++ b/src/components/WindowTopBarPluginMenu.jsx @@ -36,8 +36,6 @@ export function WindowTopBarPluginMenu({ setOpen(false); }; - if (!PluginComponents || PluginComponents.length === 0) return null; - return ( <> { const config = getWindowConfig(state, { windowId }); return { - allowClose: config.allowClose, - allowFullscreen: config.allowFullscreen, - allowMaximize: config.allowMaximize, - allowTopMenuButton: config.allowTopMenuButton, allowWindowSideBar: config.allowWindowSideBar, focused: isFocused(state, { windowId }), - maximized: config.maximized, }; }; @@ -27,9 +22,6 @@ const mapStateToProps = (state, { windowId }) => { */ const mapDispatchToProps = (dispatch, { windowId }) => ({ focusWindow: () => dispatch(actions.focusWindow(windowId)), - maximizeWindow: () => dispatch(actions.maximizeWindow(windowId)), - minimizeWindow: () => dispatch(actions.minimizeWindow(windowId)), - removeWindow: () => dispatch(actions.removeWindow(windowId)), toggleWindowSideBar: () => dispatch(actions.toggleWindowSideBar(windowId)), }); diff --git a/src/containers/WindowTopBarMenu.js b/src/containers/WindowTopBarMenu.js index d6df154576..73669e9cdd 100644 --- a/src/containers/WindowTopBarMenu.js +++ b/src/containers/WindowTopBarMenu.js @@ -1,9 +1,37 @@ import { compose } from 'redux'; +import { connect } from 'react-redux'; import { withTranslation } from 'react-i18next'; import { withPlugins } from '../extend/withPlugins'; +import * as actions from '../state/actions'; +import { getWindowConfig } from '../state/selectors'; import { WindowTopBarMenu } from '../components/WindowTopBarMenu'; +/** mapStateToProps */ +const mapStateToProps = (state, { windowId }) => { + const config = getWindowConfig(state, { windowId }); + + return { + allowClose: config.allowClose, + allowFullscreen: config.allowFullscreen, + allowMaximize: config.allowMaximize, + allowTopMenuButton: config.allowTopMenuButton, + maximized: config.maximized, + }; +}; + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = (dispatch, { windowId }) => ({ + maximizeWindow: () => dispatch(actions.maximizeWindow(windowId)), + minimizeWindow: () => dispatch(actions.minimizeWindow(windowId)), + removeWindow: () => dispatch(actions.removeWindow(windowId)), +}); + const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), withTranslation(), withPlugins('WindowTopBarMenu'), ); From e32548f9507d3f90b17f600d84335782000b74f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerd=20M=C3=BCller?= Date: Mon, 31 Mar 2025 13:17:40 +0200 Subject: [PATCH 7/8] Refactor plugin menu rendering --- src/components/WindowTopBarPluginMenu.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/WindowTopBarPluginMenu.jsx b/src/components/WindowTopBarPluginMenu.jsx index 890548b476..f7524c0221 100644 --- a/src/components/WindowTopBarPluginMenu.jsx +++ b/src/components/WindowTopBarPluginMenu.jsx @@ -36,6 +36,8 @@ export function WindowTopBarPluginMenu({ setOpen(false); }; + if (!moreButtons && (!PluginComponents || PluginComponents.length === 0)) return null; + return ( <> Date: Mon, 31 Mar 2025 15:18:16 +0200 Subject: [PATCH 8/8] Adjust window top bar prop testing --- .../mirador/tests/plugin-add.test.js | 2 +- __tests__/src/components/WindowTopBar.test.js | 18 -------- .../src/components/WindowTopBarMenu.test.js | 42 +++++++++++++++++++ src/components/WindowTopBar.jsx | 16 ++++++- src/components/WindowTopBarMenu.jsx | 10 ++--- src/containers/WindowTopBar.js | 5 +++ src/containers/WindowTopBarMenu.js | 8 +--- 7 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 __tests__/src/components/WindowTopBarMenu.test.js diff --git a/__tests__/integration/mirador/tests/plugin-add.test.js b/__tests__/integration/mirador/tests/plugin-add.test.js index 90842c96c0..76e3d1307c 100644 --- a/__tests__/integration/mirador/tests/plugin-add.test.js +++ b/__tests__/integration/mirador/tests/plugin-add.test.js @@ -11,7 +11,7 @@ describe('add two plugins to ', () => { it('all add plugins are present', async () => { expect(await screen.findByText('Plugin A')).toBeInTheDocument(); expect(await screen.findByText('Plugin B')).toBeInTheDocument(); - expect(screen.getByDisplayValue('hello componentD')).toBeInTheDocument(); + expect(screen.getAllByDisplayValue('hello componentD').length).toBeGreaterThan(0); }); it('wrapped and added plugins are present', async () => { diff --git a/__tests__/src/components/WindowTopBar.test.js b/__tests__/src/components/WindowTopBar.test.js index 5522ac0d6b..949cc3effd 100644 --- a/__tests__/src/components/WindowTopBar.test.js +++ b/__tests__/src/components/WindowTopBar.test.js @@ -75,24 +75,6 @@ describe('WindowTopBar', () => { expect(toggleWindowSideBar).toHaveBeenCalledTimes(1); }); - it('passes correct callback to closeWindow button', async () => { - const removeWindow = vi.fn(); - render(); - const button = screen.getByRole('button', { name: 'Close window' }); - expect(button).toBeInTheDocument(); - await user.click(button); - expect(removeWindow).toHaveBeenCalledTimes(1); - }); - - it('passes correct callback to maximizeWindow button', async () => { - const maximizeWindow = vi.fn(); - render(); - const button = screen.getByRole('button', { name: 'Maximize window' }); - expect(button).toBeInTheDocument(); - await user.click(button); - expect(maximizeWindow).toHaveBeenCalledTimes(1); - }); - it('close button is configurable', () => { render(); const button = screen.queryByRole('button', { name: 'Close window' }); diff --git a/__tests__/src/components/WindowTopBarMenu.test.js b/__tests__/src/components/WindowTopBarMenu.test.js new file mode 100644 index 0000000000..f4da0969ec --- /dev/null +++ b/__tests__/src/components/WindowTopBarMenu.test.js @@ -0,0 +1,42 @@ +import { screen, render } from '@tests/utils/test-utils'; +import userEvent from '@testing-library/user-event'; +import { WindowTopBarMenu } from '../../../src/components/WindowTopBarMenu'; + +/** create wrapper */ +function Subject({ ...props }) { + return ( + {}} + minimizeWindow={() => {}} + removeWindow={() => {}} + {...props} + /> + ); +} + +describe('WindowTopBarMenu', () => { + let user; + beforeEach(() => { + user = userEvent.setup(); + }); + + it('passes correct callback to closeWindow button', async () => { + const removeWindow = vi.fn(); + render(); + const button = screen.getByRole('button', { name: 'Close window' }); + expect(button).toBeInTheDocument(); + await user.click(button); + expect(removeWindow).toHaveBeenCalledTimes(1); + }); + + it('passes correct callback to maximizeWindow button', async () => { + const maximizeWindow = vi.fn(); + render(); + const button = screen.getByRole('button', { name: 'Maximize window' }); + expect(button).toBeInTheDocument(); + await user.click(button); + expect(maximizeWindow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/WindowTopBar.jsx b/src/components/WindowTopBar.jsx index 64c9cafc20..48fe5f3a76 100644 --- a/src/components/WindowTopBar.jsx +++ b/src/components/WindowTopBar.jsx @@ -29,7 +29,9 @@ const StyledToolbar = styled(Toolbar, { name: 'WindowTopBar', slot: 'toolbar' }) * WindowTopBar */ export function WindowTopBar({ - windowId, toggleWindowSideBar, focusWindow = () => {}, allowWindowSideBar = true, component = 'nav', + windowId, toggleWindowSideBar, maximized = false, allowClose = true, allowMaximize = true, + focusWindow = () => {}, allowFullscreen = false, allowTopMenuButton = true, allowWindowSideBar = true, + component = 'nav', }) { const { t } = useTranslation(); const ownerState = arguments[0]; // eslint-disable-line prefer-rest-params @@ -53,8 +55,13 @@ export function WindowTopBar({ )} @@ -62,10 +69,15 @@ export function WindowTopBar({ } WindowTopBar.propTypes = { + allowClose: PropTypes.bool, + allowFullscreen: PropTypes.bool, + allowMaximize: PropTypes.bool, + allowTopMenuButton: PropTypes.bool, allowWindowSideBar: PropTypes.bool, component: PropTypes.elementType, focused: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types focusWindow: PropTypes.func, + maximized: PropTypes.bool, toggleWindowSideBar: PropTypes.func.isRequired, windowDraggable: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types windowId: PropTypes.string.isRequired, diff --git a/src/components/WindowTopBarMenu.jsx b/src/components/WindowTopBarMenu.jsx index ab66791b1f..e6b8f936d8 100644 --- a/src/components/WindowTopBarMenu.jsx +++ b/src/components/WindowTopBarMenu.jsx @@ -37,7 +37,7 @@ const removeAttributes = (attributes = [], node) => { export function WindowTopBarMenu({ removeWindow, windowId, maximizeWindow = () => {}, maximized = false, minimizeWindow = () => {}, - allowClose = true, allowMaximize = true, allowFullscreen = false, allowTopMenuButton = true, + allowClose, allowMaximize, allowFullscreen, allowTopMenuButton, }) { const { t } = useTranslation(); const [outerW, setOuterW] = useState(); @@ -122,10 +122,10 @@ export function WindowTopBarMenu({ } WindowTopBarMenu.propTypes = { - allowClose: PropTypes.bool, - allowFullscreen: PropTypes.bool, - allowMaximize: PropTypes.bool, - allowTopMenuButton: PropTypes.bool, + allowClose: PropTypes.bool.isRequired, + allowFullscreen: PropTypes.bool.isRequired, + allowMaximize: PropTypes.bool.isRequired, + allowTopMenuButton: PropTypes.bool.isRequired, maximized: PropTypes.bool, maximizeWindow: PropTypes.func, minimizeWindow: PropTypes.func, diff --git a/src/containers/WindowTopBar.js b/src/containers/WindowTopBar.js index 57af230d22..4ed5a0e16a 100644 --- a/src/containers/WindowTopBar.js +++ b/src/containers/WindowTopBar.js @@ -10,8 +10,13 @@ const mapStateToProps = (state, { windowId }) => { const config = getWindowConfig(state, { windowId }); return { + allowClose: config.allowClose, + allowFullscreen: config.allowFullscreen, + allowMaximize: config.allowMaximize, + allowTopMenuButton: config.allowTopMenuButton, allowWindowSideBar: config.allowWindowSideBar, focused: isFocused(state, { windowId }), + maximized: config.maximized, }; }; diff --git a/src/containers/WindowTopBarMenu.js b/src/containers/WindowTopBarMenu.js index 73669e9cdd..46411c7bf2 100644 --- a/src/containers/WindowTopBarMenu.js +++ b/src/containers/WindowTopBarMenu.js @@ -10,13 +10,7 @@ import { WindowTopBarMenu } from '../components/WindowTopBarMenu'; const mapStateToProps = (state, { windowId }) => { const config = getWindowConfig(state, { windowId }); - return { - allowClose: config.allowClose, - allowFullscreen: config.allowFullscreen, - allowMaximize: config.allowMaximize, - allowTopMenuButton: config.allowTopMenuButton, - maximized: config.maximized, - }; + return {}; }; /**