Commit 78a74d2d authored by Robert Knight's avatar Robert Knight

Implement new menu component

Implement a new dropdown menu component that will replace all drop-down
menus in the sidebar and is also intended to be used in the LMS activity
views in future.

The menu consists of several components:

 - `Menu` is the dropdown menu itself, with a toggle button and menu
   content
 - `MenuSection` is a group of menu items with an optional heading
 - `MenuItem` is a menu item with a label, optional icon and optional
   submenu with additional actions. The submenu is going to be used for
   auxilliary links and actions related to groups in the groups menu
parent b9cb2bd6
'use strict';
const classnames = require('classnames');
const { createElement } = require('preact');
const propTypes = require('prop-types');
const { onActivate } = require('../util/on-activate');
const SvgIcon = require('./svg-icon');
/**
* An item in a dropdown menu.
*
* Dropdown menu items display an icon, a label and can optionally have a submenu
* associated with them.
*
* When clicked, menu items either open an external link, if the `href` prop
* is provided, or perform a custom action via the `onClick` callback.
*
* The icon can either be an external SVG image, referenced by URL, or a named
* icon rendered by an `SvgIcon`.
*
* For items that have submenus, the `MenuItem` only provides an indicator as
* to whether the submenu is open. The container is responsible for displaying
* the submenu items beneath the current item.
*/
function MenuItem({
href,
icon,
iconAlt,
isDisabled,
isExpanded,
isSelected,
isSubmenuVisible,
label,
onClick,
onToggleSubmenu,
style,
}) {
const iconClass = 'menu-item__icon';
const iconIsUrl = icon && icon.indexOf('/') !== -1;
const labelClass = classnames('menu-item__label', {
'menu-item__label--submenu': style === 'submenu',
});
return (
<div
aria-checked={isSelected}
className={classnames('menu-item', {
'menu-item--submenu': style === 'submenu',
'menu-item--shaded': style === 'shaded',
'is-disabled': isDisabled,
'is-expanded': isExpanded,
'is-selected': isSelected,
})}
role="menuitem"
{...onClick && onActivate('menuitem', onClick)}
>
{icon !== undefined && (
<div className="menu-item__icon-container">
{icon &&
(iconIsUrl ? (
<img className={iconClass} alt={iconAlt} src={icon} />
) : (
<SvgIcon name={icon} size={10} />
))}
</div>
)}
{href && (
<a
className={labelClass}
href={href}
target="_blank"
rel="noopener noreferrer"
>
{label}
</a>
)}
{!href && <span className={labelClass}>{label}</span>}
{typeof isSubmenuVisible === 'boolean' && (
<div
className="menu-item__toggle"
// We need to pass strings here rather than just the boolean attribute
// because otherwise the attribute will be omitted entirely when
// `isSubmenuVisible` is false.
aria-expanded={isSubmenuVisible ? 'true' : 'false'}
aria-label={`Show actions for ${label}`}
{...onActivate('button', onToggleSubmenu)}
>
<SvgIcon name={isSubmenuVisible ? 'collapse-menu' : 'expand-menu'} />
</div>
)}
</div>
);
}
MenuItem.propTypes = {
/**
* URL of the external link to open when this item is clicked.
* Either the `href` or an `onClick` callback should be supplied.
*/
href: propTypes.string,
/** Alt text for icon. */
iconAlt: propTypes.string,
/**
* Name or URL of icon to display. If the value is a URL it is displayed
* using an `<img>`, if it is a name it is displayed using `SvgIcon`.
*
* If the property is `null` a blank placeholder is displayed in place of an
* icon. If the property is omitted, no placeholder is displayed.
*/
icon: propTypes.string,
/**
* Dim the label to indicate that this item is not currently available.
*
* The `onClick` callback will still be invoked when this item is clicked and
* the submenu, if any, can still be toggled.
*/
isDisabled: propTypes.bool,
/**
* Indicates that the submenu associated with this item is currently open.
*/
isExpanded: propTypes.bool,
/**
* Display an indicator to show that this menu item represents something
* which is currently selected/active/focused.
*/
isSelected: propTypes.bool,
/**
* If present, display a button to toggle the sub-menu associated with this
* item and indicate the current state; `true` if the submenu is visible.
*/
isSubmenuVisible: propTypes.bool,
/** Label of the menu item. */
label: propTypes.string.isRequired,
/** Callback to invoke when the menu item is clicked. */
onClick: propTypes.func,
/**
* Callback when the user clicks on the toggle to change the expanded
* state of the menu.
*/
onToggleSubmenu: propTypes.func,
/** Style of menu item. */
style: propTypes.oneOf(['submenu', 'shaded']),
};
module.exports = MenuItem;
'use strict';
const { Fragment, createElement } = require('preact');
const propTypes = require('prop-types');
const map = val => (Array.isArray(val) ? val : [val]);
/**
* Group a set of menu items together visually, with an optional header.
*
* @example
* <Menu label="Things">
* <MenuSection heading="Big things">
* <MenuItem .../>
* <MenuItem .../>
* </MenuSection>
* <MenuSection heading="Little things">
* <MenuItem .../>
* <MenuItem .../>
* </MenuSection>
* </Menu>
*/
function MenuSection({ heading, children }) {
return (
<Fragment>
{heading && <h2 className="menu-section__heading">{heading}</h2>}
<ul className="menu-section__content">
{map(children, child => (
<li key={child.key}>{child}</li>
))}
</ul>
</Fragment>
);
}
MenuSection.propTypes = {
/**
* Heading displayed at the top of the menu.
*/
heading: propTypes.string,
/**
* Menu items to display in this section.
*/
children: propTypes.oneOfType([
propTypes.object,
propTypes.arrayOf(propTypes.object),
]).isRequired,
};
module.exports = MenuSection;
'use strict';
const { Fragment, createElement } = require('preact');
const { useCallback, useEffect, useRef, useState } = require('preact/hooks');
const propTypes = require('prop-types');
const { listen } = require('../util/dom');
const SvgIcon = require('./svg-icon');
let ignoreNextClick = false;
/**
* A drop-down menu.
*
* Menus consist of a button which toggles whether the menu is open, an
* an arrow indicating the state of the menu and content when is shown when
* the menu is open. The children of the menu component are rendered as the
* content of the menu when open. Typically this consists of a list of
* `MenuSection` and/or `MenuItem` components.
*
* @example
* <Menu label="Preferences">
* <MenuItem label="View" onClick={showViewSettings}/>
* <MenuItem label="Theme" onClick={showThemeSettings}/>
* </Menu>
*/
function Menu({
align = 'left',
children,
defaultOpen = false,
label,
menuIndicator = true,
title,
}) {
const [isOpen, setOpen] = useState(defaultOpen);
// Toggle menu when user presses toggle button. The menu is shown on mouse
// press for a more responsive/native feel but also handles a click event for
// activation via other input methods.
const toggleMenu = event => {
// If the menu was opened on press, don't close it again on the subsequent
// mouse up ("click") event.
if (event.type === 'mousedown') {
ignoreNextClick = true;
} else if (event.type === 'click' && ignoreNextClick) {
ignoreNextClick = false;
event.stopPropagation();
event.preventDefault();
return;
}
setOpen(!isOpen);
};
const closeMenu = useCallback(() => setOpen(false), [setOpen]);
// Close menu when user clicks outside or presses Esc.
const menuRef = useRef();
useEffect(() => {
if (!isOpen) {
return () => {};
}
const unlisten = listen(
document.body,
['keypress', 'click', 'mousedown'],
event => {
if (event.type === 'keypress' && event.key !== 'Escape') {
return;
}
if (event.type === 'click' && ignoreNextClick) {
ignoreNextClick = false;
return;
}
if (
event.type === 'mousedown' &&
menuRef.current &&
menuRef.current.contains(event.target)
) {
// Close the menu as soon as the user _presses_ the mouse outside the
// menu, but only when they _release_ the mouse if they click inside
// the menu.
return;
}
closeMenu();
}
);
return unlisten;
}, [closeMenu, isOpen]);
const menuStyle = {
[align]: 0,
};
return (
<div className="menu" ref={menuRef}>
<button
aria-expanded={isOpen ? 'true' : 'false'}
aria-haspopup={true}
className="menu__toggle"
onMouseDown={toggleMenu}
onClick={toggleMenu}
title={title}
>
{label}
{menuIndicator && (
<span className="menu__toggle-arrow">
<SvgIcon name="expand-menu" />
</span>
)}
</button>
{isOpen && (
<Fragment>
<div className="menu__arrow" />
<div className="menu__content" role="menu" style={menuStyle}>
{children}
</div>
</Fragment>
)}
</div>
);
}
Menu.propTypes = {
/**
* Whether the menu content is aligned with the left (default) or right edges
* of the toggle element.
*/
align: propTypes.oneOf(['left', 'right']),
/**
* Label element for the toggle button that hides and shows the menu.
*/
label: propTypes.object.isRequired,
/**
* Menu items and sections to display in the content area of the menu.
*
* These are typically `MenuSection` and `MenuItem` components, but other
* custom content is also allowed.
*/
children: propTypes.oneOfType([
propTypes.object,
propTypes.arrayOf(propTypes.object),
]),
/**
* Whether the menu is open or closed when initially rendered.
*/
defaultOpen: propTypes.bool,
/**
* A title for the menu. This is important for accessibility if the menu's
* toggle button has only an icon as a label.
*/
title: propTypes.string.isRequired,
/**
* Whether to display an indicator next to the label that there is a
* dropdown menu.
*/
menuIndicator: propTypes.bool,
};
module.exports = Menu;
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const MenuItem = require('../menu-item');
describe('MenuItem', () => {
const createMenuItem = props =>
shallow(<MenuItem label="Test item" {...props} />);
it('invokes `onClick` callback when clicked', () => {
const onClick = sinon.stub();
const wrapper = createMenuItem({ onClick });
wrapper.find('[role="menuitem"]').simulate('click');
assert.called(onClick);
});
it('renders a link if an `href` is provided', () => {
const wrapper = createMenuItem({ href: 'https://example.com' });
const link = wrapper.find('a');
assert.equal(link.length, 1);
assert.equal(link.prop('href'), 'https://example.com');
assert.equal(link.prop('rel'), 'noopener noreferrer');
});
it('renders a non-link if an `href` is not provided', () => {
const wrapper = createMenuItem();
assert.isFalse(wrapper.exists('a'));
assert.equal(wrapper.find('.menu-item__label').text(), 'Test item');
});
it('renders an `<img>` icon if an icon URL is provided', () => {
const src = 'https://example.com/icon.svg';
const wrapper = createMenuItem({ icon: src });
const icon = wrapper.find('img');
assert.equal(icon.prop('src'), src);
});
it('renders an SVG icon if an icon name is provided', () => {
const wrapper = createMenuItem({ icon: 'an-svg-icon' });
assert.isTrue(wrapper.exists('SvgIcon[name="an-svg-icon"]'));
});
it('shows the submenu indicator if `isSubmenuVisible` is a boolean', () => {
const wrapper = createMenuItem({ isSubmenuVisible: true });
assert.isTrue(wrapper.exists('SvgIcon[name="collapse-menu"]'));
wrapper.setProps({ isSubmenuVisible: false });
assert.isTrue(wrapper.exists('SvgIcon[name="expand-menu"]'));
});
it('does not show submenu indicator if `isSubmenuVisible` is undefined', () => {
const wrapper = createMenuItem();
assert.isFalse(wrapper.exists('SvgIcon'));
});
it('calls the `onToggleSubmenu` callback when the submenu toggle is clicked', () => {
const onToggleSubmenu = sinon.stub();
const wrapper = createMenuItem({ isSubmenuVisible: true, onToggleSubmenu });
wrapper.find('.menu-item__toggle').simulate('click');
assert.called(onToggleSubmenu);
});
});
'use strict';
const { createElement } = require('preact');
const { shallow } = require('enzyme');
const MenuSection = require('../menu-section');
describe('MenuSection', () => {
const createMenuSection = props =>
shallow(
<MenuSection {...props}>
<div className="menu-item">Test item</div>
</MenuSection>
);
it('renders the heading', () => {
const wrapper = createMenuSection({ heading: 'A heading' });
assert.equal(wrapper.find('h2').text(), 'A heading');
});
it('omits the heading if `heading` is not set', () => {
const wrapper = createMenuSection();
assert.isFalse(wrapper.exists('h2'));
});
it('renders menu items', () => {
const wrapper = createMenuSection();
assert.isTrue(wrapper.exists('.menu-item'));
});
});
'use strict';
const { createElement } = require('preact');
const { act } = require('preact/test-utils');
const { mount } = require('enzyme');
const Menu = require('../menu');
describe('Menu', () => {
let container;
const TestLabel = () => 'Test label';
const TestMenuItem = () => 'Test item';
const createMenu = props => {
// Use `mount` rather than `shallow` rendering in order to supporting
// testing of clicking outside the element.
return mount(
<Menu {...props} label={<TestLabel />} title="Test menu">
<TestMenuItem />
</Menu>,
{ attachTo: container }
);
};
function isOpen(wrapper) {
return wrapper.exists('.menu__content');
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
Menu.$imports.$mock({
// eslint-disable-next-line react/display-name
'./svg-icon': () => <span>Fake SVG icon</span>,
});
});
afterEach(() => {
Menu.$imports.$restore();
container.remove();
});
it('opens and closes when the toggle button is clicked', () => {
const wrapper = createMenu();
assert.isFalse(isOpen(wrapper));
wrapper.find('button').simulate('click');
assert.isTrue(isOpen(wrapper));
wrapper.find('button').simulate('click');
assert.isFalse(isOpen(wrapper));
});
it('opens and closes when the toggle button is pressed', () => {
const wrapper = createMenu();
assert.isFalse(isOpen(wrapper));
wrapper.find('button').simulate('mousedown');
// Make sure the follow-up click doesn't close the menu.
wrapper.find('button').simulate('click');
assert.isTrue(isOpen(wrapper));
});
it('renders the label', () => {
const wrapper = createMenu();
assert.isTrue(wrapper.exists(TestLabel));
});
it('renders menu items when open', () => {
const wrapper = createMenu({ defaultOpen: true });
assert.isTrue(wrapper.exists(TestMenuItem));
});
let e;
[
new Event('mousedown'),
new Event('click'),
((e = new Event('keypress')), (e.key = 'Escape'), e),
].forEach(event => {
it('closes when the user clicks or presses the mouse outside', () => {
const wrapper = createMenu();
act(() => {
document.body.dispatchEvent(event);
});
assert.isFalse(isOpen(wrapper));
});
});
it('does not close menu if user presses mouse on menu content', () => {
const wrapper = createMenu({ defaultOpen: true });
let content = wrapper.find('.menu__content');
act(() => {
content
.getDOMNode()
.dispatchEvent(new Event('mousedown', { bubbles: true }));
wrapper.update();
content = wrapper.find('.menu__content');
});
assert.isTrue(isOpen(wrapper));
});
});
'use strict';
/**
* Attach `handler` as an event listener for `events` on `element`.
*
* @return {function} Function which removes the event listeners.
*/
function listen(element, events, handler) {
if (!Array.isArray(events)) {
events = [events];
}
events.forEach(event => element.addEventListener(event, handler));
return () => {
events.forEach(event => element.removeEventListener(event, handler));
};
}
module.exports = {
listen,
};
'use strict';
/**
* Return a set of props that can be applied to a React DOM element to make
* it activateable like a button.
*
* Before using this helper, consider if there is a more appropriate semantic
* HTML element which will provide the behaviors you need automatically.
*
* @param {string} role - ARIA role for the item
* @param {Function} handler - Event handler
* @return {Object} Props to spread into a DOM element
*/
function onActivate(role, handler) {
return {
// Support mouse activation.
onClick: handler,
// Support keyboard activation.
onKeypress: event => {
if (event.key === 'Enter' || event.key === ' ') {
handler(event);
}
},
// Every item that is "activateable" should have an appropriate ARIA role.
role,
// Make item focusable using the keyboard.
tabIndex: 0,
};
}
module.exports = { onActivate };
$menu-item-padding: 10px;
.menu-item {
$item-padding: $menu-item-padding;
$item-height: 40px;
display: flex;
flex-direction: row;
flex-grow: 1;
align-items: center;
min-height: $item-height;
padding-left: $item-padding;
// TODO - Make the link fill the full available vertical space.
cursor: pointer;
// Prevent menu item text from being selected when user toggles menu.
user-select: none;
// An item in a submenu associated with a top-level item.
&--submenu {
min-height: $item-height - 10px;
background: $grey-1;
&:hover {
background: $grey-1;
}
}
// Shaded item at the bottom of the menu.
// This is used to indicate an action which is different in functionality
// from a list of items above which do the same thing.
&--shaded {
background: $grey-1;
}
&:hover {
background: $grey-1;
}
&.is-disabled {
.menu-item__label {
color: $grey-4;
}
}
&.is-expanded {
background: $grey-1;
}
&.is-selected {
$border-width: 4px;
border-left: $border-width solid $brand;
padding-left: $item-padding - $border-width;
}
}
.menu-item__icon-container {
margin-right: 10px;
width: 15px;
height: 15px;
}
.menu-item__icon {
color: $color-gray;
display: inline-block;
margin-right: 4px;
position: relative;
height: 15px;
width: 15px;
}
.menu-item__label {
align-self: stretch;
display: flex;
flex-direction: row;
align-items: center;
color: inherit;
white-space: nowrap;
flex-grow: 1;
flex-shrink: 1;
font-weight: 500;
padding-right: $menu-item-padding;
&--submenu {
font-weight: 300;
}
&:hover {
color: $brand;
}
}
// Toggle button used to expand or collapse the submenu associated with a menu
// item.
.menu-item__toggle {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
align-self: stretch;
width: 40px;
// Add a wide transparent border to provide a large-enough hit target (~40px),
// larger than the visual size of the button (~20px).
background-color: $grey-1;
background-clip: content-box;
border: 7px solid transparent;
// Add slight rounded borders. border-radius sets the outer radius, but
// what the user sees is the inner radius, which is much smaller.
border-radius: 12px;
&:hover {
background-color: $grey-3;
}
// The toggle icon is loaded from a string, so we can't apply a class to it
// directly.
svg {
width: 12px;
height: 12px;
}
@include outline-on-keyboard-focus;
}
.menu-section__content {
border-bottom: solid 1px rgba(0, 0, 0, 0.15);
}
.menu-section__heading {
color: $gray-light;
font-size: $body1-font-size;
line-height: 1;
margin: 1px 1px 0;
padding: 12px 10px 0;
text-transform: uppercase;
}
.menu {
position: relative;
}
// Toggle button that opens the menu.
.menu__toggle {
appearance: none;
border: none;
background: none;
padding: 0;
color: inherit;
display: flex;
}
// Triangular indicator next to the toggle button indicating that there is
// an associated drop-down menu.
.menu__toggle-arrow {
width: 10px;
height: 10px;
margin-left: 5px;
}
// Triangular indicator at the top of the menu that associates it with the
// toggle button.
.menu__arrow {
$size: 10px;
background-color: white;
border-left: 1px solid $grey-3;
border-top: 1px solid $grey-3;
height: $size;
position: absolute;
right: #{$size / 2};
top: calc(100% - #{$size / 2} + 1px);
transform: rotate(45deg);
width: $size;
z-index: 3;
}
// Content area of the menu.
.menu__content {
background-color: white;
border: 1px solid $grey-3;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
position: absolute;
top: calc(100% + 2px);
}
......@@ -22,6 +22,9 @@ $base-line-height: 20px;
@import './components/loggedout-message';
@import './components/login-control';
@import './components/markdown';
@import './components/menu';
@import './components/menu-item';
@import './components/menu-section';
@import './components/moderation-banner';
@import './components/new-note';
@import './components/primary-action-btn';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment