Skip to content
2 changes: 1 addition & 1 deletion __tests__/integration/mirador/tests/plugin-add.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('add two plugins to <WorkspaceControlPanelButtons>', () => {
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 () => {
Expand Down
18 changes: 0 additions & 18 deletions __tests__/src/components/WindowTopBar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,24 +75,6 @@ describe('WindowTopBar', () => {
expect(toggleWindowSideBar).toHaveBeenCalledTimes(1);
});

it('passes correct callback to closeWindow button', async () => {
const removeWindow = vi.fn();
render(<Subject allowClose removeWindow={removeWindow} />);
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(<Subject allowMaximize maximizeWindow={maximizeWindow} />);
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(<Subject allowClose={false} />);
const button = screen.queryByRole('button', { name: 'Close window' });
Expand Down
42 changes: 42 additions & 0 deletions __tests__/src/components/WindowTopBarMenu.test.js
Original file line number Diff line number Diff line change
@@ -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 (
<WindowTopBarMenu
windowId="xyz"
classes={{}}
maximizeWindow={() => {}}
minimizeWindow={() => {}}
removeWindow={() => {}}
{...props}
/>
);
}

describe('WindowTopBarMenu', () => {
let user;
beforeEach(() => {
user = userEvent.setup();
});

it('passes correct callback to closeWindow button', async () => {
const removeWindow = vi.fn();
render(<Subject allowClose removeWindow={removeWindow} />);
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(<Subject allowMaximize maximizeWindow={maximizeWindow} />);
const button = screen.getByRole('button', { name: 'Maximize window' });
expect(button).toBeInTheDocument();
await user.click(button);
expect(maximizeWindow).toHaveBeenCalledTimes(1);
});
});
3 changes: 2 additions & 1 deletion __tests__/src/components/WindowTopBarPluginMenu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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(<Subject />);
render(<WindowTopBarMenu />);
expect(screen.queryByTestId('testA')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Window options' })).not.toBeInTheDocument();
});
Expand Down
63 changes: 16 additions & 47 deletions src/components/WindowTopBar.jsx
Original file line number Diff line number Diff line change
@@ -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' })(() => ({
Expand All @@ -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',
}) {
Expand All @@ -54,43 +46,23 @@ export function WindowTopBar({
variant="dense"
>
{allowWindowSideBar && (
<MiradorMenuButton
aria-label={t('toggleWindowSideBar')}
onClick={toggleWindowSideBar}
className={ns('window-menu-btn')}
>
<MenuIcon />
</MiradorMenuButton>
<MiradorMenuButton
aria-label={t('toggleWindowSideBar')}
onClick={toggleWindowSideBar}
className={ns('window-menu-btn')}
>
<MenuIcon />
</MiradorMenuButton>
)}
<WindowTopBarTitle
<WindowTopBarMenu
allowClose={allowClose}
allowFullscreen={allowFullscreen}
allowMaximize={allowMaximize}
allowTopMenuButton={allowTopMenuButton}
maximized={maximized}
ownerState={ownerState}
windowId={windowId}
/>
{allowTopMenuButton && (
<WindowTopMenuButton windowId={windowId} className={ns('window-menu-btn')} />
)}
<WindowTopBarPluginArea windowId={windowId} />
<WindowTopBarPluginMenu windowId={windowId} />
{allowMaximize && (
<MiradorMenuButton
aria-label={(maximized ? t('minimizeWindow') : t('maximizeWindow'))}
className={classNames(ns('window-maximize'), ns('window-menu-btn'))}
onClick={(maximized ? minimizeWindow : maximizeWindow)}
>
{(maximized ? <WindowMinIcon /> : <WindowMaxIcon />)}
</MiradorMenuButton>
)}
{allowFullscreen && (
<FullScreenButton className={ns('window-menu-btn')} />
)}
{allowClose && (
<MiradorMenuButton
aria-label={t('closeWindow')}
className={classNames(ns('window-close'), ns('window-menu-btn'))}
onClick={removeWindow}
>
<CloseIcon />
</MiradorMenuButton>
)}
</StyledToolbar>
</Root>
);
Expand All @@ -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,
Expand Down
134 changes: 134 additions & 0 deletions src/components/WindowTopBarMenu.jsx
Original file line number Diff line number Diff line change
@@ -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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a minor thing, but I'm not sure if WindowTopBarMenu is the best name for this component, as it contains the entire content of the WindowTopBar aside from the sidebar button. Not sure what a better name would be. WindowTopBarContent? WindowTopBarControls?

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)
&& <WindowTopBarPluginArea key={`WindowTopBarPluginArea-${windowId}`} windowId={windowId} />,
allowTopMenuButton
&& <WindowTopMenuButton key={`WindowTopMenuButton-${windowId}`} windowId={windowId} className={ns('window-menu-btn')} />,
allowMaximize
&& (
<MiradorMenuButton
key={`allowMaximizeMiradorMenuButton-${windowId}`}
aria-label={t(maximized ? 'minimizeWindow' : 'maximizeWindow')}
className={classNames(ns('window-maximize'), ns('window-menu-btn'))}
onClick={maximized ? minimizeWindow : maximizeWindow}
>
{maximized ? <WindowMinIcon /> : <WindowMaxIcon />}
</MiradorMenuButton>
),
allowFullscreen
&& <FullScreenButton key={`FullScreenButton-${windowId}`} className={ns('window-menu-btn')} />,
].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);

