Unverified Commit 8813b8cf authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1187 from hypothesis/single-expanded-menu-item

Only allow one group's submenu to be expanded at a time
parents 398663fa 1660d3b0
......@@ -2,7 +2,6 @@
const propTypes = require('prop-types');
const { Fragment, createElement } = require('preact');
const { useState } = require('preact/hooks');
const useStore = require('../store/use-store');
const { orgName } = require('../util/group-list-item-common');
......@@ -19,19 +18,17 @@ const MenuItem = require('./menu-item');
*/
function GroupListItem({
analytics,
defaultSubmenuOpen = false,
isExpanded,
flash,
group,
groups: groupsService,
onExpand,
}) {
const canLeaveGroup = group.type === 'private';
const activityUrl = group.links.html;
const hasActionMenu = activityUrl || canLeaveGroup;
const isSelectable = !group.scopes.enforced || group.isScopedToUri;
const [isExpanded, setExpanded] = useState(
hasActionMenu ? defaultSubmenuOpen : undefined
);
const focusedGroupId = useStore(store => store.focusedGroupId());
const isSelected = group.id === focusedGroupId;
......@@ -63,7 +60,7 @@ function GroupListItem({
// TODO - Fix this more cleanly in `MenuItem`.
event.preventDefault();
setExpanded(!isExpanded);
onExpand(!isExpanded);
};
const copyLink = () => {
......@@ -79,23 +76,21 @@ function GroupListItem({
group.type === 'private' ? 'Copy invite link' : 'Copy activity link';
// Close the submenu when any clicks happen which close the top-level menu.
const collapseSubmenu = () => setExpanded(false);
const collapseSubmenu = () => onExpand(false);
return (
<Fragment>
<MenuItem
icon={group.logo || 'blank'}
iconAlt={orgName(group)}
isDisabled={!isSelectable}
isExpanded={isExpanded}
isSelected={isSelected}
isSubmenuVisible={isExpanded}
label={group.name}
onClick={isSelectable ? focusGroup : toggleSubmenu}
onToggleSubmenu={toggleSubmenu}
/>
{isExpanded && (
<div className="group-list-item__submenu">
<MenuItem
icon={group.logo || 'blank'}
iconAlt={orgName(group)}
isDisabled={!isSelectable}
isExpanded={hasActionMenu ? isExpanded : false}
isSelected={isSelected}
isSubmenuVisible={isExpanded}
label={group.name}
onClick={isSelectable ? focusGroup : toggleSubmenu}
onToggleSubmenu={toggleSubmenu}
submenu={
<Fragment>
<ul onClick={collapseSubmenu}>
{activityUrl && (
<li>
......@@ -133,17 +128,26 @@ function GroupListItem({
This group is restricted to specific URLs.
</p>
)}
</div>
)}
</Fragment>
</Fragment>
}
/>
);
}
GroupListItem.propTypes = {
group: propTypes.object.isRequired,
/** Whether the submenu is open when the item is initially rendered. */
defaultSubmenuOpen: propTypes.bool,
/**
* Whether the submenu for this group is expanded.
*/
isExpanded: propTypes.bool,
/**
* Callback invoked to expand or collapse the current group.
*
* @type {(expand: boolean) => any}
*/
onExpand: propTypes.func,
// Injected services.
analytics: propTypes.object.isRequired,
......
......@@ -9,21 +9,40 @@ const MenuSection = require('./menu-section');
/**
* A labeled section of the groups list.
*/
function GroupListSection({ groups, heading }) {
function GroupListSection({ expandedGroup, onExpandGroup, groups, heading }) {
return (
<MenuSection heading={heading}>
{groups.map(group => (
<GroupListItem key={group.id} group={group} />
<GroupListItem
key={group.id}
isExpanded={group === expandedGroup}
onExpand={expanded => onExpandGroup(expanded ? group : null)}
group={group}
/>
))}
</MenuSection>
);
}
GroupListSection.propTypes = {
/**
* The `Group` whose submenu is currently expanded, or `null` if no group
* is currently expanded.
*/
expandedGroup: propTypes.object,
/* The list of groups to be displayed in the group list section. */
groups: propTypes.arrayOf(propTypes.object),
/* The string name of the group list section. */
heading: propTypes.string,
/**
* Callback invoked when a group is expanded or collapsed.
*
* The argument is the group being expanded, or `null` if the expanded group
* is being collapsed.
*
* @type {(group: Group|null) => any}
*/
onExpandGroup: propTypes.func,
};
module.exports = GroupListSection;
'use strict';
const { createElement } = require('preact');
const { useMemo } = require('preact/hooks');
const { useMemo, useState } = require('preact/hooks');
const propTypes = require('prop-types');
const isThirdPartyService = require('../util/is-third-party-service');
......@@ -53,6 +53,13 @@ function GroupList({ serviceUrl, settings }) {
const canCreateNewGroup = userid && !isThirdPartyUser(userid, authDomain);
const newGroupLink = serviceUrl('groups.new');
// The group whose submenu is currently open, or `null` if no group item is
// currently expanded.
//
// nb. If we create other menus that behave similarly in future, we may want
// to move this state to the `Menu` component.
const [expandedGroup, setExpandedGroup] = useState(null);
let label;
if (focusedGroup) {
const icon = focusedGroup.organization.logo;
......@@ -84,22 +91,32 @@ function GroupList({ serviceUrl, settings }) {
align="left"
contentClass="group-list__content"
label={label}
onOpenChanged={() => setExpandedGroup(null)}
title="Select group"
>
{currentGroupsSorted.length > 0 && (
<GroupListSection
expandedGroup={expandedGroup}
onExpandGroup={setExpandedGroup}
heading="Currently Viewing"
groups={currentGroupsSorted}
/>
)}
{featuredGroupsSorted.length > 0 && (
<GroupListSection
expandedGroup={expandedGroup}
onExpandGroup={setExpandedGroup}
heading="Featured Groups"
groups={featuredGroupsSorted}
/>
)}
{myGroupsSorted.length > 0 && (
<GroupListSection heading="My Groups" groups={myGroupsSorted} />
<GroupListSection
expandedGroup={expandedGroup}
onExpandGroup={setExpandedGroup}
heading="My Groups"
groups={myGroupsSorted}
/>
)}
{canCreateNewGroup && (
......
......@@ -6,6 +6,7 @@ const propTypes = require('prop-types');
const { onActivate } = require('../util/on-activate');
const Slider = require('./slider');
const SvgIcon = require('./svg-icon');
/**
......@@ -20,9 +21,8 @@ const SvgIcon = require('./svg-icon');
* 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.
* For items that have submenus, the `MenuItem` will call the `renderSubmenu`
* prop to render the content of the submenu, when the submenu is visible.
*/
function MenuItem({
href,
......@@ -36,6 +36,7 @@ function MenuItem({
label,
onClick,
onToggleSubmenu,
submenu,
}) {
const iconClass = 'menu-item__icon';
const iconIsUrl = icon && icon.indexOf('/') !== -1;
......@@ -58,51 +59,60 @@ function MenuItem({
const rightIcon = isSubmenuItem ? renderedIcon : null;
return (
<div
aria-checked={isSelected}
className={classnames('menu-item', {
'menu-item--submenu': isSubmenuItem,
'is-disabled': isDisabled,
'is-expanded': isExpanded,
'is-selected': isSelected,
})}
role="menuitem"
{...(onClick && onActivate('menuitem', onClick))}
>
<div className="menu-item__action">
{hasLeftIcon && (
<div className="menu-item__icon-container">{leftIcon}</div>
)}
{href && (
<a
className={labelClass}
href={href}
target="_blank"
rel="noopener noreferrer"
// Wrapper element is a `<div>` rather than a `Fragment` to work around
// limitations of Enzyme's shallow rendering.
<div>
<div
aria-checked={isSelected}
className={classnames('menu-item', {
'menu-item--submenu': isSubmenuItem,
'is-disabled': isDisabled,
'is-expanded': isExpanded,
'is-selected': isSelected,
})}
role="menuitem"
{...(onClick && onActivate('menuitem', onClick))}
>
<div className="menu-item__action">
{hasLeftIcon && (
<div className="menu-item__icon-container">{leftIcon}</div>
)}
{href && (
<a
className={labelClass}
href={href}
target="_blank"
rel="noopener noreferrer"
>
{label}
</a>
)}
{!href && <span className={labelClass}>{label}</span>}
{hasRightIcon && (
<div className="menu-item__icon-container">{rightIcon}</div>
)}
</div>
{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)}
>
{label}
</a>
)}
{!href && <span className={labelClass}>{label}</span>}
{hasRightIcon && (
<div className="menu-item__icon-container">{rightIcon}</div>
<SvgIcon
name={isSubmenuVisible ? 'collapse-menu' : 'expand-menu'}
className="menu-item__toggle-icon"
/>
</div>
)}
</div>
{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'}
className="menu-item__toggle-icon"
/>
</div>
<Slider visible={isSubmenuVisible}>
<div className="menu-item__submenu">{submenu}</div>
</Slider>
)}
</div>
);
......@@ -171,6 +181,15 @@ MenuItem.propTypes = {
* state of the menu.
*/
onToggleSubmenu: propTypes.func,
/**
* Contents of the submenu for this item.
*
* This is typically a list of `MenuItem` components with the `isSubmenuItem`
* prop set to `true`, but can include other content as well.
* The submenu is only rendered if `isSubmenuVisible` is `true`.
*/
submenu: propTypes.any,
};
module.exports = MenuItem;
......@@ -50,11 +50,21 @@ function Menu({
contentClass,
defaultOpen = false,
label,
onOpenChanged,
menuIndicator = true,
title,
}) {
const [isOpen, setOpen] = useState(defaultOpen);
// Notify parent when menu is opened or closed.
const wasOpen = useRef(isOpen);
useEffect(() => {
if (typeof onOpenChanged === 'function' && wasOpen.current !== isOpen) {
wasOpen.current = isOpen;
onOpenChanged(isOpen);
}
}, [isOpen, onOpenChanged]);
// 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.
......@@ -240,6 +250,14 @@ Menu.propTypes = {
*/
defaultOpen: propTypes.bool,
/**
* Callback invoked when the menu is opened or closed.
*
* This can be used, for example, to reset any ephemeral state that the
* menu content may have.
*/
onOpenChanged: propTypes.func,
/**
* A title for the menu. This is important for accessibility if the menu's
* toggle button has only an icon as a label.
......
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { useCallback, useEffect, useRef, useState } = require('preact/hooks');
/**
* A container which reveals its content when `visible` is `true` using
* a sliding animation.
*
* When the content is not partially or wholly visible, it is removed from the
* DOM using `display: none` so it does not appear in the keyboard navigation
* order.
*
* Currently the only reveal/expand direction supported is top-down.
*/
function Slider({ children, visible }) {
const containerRef = useRef(null);
const [containerHeight, setContainerHeight] = useState(visible ? 'auto' : 0);
// Whether the content is currently partially or wholly visible. This is
// different from `visible` when collapsing as it is true until the collapse
// animation completes.
const [contentVisible, setContentVisible] = useState(visible);
// Adjust the container height when the `visible` prop changes.
useEffect(() => {
const isVisible = containerHeight !== 0;
if (visible === isVisible) {
// Do nothing after the initial mount.
return;
}
const el = containerRef.current;
if (visible) {
// Show the content synchronously so that we can measure it here.
el.style.display = '';
// Make content visible in future renders.
setContentVisible(true);
// When expanding, transition the container to the current fixed height
// of the content. After the transition completes, we'll reset to "auto"
// height to adapt to future content changes.
setContainerHeight(el.scrollHeight);
} else {
// When collapsing, immediately change the current height to a fixed height
// (in case it is currently "auto"), force a synchronous layout,
// then transition to 0.
//
// These steps are needed because browsers will not animate transitions
// from "auto" => "0" and may not animate "auto" => fixed height => 0
// if the layout tree transitions directly from "auto" => 0.
el.style.height = `${el.scrollHeight}px`;
// Force a sync layout.
el.getBoundingClientRect();
setContainerHeight(0);
}
}, [containerHeight, visible]);
const handleTransitionEnd = useCallback(() => {
if (visible) {
setContainerHeight('auto');
} else {
// When the collapse animation completes, stop rendering the content so
// that the browser has fewer nodes to render and the content is removed
// from keyboard navigation.
setContentVisible(false);
}
}, [setContainerHeight, visible]);
return (
<div
// nb. Preact uses "ontransitionend" rather than "onTransitionEnd".
// See https://bugs.chromium.org/p/chromium/issues/detail?id=961193
//
// eslint-disable-next-line react/no-unknown-property
ontransitionend={handleTransitionEnd}
ref={containerRef}
style={{
display: contentVisible ? '' : 'none',
height: containerHeight,
overflow: 'hidden',
transition: `height 0.15s ease-in`,
}}
>
{children}
</div>
);
}
Slider.propTypes = {
children: propTypes.any,
/**
* Whether the content should be visible or not.
*/
visible: propTypes.bool,
};
module.exports = Slider;
......@@ -62,6 +62,11 @@ describe('GroupListItem', () => {
}
FakeMenuItem.displayName = 'MenuItem';
function FakeSlider({ children, visible }) {
return visible ? children : null;
}
FakeSlider.displayName = 'Slider';
GroupListItem.$imports.$mock({
'./menu-item': FakeMenuItem,
'../util/copy-to-clipboard': {
......@@ -179,8 +184,29 @@ describe('GroupListItem', () => {
});
});
it('expands submenu if `isExpanded` is `true`', () => {
const wrapper = createGroupListItem(fakeGroup, { isExpanded: true });
assert.isTrue(
wrapper
.find('MenuItem')
.first()
.prop('isExpanded')
);
});
it('collapses submenu if `isExpanded` is `false`', () => {
const wrapper = createGroupListItem(fakeGroup, { isExpanded: false });
assert.isFalse(
wrapper
.find('MenuItem')
.first()
.prop('isExpanded')
);
});
it('toggles submenu when toggle is clicked', () => {
const wrapper = createGroupListItem(fakeGroup);
const onExpand = sinon.stub();
const wrapper = createGroupListItem(fakeGroup, { onExpand });
const toggleSubmenu = () => {
const dummyEvent = new Event();
act(() => {
......@@ -194,64 +220,85 @@ describe('GroupListItem', () => {
};
toggleSubmenu();
assert.isTrue(wrapper.exists('ul'));
assert.calledWith(onExpand, true);
onExpand.resetHistory();
wrapper.setProps({ isExpanded: true });
toggleSubmenu();
assert.isFalse(wrapper.exists('ul'));
assert.calledWith(onExpand, false);
});
it('does not show submenu toggle if there are no available actions', () => {
fakeGroup.links.html = null;
fakeGroup.type = 'open';
const wrapper = createGroupListItem(fakeGroup);
assert.isUndefined(wrapper.find('MenuItem').prop('isExpanded'));
assert.isFalse(wrapper.find('MenuItem').prop('isExpanded'));
});
function getSubmenu(wrapper) {
const submenu = wrapper
.find('MenuItem')
.first()
.prop('submenu');
return mount(<div>{submenu}</div>);
}
it('does not show link to activity page if not available', () => {
fakeGroup.links.html = null;
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
assert.isFalse(wrapper.exists('MenuItem[label="View group activity"]'));
const submenu = getSubmenu(wrapper);
assert.isFalse(submenu.exists('MenuItem[label="View group activity"]'));
});
it('shows link to activity page if available', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
assert.isTrue(wrapper.exists('MenuItem[label="View group activity"]'));
const submenu = getSubmenu(wrapper);
assert.isTrue(submenu.exists('MenuItem[label="View group activity"]'));
});
it('does not show "Leave" action if user cannot leave', () => {
fakeGroup.type = 'open';
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
assert.isFalse(wrapper.exists('MenuItem[label="Leave group"]'));
const submenu = getSubmenu(wrapper);
assert.isFalse(submenu.exists('MenuItem[label="Leave group"]'));
});
it('shows "Leave" action if user can leave', () => {
fakeGroup.type = 'private';
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
assert.isTrue(wrapper.exists('MenuItem[label="Leave group"]'));
const submenu = getSubmenu(wrapper);
assert.isTrue(submenu.exists('MenuItem[label="Leave group"]'));
});
it('prompts to leave group if "Leave" action is clicked', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
clickMenuItem(wrapper, 'Leave group');
const submenu = getSubmenu(wrapper);
clickMenuItem(submenu, 'Leave group');
assert.called(window.confirm);
assert.notCalled(fakeGroupsService.leave);
});
it('leaves group if "Leave" is clicked and user confirms', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
window.confirm.returns(true);
clickMenuItem(wrapper, 'Leave group');
const submenu = getSubmenu(wrapper);
clickMenuItem(submenu, 'Leave group');
assert.called(window.confirm);
assert.calledWith(fakeGroupsService.leave, fakeGroup.id);
});
......@@ -277,7 +324,7 @@ describe('GroupListItem', () => {
fakeGroup.scopes.enforced = enforced;
fakeGroup.isScopedToUri = isScopedToUri;
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
assert.equal(
wrapper
......@@ -286,7 +333,9 @@ describe('GroupListItem', () => {
.prop('isDisabled'),
expectDisabled
);
assert.equal(wrapper.exists('.group-list-item__footer'), expectDisabled);
const submenu = getSubmenu(wrapper);
assert.equal(submenu.exists('.group-list-item__footer'), expectDisabled);
});
});
......@@ -316,9 +365,10 @@ describe('GroupListItem', () => {
fakeGroup.type = groupType;
fakeGroup.links.html = hasLink ? 'https://anno.co/groups/1' : null;
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
const copyAction = wrapper
const submenu = getSubmenu(wrapper);
const copyAction = submenu
.find('MenuItem')
.filterWhere(n => n.prop('label').startsWith('Copy'));
......@@ -332,9 +382,9 @@ describe('GroupListItem', () => {
it('copies activity URL if "Copy link" action is clicked', () => {
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
clickMenuItem(wrapper, 'Copy invite link');
clickMenuItem(getSubmenu(wrapper), 'Copy invite link');
assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid');
assert.calledWith(fakeFlash.info, 'Copied link for "Test"');
});
......@@ -342,9 +392,9 @@ describe('GroupListItem', () => {
it('reports an error if "Copy link" action fails', () => {
fakeCopyText.throws(new Error('Something went wrong'));
const wrapper = createGroupListItem(fakeGroup, {
defaultSubmenuOpen: true,
isExpanded: true,
});
clickMenuItem(wrapper, 'Copy invite link');
clickMenuItem(getSubmenu(wrapper), 'Copy invite link');
assert.calledWith(fakeCopyText, 'https://annotate.com/groups/groupid');
assert.calledWith(fakeFlash.error, 'Unable to copy link');
});
......
......@@ -22,8 +22,11 @@ describe('GroupListSection', () => {
const createGroupListSection = ({
groups = testGroups,
heading = 'Test section',
...props
} = {}) => {
return shallow(<GroupListSection groups={groups} heading={heading} />);
return shallow(
<GroupListSection groups={groups} heading={heading} {...props} />
);
};
it('renders heading', () => {
......@@ -35,4 +38,39 @@ describe('GroupListSection', () => {
const wrapper = createGroupListSection();
assert.equal(wrapper.find(GroupListItem).length, testGroups.length);
});
it('expands group specified by `expandedGroup` prop', () => {
const wrapper = createGroupListSection();
for (let i = 0; i < testGroups.length; i++) {
wrapper.setProps({ expandedGroup: testGroups[i] });
wrapper.find(GroupListItem).forEach((n, idx) => {
assert.equal(n.prop('isExpanded'), idx === i);
});
}
});
it("sets expanded group when a group's submenu is expanded", () => {
const onExpandGroup = sinon.stub();
const wrapper = createGroupListSection({ onExpandGroup });
wrapper
.find(GroupListItem)
.first()
.props()
.onExpand(true);
assert.calledWith(onExpandGroup, testGroups[0]);
});
it("resets expanded group when group's submenu is collapsed", () => {
const onExpandGroup = sinon.stub();
const wrapper = createGroupListSection({
expandedGroup: testGroups[0],
onExpandGroup,
});
wrapper
.find(GroupListItem)
.first()
.props()
.onExpand(false);
assert.calledWith(onExpandGroup, null);
});
});
......@@ -2,6 +2,7 @@
const { shallow } = require('enzyme');
const { createElement } = require('preact');
const { act } = require('preact/test-utils');
const GroupList = require('../group-list');
......@@ -23,6 +24,27 @@ describe('GroupList', () => {
).dive();
}
/**
* Configure the store to populate all of the group sections.
* Must be called before group list is rendered.
*/
function populateGroupSections() {
const testGroups = [
{
...testGroup,
id: 'zzz',
},
{
...testGroup,
id: 'aaa',
},
];
fakeStore.getMyGroups.returns(testGroups);
fakeStore.getCurrentlyViewingGroups.returns(testGroups);
fakeStore.getFeaturedGroups.returns(testGroups);
return testGroups;
}
beforeEach(() => {
fakeServiceUrl = sinon.stub();
fakeSettings = {
......@@ -75,20 +97,7 @@ describe('GroupList', () => {
});
it('sorts groups within each section by organization', () => {
const testGroups = [
{
...testGroup,
id: 'zzz',
},
{
...testGroup,
id: 'aaa',
},
];
fakeStore.getMyGroups.returns(testGroups);
fakeStore.getCurrentlyViewingGroups.returns(testGroups);
fakeStore.getFeaturedGroups.returns(testGroups);
const testGroups = populateGroupSections();
const fakeGroupOrganizations = groups =>
groups.sort((a, b) => a.id.localeCompare(b.id));
GroupList.$imports.$mock({
......@@ -163,4 +172,63 @@ describe('GroupList', () => {
const img = shallow(label).find('img');
assert.equal(img.prop('src'), 'test-icon');
});
/**
* Assert that the submenu for a particular group is expanded (or none is
* if `group` is `null`).
*/
const verifyGroupIsExpanded = (wrapper, group) =>
wrapper.find('GroupListSection').forEach(section => {
assert.equal(section.prop('expandedGroup'), group);
});
it("sets or resets expanded group item when a group's submenu toggle is clicked", () => {
const testGroups = populateGroupSections();
// Render group list. Initially no submenu should be expanded.
const wrapper = createGroupList();
verifyGroupIsExpanded(wrapper, null);
// Expand a group in one of the sections.
act(() => {
wrapper
.find('GroupListSection')
.first()
.prop('onExpandGroup')(testGroups[0]);
});
wrapper.update();
verifyGroupIsExpanded(wrapper, testGroups[0]);
// Reset expanded group.
act(() => {
wrapper
.find('GroupListSection')
.first()
.prop('onExpandGroup')(null);
});
wrapper.update();
verifyGroupIsExpanded(wrapper, null);
});
it('resets expanded group when menu is closed', () => {
const testGroups = populateGroupSections();
const wrapper = createGroupList();
// Expand one of the submenus.
act(() => {
wrapper
.find('GroupListSection')
.first()
.prop('onExpandGroup')(testGroups[0]);
});
wrapper.update();
verifyGroupIsExpanded(wrapper, testGroups[0]);
// Close the menu
act(() => {
wrapper.find('Menu').prop('onOpenChanged')(false);
});
wrapper.update();
verifyGroupIsExpanded(wrapper, null);
});
});
......@@ -56,7 +56,9 @@ describe('MenuItem', () => {
});
it('shows the submenu indicator if `isSubmenuVisible` is a boolean', () => {
const wrapper = createMenuItem({ isSubmenuVisible: true });
const wrapper = createMenuItem({
isSubmenuVisible: true,
});
assert.isTrue(wrapper.exists('SvgIcon[name="collapse-menu"]'));
wrapper.setProps({ isSubmenuVisible: false });
......@@ -70,7 +72,10 @@ describe('MenuItem', () => {
it('calls the `onToggleSubmenu` callback when the submenu toggle is clicked', () => {
const onToggleSubmenu = sinon.stub();
const wrapper = createMenuItem({ isSubmenuVisible: true, onToggleSubmenu });
const wrapper = createMenuItem({
isSubmenuVisible: true,
onToggleSubmenu,
});
wrapper.find('.menu-item__toggle').simulate('click');
assert.called(onToggleSubmenu);
});
......@@ -98,4 +103,42 @@ describe('MenuItem', () => {
// The actual icon for the submenu should be shown on the right.
assert.equal(iconSpaces.at(1).children().length, 1);
});
it('does not render submenu content if `isSubmenuVisible` is undefined', () => {
const wrapper = createMenuItem({});
assert.isFalse(wrapper.exists('Slider'));
});
it('shows submenu content if `isSubmenuVisible` is true', () => {
const wrapper = createMenuItem({
isSubmenuVisible: true,
submenu: <div>Submenu content</div>,
});
assert.equal(wrapper.find('Slider').prop('visible'), true);
assert.equal(
wrapper
.find('Slider')
.children()
.text(),
'Submenu content'
);
});
it('hides submenu content if `isSubmenuVisible` is false', () => {
const wrapper = createMenuItem({
isSubmenuVisible: false,
submenu: <div>Submenu content</div>,
});
assert.equal(wrapper.find('Slider').prop('visible'), false);
// The submenu content may still be rendered if the submenu is currently
// collapsing.
assert.equal(
wrapper
.find('Slider')
.children()
.text(),
'Submenu content'
);
});
});
......@@ -51,6 +51,15 @@ describe('Menu', () => {
assert.isFalse(isOpen(wrapper));
});
it('calls `onOpenChanged` prop when menu is opened or closed', () => {
const onOpenChanged = sinon.stub();
const wrapper = createMenu({ onOpenChanged });
wrapper.find('button').simulate('click');
assert.calledWith(onOpenChanged, true);
wrapper.find('button').simulate('click');
assert.calledWith(onOpenChanged, false);
});
it('opens and closes when the toggle button is pressed', () => {
const wrapper = createMenu();
assert.isFalse(isOpen(wrapper));
......
'use strict';
const { mount } = require('enzyme');
const { createElement } = require('preact');
const Slider = require('../slider');
describe('Slider', () => {
let container;
const createSlider = (props = {}) => {
return mount(
<Slider visible={false} {...props}>
<div style={{ width: 100, height: 200 }}>Test content</div>
</Slider>,
{ attachTo: container }
);
};
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it('should render collapsed if `visible` is false on mount', () => {
const wrapper = createSlider({ visible: false });
const { height } = wrapper.getDOMNode().getBoundingClientRect();
assert.equal(height, 0);
// The content shouldn't be rendered, so it doesn't appear in the keyboard
// navigation order.
assert.equal(wrapper.getDOMNode().style.display, 'none');
});
it('should render expanded if `visible` is true on mount', () => {
const wrapper = createSlider({ visible: true });
const { height } = wrapper.getDOMNode().getBoundingClientRect();
assert.equal(height, 200);
});
it('should transition to expanded if `visible` changes to `true`', () => {
const wrapper = createSlider({ visible: false });
wrapper.setProps({ visible: true });
const containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.height, '200px');
});
it('should transition to collapsed if `visible` changes to `false`', done => {
const wrapper = createSlider({ visible: true });
wrapper.setProps({ visible: false });
setTimeout(() => {
const { height } = wrapper.getDOMNode().getBoundingClientRect();
assert.equal(height, 0);
done();
}, 1);
});
it('should set the container height to "auto" when an expand transition finishes', () => {
const wrapper = createSlider({ visible: false });
wrapper.setProps({ visible: true });
let containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.height, '200px');
wrapper
.find('div')
.first()
.simulate('transitionend');
containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.height, 'auto');
});
it('should stop rendering content when a collapse transition finishes', () => {
const wrapper = createSlider({ visible: true });
wrapper.setProps({ visible: false });
wrapper
.find('div')
.first()
.simulate('transitionend');
const containerStyle = wrapper.getDOMNode().style;
assert.equal(containerStyle.display, 'none');
});
[true, false].forEach(visible => {
it('should handle unmounting while expanding or collapsing', () => {
const wrapper = createSlider({ visible });
wrapper.setProps({ visible: !visible });
wrapper.unmount();
});
});
});
.group-list-item__submenu {
border-bottom: solid 1px $grey-2;
}
// Footer to display at the bottom of a menu item.
.group-list-item__footer {
background-color: $grey-1;
......
......@@ -28,7 +28,13 @@ $menu-item-padding: 10px;
font-weight: normal;
}
// Animate the background color transition of menu items with expanding submenus.
// The timing should match the expansion of the submenu.
transition: background 0.15s ease-in;
&.is-expanded {
// Set the background color of menu items with submenus to match the
// submenu items.
background: $grey-1;
}
}
......@@ -142,3 +148,8 @@ $menu-item-padding: 10px;
height: 12px;
}
}
// The container for open submenus
.menu-item__submenu {
border-bottom: solid 1px $grey-2;
}
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