Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move window topbar content to menu on small screens #3872

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
65 changes: 11 additions & 54 deletions src/components/WindowTopBar.js
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,10 +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,
focusWindow = () => {}, allowFullscreen = false, allowTopMenuButton = true, allowWindowSideBar = true,
component = 'nav',
windowId, toggleWindowSideBar, focusWindow = () => {}, allowWindowSideBar = true, component = 'nav',
}) {
const { t } = useTranslation();
const ownerState = arguments[0]; // eslint-disable-line prefer-rest-params
Expand All @@ -54,61 +44,28 @@ 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
windowId={windowId}
ownerState={ownerState}
/>
{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>
);
}

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,
Expand Down
167 changes: 167 additions & 0 deletions src/components/WindowTopBarMenu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
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(
<WindowTopBarPluginArea key={`WindowTopBarPluginArea-${windowId}`} windowId={windowId} />,
);
}

allowTopMenuButton && buttons.push(
<WindowTopMenuButton
key={`WindowTopMenuButton-${windowId}`}
windowId={windowId}
className={ns('window-menu-btn')}
/>,
);

allowMaximize && buttons.push(
<MiradorMenuButton
key={`allowMaximizeMiradorMenuButton-${windowId}`}
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 && buttons.push(
<FullScreenButton
key={`FullScreenButton-${windowId}`}
className={ns('window-menu-btn')}
/>,
);

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 (
<>
<Portal>
<InvisibleIconButtonsWrapper ref={portalRef}>
{buttons}
</InvisibleIconButtonsWrapper>
</Portal>
<ResizeObserver
onResize={(rect) => {
// 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));
}}
/>
<WindowTopBarTitle
windowId={windowId}
/>
<IconButtonsWrapper ref={iconButtonsWrapperRef}>
{visibleButtons}
{showMoreButtons && (
<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,
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,
};
14 changes: 9 additions & 5 deletions src/components/WindowTopBarPluginMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,24 @@ 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);
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
*/
const handleMenuClick = (event) => {
setAnchorEl(event.currentTarget);
setOpen(true);
};

/** */
/**
* Set the anchorEl state to null (closing the menu)
*/
const handleMenuClose = () => {
setAnchorEl(null);
setOpen(false);
Expand All @@ -45,7 +48,6 @@ export function WindowTopBarPluginMenu({
>
{menuIcon}
</MiradorMenuButton>

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