Check warning on line 90 in src/components/WindowTopBarMenu.jsx

View check run for this annotation

Codecov / codecov/patch

src/components/WindowTopBarMenu.jsx#L90

Added line #L90 was not covered by tests
}
setVisibleButtonsNum(newVisibleButtonsNum);
}, [outerW, moreButtonAlwaysShowing]);

return (
<>
<Portal>
<InvisibleIconButtonsWrapper ref={portalRef}>{buttons}</InvisibleIconButtonsWrapper>
</Portal>
<ResizeObserver
// 96 to compensate for the burger menu button on the left and the close window button on the right
onResize={({ width }) => setOuterW(Math.max(width - 96, 0))}
/>
<WindowTopBarTitle windowId={windowId} />
<IconButtonsWrapper ref={iconButtonsWrapperRef}>
{visibleButtons}
{(moreButtonAlwaysShowing || moreButtons.length > 0) && (
<WindowTopBarPluginMenu windowId={windowId} moreButtons={moreButtons} />
)}
{allowClose && (
<MiradorMenuButton
aria-label={t('closeWindow')}
className={classNames(ns('window-close'), ns('window-menu-btn'))}
onClick={removeWindow}
>
<CloseIcon />
</MiradorMenuButton>
)}
</IconButtonsWrapper>
</>
);
}

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,
};
15 changes: 10 additions & 5 deletions src/components/WindowTopBarPluginMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import WorkspaceContext from '../contexts/WorkspaceContext';
*
*/
export function WindowTopBarPluginMenu({
PluginComponents = [], windowId, menuIcon = <MoreVertIcon />,
PluginComponents = [], windowId, menuIcon = <MoreVertIcon />, moreButtons = null,
}) {
const { t } = useTranslation();
const container = useContext(WorkspaceContext);
Expand All @@ -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 (
<>
Expand All @@ -45,7 +49,6 @@ export function WindowTopBarPluginMenu({
>
{menuIcon}
</MiradorMenuButton>

<Menu
id={windowPluginMenuId}
container={container?.current}
Expand All @@ -61,6 +64,7 @@ export function WindowTopBarPluginMenu({
open={open}
onClose={handleMenuClose}
>
{moreButtons}
<PluginHook handleClose={handleMenuClose} {...pluginProps} />
</Menu>
</>
Expand All @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions src/containers/WindowTopBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
});

Expand Down
Loading