diff --git a/__tests__/integration/mirador/tests/plugin-add.test.js b/__tests__/integration/mirador/tests/plugin-add.test.js index 90842c96c..76e3d1307 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 5522ac0d6..949cc3eff 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 000000000..f4da0969e --- /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/__tests__/src/components/WindowTopBarPluginMenu.test.js b/__tests__/src/components/WindowTopBarPluginMenu.test.js index d18e9c06b..809ed6d0c 100644 --- a/__tests__/src/components/WindowTopBarPluginMenu.test.js +++ b/__tests__/src/components/WindowTopBarPluginMenu.test.js @@ -2,6 +2,7 @@ import { render, screen } from '@tests/utils/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 */ @@ -27,7 +28,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: 'Window options' })).not.toBeInTheDocument(); }); diff --git a/src/components/WindowTopBar.jsx b/src/components/WindowTopBar.jsx index fe3d7bf97..48fe5f3a7 100644 --- a/src/components/WindowTopBar.jsx +++ b/src/components/WindowTopBar.jsx @@ -1,19 +1,12 @@ 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 { useTranslation } from 'react-i18next'; 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' })(() => ({ @@ -36,8 +29,7 @@ const StyledToolbar = styled(Toolbar, { name: 'WindowTopBar', slot: 'toolbar' }) * WindowTopBar */ export function WindowTopBar({ - removeWindow, windowId, toggleWindowSideBar, - maximizeWindow = () => {}, maximized = false, minimizeWindow = () => {}, allowClose = true, allowMaximize = true, + windowId, toggleWindowSideBar, maximized = false, allowClose = true, allowMaximize = true, focusWindow = () => {}, allowFullscreen = false, allowTopMenuButton = true, allowWindowSideBar = true, component = 'nav', }) { @@ -54,43 +46,23 @@ export function WindowTopBar({ variant="dense" > {allowWindowSideBar && ( - - - + + + )} - - {allowTopMenuButton && ( - - )} - - - {allowMaximize && ( - - {(maximized ? : )} - - )} - {allowFullscreen && ( - - )} - {allowClose && ( - - - - )} ); @@ -106,9 +78,6 @@ WindowTopBar.propTypes = { 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.jsx b/src/components/WindowTopBarMenu.jsx new file mode 100644 index 000000000..e6b8f936d --- /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, allowMaximize, allowFullscreen, allowTopMenuButton, +}) { + 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.isRequired, + allowFullscreen: PropTypes.bool.isRequired, + allowMaximize: PropTypes.bool.isRequired, + allowTopMenuButton: PropTypes.bool.isRequired, + 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 e255667c3..f7524c022 100644 --- a/src/components/WindowTopBarPluginMenu.jsx +++ b/src/components/WindowTopBarPluginMenu.jsx @@ -11,7 +11,7 @@ import WorkspaceContext from '../contexts/WorkspaceContext'; * */ export function WindowTopBarPluginMenu({ - PluginComponents = [], windowId, menuIcon = , + PluginComponents = [], windowId, menuIcon = , moreButtons = null, }) { const { t } = useTranslation(); const container = useContext(WorkspaceContext); @@ -20,19 +20,23 @@ export function WindowTopBarPluginMenu({ const [open, setOpen] = useState(false); const windowPluginMenuId = useId(); - /** */ + /** + * Set the anchorEl state to the click target + */ const handleMenuClick = (event) => { setAnchorEl(event.currentTarget); setOpen(true); }; - /** */ + /** + * Set the anchorEl state to null (closing the menu) + */ const handleMenuClose = () => { setAnchorEl(null); setOpen(false); }; - if (!PluginComponents || PluginComponents.length === 0) return null; + if (!moreButtons && (!PluginComponents || PluginComponents.length === 0)) return null; return ( <> @@ -45,7 +49,6 @@ export function WindowTopBarPluginMenu({ > {menuIcon} - + {moreButtons} @@ -71,6 +75,7 @@ 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.array, // eslint-disable-line react/forbid-prop-types windowId: PropTypes.string.isRequired, diff --git a/src/containers/WindowTopBar.js b/src/containers/WindowTopBar.js index 990007620..4ed5a0e16 100644 --- a/src/containers/WindowTopBar.js +++ b/src/containers/WindowTopBar.js @@ -27,9 +27,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 new file mode 100644 index 000000000..46411c7bf --- /dev/null +++ b/src/containers/WindowTopBarMenu.js @@ -0,0 +1,33 @@ +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 {}; +}; + +/** + * 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'), +); + +export default enhance(WindowTopBarMenu);