Commit 67b517fb authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Update `MenuItem` to take `IconComponent`s, `leftChannelContent`

Previously, `MenuItem`'s `icon` prop accepted a string, which could be:

- the name of a registered icon
- a URL (in which case an `img` with that URL as `src` was rendered)
- the string "blank" (in which case space was added at left)

This became over-complex when migrating to use updated icon components.
Now the `icon` prop expects an `IconComponent`, but `MenuItem` also
takes a `leftChannelContent` prop. This allows consumers to set any
content in the left channel — e.g. an image instead of an icon — and
reduces the complexity of this component.

`GroupListItem` has been updated to set `leftChannelContent` as needed
instead of passing "blank" or a URL to an image as the `icon` prop.

Other components updated to provide `IconComponent`s instead of strings.
parent ec8c3bcf
import { import {
Button, Button,
CancelIcon, CancelIcon,
GlobeIcon,
GroupsIcon,
LockIcon,
MenuExpandIcon, MenuExpandIcon,
} from '@hypothesis/frontend-shared/lib/next'; } from '@hypothesis/frontend-shared/lib/next';
import classnames from 'classnames'; import classnames from 'classnames';
...@@ -114,13 +117,13 @@ function AnnotationPublishControl({ ...@@ -114,13 +117,13 @@ function AnnotationPublishControl({
align="left" align="left"
> >
<MenuItem <MenuItem
icon={group.type === 'open' ? 'public' : 'groups'} icon={group.type === 'open' ? GlobeIcon : GroupsIcon}
label={group.name} label={group.name}
isSelected={!isPrivate} isSelected={!isPrivate}
onClick={() => onSetPrivate(false)} onClick={() => onSetPrivate(false)}
/> />
<MenuItem <MenuItem
icon="lock" icon={LockIcon}
label="Only Me" label="Only Me"
isSelected={isPrivate} isSelected={isPrivate}
onClick={() => onSetPrivate(true)} onClick={() => onSetPrivate(true)}
......
import {
GlobeIcon,
GroupsIcon,
LockIcon,
} from '@hypothesis/frontend-shared/lib/next';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { checkAccessibility } from '../../../../test-util/accessibility'; import { checkAccessibility } from '../../../../test-util/accessibility';
...@@ -140,7 +145,7 @@ describe('AnnotationPublishControl', () => { ...@@ -140,7 +145,7 @@ describe('AnnotationPublishControl', () => {
const wrapper = createAnnotationPublishControl(); const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find('MenuItem').first(); const shareMenuItem = wrapper.find('MenuItem').first();
assert.equal(shareMenuItem.prop('icon'), 'groups'); assert.equal(shareMenuItem.props().icon, GroupsIcon);
}); });
}); });
...@@ -153,7 +158,7 @@ describe('AnnotationPublishControl', () => { ...@@ -153,7 +158,7 @@ describe('AnnotationPublishControl', () => {
const wrapper = createAnnotationPublishControl(); const wrapper = createAnnotationPublishControl();
const shareMenuItem = wrapper.find('MenuItem').first(); const shareMenuItem = wrapper.find('MenuItem').first();
assert.equal(shareMenuItem.prop('icon'), 'public'); assert.equal(shareMenuItem.props().icon, GlobeIcon);
}); });
}); });
}); });
...@@ -173,7 +178,7 @@ describe('AnnotationPublishControl', () => { ...@@ -173,7 +178,7 @@ describe('AnnotationPublishControl', () => {
const wrapper = createAnnotationPublishControl(); const wrapper = createAnnotationPublishControl();
const privateMenuItem = wrapper.find('MenuItem').at(1); const privateMenuItem = wrapper.find('MenuItem').at(1);
assert.equal(privateMenuItem.prop('icon'), 'lock'); assert.equal(privateMenuItem.prop('icon'), LockIcon);
}); });
it('should have an "Only me" label', () => { it('should have an "Only me" label', () => {
......
import classnames from 'classnames'; import classnames from 'classnames';
import { PlusIcon } from '@hypothesis/frontend-shared/lib/next';
import { useMemo, useState } from 'preact/hooks'; import { useMemo, useState } from 'preact/hooks';
import { serviceConfig } from '../../config/service-config'; import { serviceConfig } from '../../config/service-config';
...@@ -158,7 +159,11 @@ function GroupList({ settings }) { ...@@ -158,7 +159,11 @@ function GroupList({ settings }) {
)} )}
{canCreateNewGroup && ( {canCreateNewGroup && (
<MenuItem icon="add" href={newGroupLink} label="New private group" /> <MenuItem
icon={PlusIcon}
href={newGroupLink}
label="New private group"
/>
)} )}
</Menu> </Menu>
); );
......
import classnames from 'classnames'; import classnames from 'classnames';
import {
CopyIcon,
ExternalIcon,
LeaveIcon,
} from '@hypothesis/frontend-shared/lib/next';
import { orgName } from '../../helpers/group-list-item-common'; import { orgName } from '../../helpers/group-list-item-common';
import { withServices } from '../../service-context'; import { withServices } from '../../service-context';
...@@ -95,15 +100,20 @@ function GroupListItem({ ...@@ -95,15 +100,20 @@ function GroupListItem({
const copyLinkLabel = const copyLinkLabel =
group.type === 'private' ? 'Copy invite link' : 'Copy activity link'; group.type === 'private' ? 'Copy invite link' : 'Copy activity link';
const leftChannelContent = group.logo ? (
<img className="w-4 h-4" alt={orgName(group)} src={group.logo} />
) : (
<span className="sr-only">{orgName(group)}</span>
);
return ( return (
<MenuItem <MenuItem
icon={group.logo || 'blank'}
iconAlt={orgName(group)}
isDisabled={!isSelectable} isDisabled={!isSelectable}
isExpanded={hasActionMenu ? isExpanded : false} isExpanded={hasActionMenu ? isExpanded : false}
isSelected={isSelected} isSelected={isSelected}
isSubmenuVisible={hasActionMenu ? isExpanded : undefined} isSubmenuVisible={hasActionMenu ? isExpanded : undefined}
label={group.name} label={group.name}
leftChannelContent={leftChannelContent}
onClick={isSelectable ? focusGroup : toggleSubmenu} onClick={isSelectable ? focusGroup : toggleSubmenu}
onToggleSubmenu={toggleSubmenu} onToggleSubmenu={toggleSubmenu}
submenu={ submenu={
...@@ -113,7 +123,7 @@ function GroupListItem({ ...@@ -113,7 +123,7 @@ function GroupListItem({
<li> <li>
<MenuItem <MenuItem
href={activityUrl} href={activityUrl}
icon="external" icon={ExternalIcon}
isSubmenuItem={true} isSubmenuItem={true}
label="View group activity" label="View group activity"
/> />
...@@ -123,7 +133,7 @@ function GroupListItem({ ...@@ -123,7 +133,7 @@ function GroupListItem({
<li> <li>
<MenuItem <MenuItem
onClick={() => copyLink(activityUrl)} onClick={() => copyLink(activityUrl)}
icon="copy" icon={CopyIcon}
isSubmenuItem={true} isSubmenuItem={true}
label={copyLinkLabel} label={copyLinkLabel}
/> />
...@@ -132,7 +142,7 @@ function GroupListItem({ ...@@ -132,7 +142,7 @@ function GroupListItem({
{group.canLeave && ( {group.canLeave && (
<li> <li>
<MenuItem <MenuItem
icon="leave" icon={LeaveIcon}
isSubmenuItem={true} isSubmenuItem={true}
label="Leave group" label="Leave group"
onClick={leaveGroup} onClick={leaveGroup}
......
...@@ -127,9 +127,9 @@ describe('GroupListItem', () => { ...@@ -127,9 +127,9 @@ describe('GroupListItem', () => {
.returns(group.organization.name); .returns(group.organization.name);
const wrapper = createGroupListItem(group); const wrapper = createGroupListItem(group);
const altText = wrapper.find('MenuItem').prop('iconAlt'); const leftContent = wrapper.find('MenuItem').prop('leftChannelContent');
assert.equal(altText, group.organization.name); assert.equal(leftContent.props.alt, group.organization.name);
}); });
describe('selected state', () => { describe('selected state', () => {
......
import classnames from 'classnames'; import classnames from 'classnames';
import { Icon } from '@hypothesis/frontend-shared'; import type { IconComponent } from '@hypothesis/frontend-shared/lib/types';
import { import {
CaretUpIcon,
MenuExpandIcon, MenuExpandIcon,
MenuCollapseIcon,
} from '@hypothesis/frontend-shared/lib/next'; } from '@hypothesis/frontend-shared/lib/next';
import type { ComponentChildren, Ref } from 'preact'; import type { ComponentChildren, Ref } from 'preact';
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
...@@ -21,7 +21,9 @@ function SubmenuToggle({ ...@@ -21,7 +21,9 @@ function SubmenuToggle({
isExpanded, isExpanded,
onToggleSubmenu, onToggleSubmenu,
}: SubmenuToggleProps) { }: SubmenuToggleProps) {
const Icon = isExpanded ? MenuCollapseIcon : MenuExpandIcon; // FIXME: Use `MenuCollapseIcon` instead of `CaretUpIcon` once size
// disparities are addressed
const Icon = isExpanded ? CaretUpIcon : MenuExpandIcon;
return ( return (
<div <div
data-testid="submenu-toggle" data-testid="submenu-toggle"
...@@ -53,10 +55,7 @@ function SubmenuToggle({ ...@@ -53,10 +55,7 @@ function SubmenuToggle({
onClick={onToggleSubmenu} onClick={onToggleSubmenu}
title={title} title={title}
> >
<Icon <Icon className="w-3 h-3" />
name={isExpanded ? 'collapse-menu' : 'expand-menu'}
className="w-3 h-3"
/>
</div> </div>
); );
} }
...@@ -67,17 +66,13 @@ export type MenuItemProps = { ...@@ -67,17 +66,13 @@ export type MenuItemProps = {
* `href` or an `onClick` callback should be supplied. * `href` or an `onClick` callback should be supplied.
*/ */
href?: string; href?: string;
/** Alt text for icon */
iconAlt?: string;
/** /**
* Name or URL of icon to display. If the value is a URL it is displayed using * Icon to render for this item. This will show to the left of the item label
* an `<img>`; if it is a non-URL string it is assumed to be the `name` of a * unless this is a submenu item, in which case it goes on the right. Ignored
* registered icon. If the property is `"blank"` a blank placeholder is * if this is not a submenu item and `leftChannelContent` is also provided.
* displayed in place of an icon. The placeholder is useful to keep menu item
* labels aligned.
*/ */
icon?: string; icon?: IconComponent;
/** /**
* Dim the label to indicate that this item is not currently available. The * Dim the label to indicate that this item is not currently available. The
...@@ -108,7 +103,15 @@ export type MenuItemProps = { ...@@ -108,7 +103,15 @@ export type MenuItemProps = {
*/ */
isSubmenuVisible?: boolean; isSubmenuVisible?: boolean;
label: string; label: ComponentChildren;
/**
* Optional content to render into a left channel. This accommodates small
* non-icon images or spacing and will supersede any provided icon if this
* is not a submenu item.
*/
leftChannelContent?: ComponentChildren;
onClick?: (e: Event) => void; onClick?: (e: Event) => void;
onToggleSubmenu?: (e: Event) => void; onToggleSubmenu?: (e: Event) => void;
/** /**
...@@ -138,20 +141,18 @@ export type MenuItemProps = { ...@@ -138,20 +141,18 @@ export type MenuItemProps = {
*/ */
export default function MenuItem({ export default function MenuItem({
href, href,
icon, icon: Icon,
iconAlt,
isDisabled, isDisabled,
isExpanded, isExpanded,
isSelected, isSelected,
isSubmenuItem, isSubmenuItem,
isSubmenuVisible, isSubmenuVisible,
label, label,
leftChannelContent,
onClick, onClick,
onToggleSubmenu, onToggleSubmenu,
submenu, submenu,
}: MenuItemProps) { }: MenuItemProps) {
const iconIsUrl = icon && icon.indexOf('/') !== -1;
const menuItemRef = useRef<HTMLAnchorElement | HTMLDivElement | null>(null); const menuItemRef = useRef<HTMLAnchorElement | HTMLDivElement | null>(null);
let focusTimer: number | undefined; let focusTimer: number | undefined;
...@@ -197,40 +198,27 @@ export default function MenuItem({ ...@@ -197,40 +198,27 @@ export default function MenuItem({
} }
}; };
let renderedIcon = null; const renderedIcon = Icon ? <Icon className="h-3 w-3" /> : null;
if (icon && icon !== 'blank') { const leftIcon = !isSubmenuItem ? renderedIcon : null;
renderedIcon = iconIsUrl ? (
<img className="w-4 h-4" alt={iconAlt} src={icon} />
) : (
<Icon name={icon} classes="h-3 w-3" />
);
}
const leftIcon = isSubmenuItem ? null : renderedIcon;
const rightIcon = isSubmenuItem ? renderedIcon : null; const rightIcon = isSubmenuItem ? renderedIcon : null;
// MenuItem content layout consists of: const hasLeftChannel = leftChannelContent || isSubmenuItem || !!leftIcon;
// - Sometimes a left item, which may contain an icon or serve as const hasRightContent = !!rightIcon;
// an indenting space for label alignment
// - Always a label
// - Sometimes a right item, which contains an icon (submenu items)
// - Sometimes a submenu-toggle control (only if the item has a submenu)
const hasLeftItem = leftIcon || isSubmenuItem || icon === 'blank';
const hasRightItem = rightIcon && isSubmenuItem;
const menuItemContent = ( const menuItemContent = (
<> <>
{hasLeftItem && ( {hasLeftChannel && (
<div <div
className="w-7 flex items-center justify-center" className="w-7 flex items-center justify-center"
data-testid="left-item-container" data-testid="left-item-container"
> >
{leftIcon} {leftChannelContent ?? leftIcon}
</div> </div>
)} )}
<span className="flex items-center grow whitespace-nowrap px-1"> <span className="flex items-center grow whitespace-nowrap px-1">
{label} {label}
</span> </span>
{hasRightItem && ( {hasRightContent && (
<div <div
className="w-8 flex items-center justify-center" className="w-8 flex items-center justify-center"
data-testid="right-item-container" data-testid="right-item-container"
......
import { EditIcon } from '@hypothesis/frontend-shared/lib/next';
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'preact/test-utils'; import { act } from 'preact/test-utils';
...@@ -40,13 +41,6 @@ describe('MenuItem', () => { ...@@ -40,13 +41,6 @@ describe('MenuItem', () => {
assert.equal(link.prop('rel'), 'noopener noreferrer'); assert.equal(link.prop('rel'), 'noopener noreferrer');
}); });
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('invokes `onClick` callback when pressing `Enter` or space', () => { it('invokes `onClick` callback when pressing `Enter` or space', () => {
const onClick = sinon.stub(); const onClick = sinon.stub();
const wrapper = createMenuItem({ href: 'https://example.com', onClick }); const wrapper = createMenuItem({ href: 'https://example.com', onClick });
...@@ -93,20 +87,25 @@ describe('MenuItem', () => { ...@@ -93,20 +87,25 @@ describe('MenuItem', () => {
}); });
describe('icons for top-level menu items', () => { describe('icons for top-level menu items', () => {
it('renders an icon if an icon name is provided', () => { it('renders an icon if an icon is provided', () => {
const wrapper = createMenuItem({ icon: 'edit' }); const wrapper = createMenuItem({ icon: EditIcon });
assert.isTrue(wrapper.exists('Icon[name="edit"]')); assert.isTrue(wrapper.exists('EditIcon'));
}); });
it('adds a left container if `icon` is "blank"', () => { it('adds a left container if left content is provided', () => {
const wrapper = createMenuItem({ icon: 'blank' }); const wrapper = createMenuItem({
assert.equal( leftChannelContent: <span>Hi</span>,
wrapper.find('[data-testid="left-item-container"]').length, icon: EditIcon,
1 });
const leftChannel = wrapper.find('[data-testid="left-item-container"]');
assert.equal(leftChannel.text(), 'Hi');
assert.isFalse(
wrapper.exists('EditIcon'),
'Icon ignored if left channel content provided'
); );
}); });
it('does not add a left container if `icon` is missing', () => { it('does not add a left container if neither icon nor left content provided', () => {
const wrapper = createMenuItem(); const wrapper = createMenuItem();
assert.equal( assert.equal(
wrapper.find('[data-testid="left-item-container"]').length, wrapper.find('[data-testid="left-item-container"]').length,
...@@ -115,7 +114,7 @@ describe('MenuItem', () => { ...@@ -115,7 +114,7 @@ describe('MenuItem', () => {
}); });
it('renders an icon on the left if `icon` provided', () => { it('renders an icon on the left if `icon` provided', () => {
const wrapper = createMenuItem({ icon: 'edit' }); const wrapper = createMenuItem({ icon: EditIcon });
const leftItem = wrapper.find('[data-testid="left-item-container"]'); const leftItem = wrapper.find('[data-testid="left-item-container"]');
// There should be only one icon space, on the left. // There should be only one icon space, on the left.
...@@ -174,16 +173,12 @@ describe('MenuItem', () => { ...@@ -174,16 +173,12 @@ describe('MenuItem', () => {
it('renders submenu item icons on the right', () => { it('renders submenu item icons on the right', () => {
const wrapper = createMenuItem({ const wrapper = createMenuItem({
icon: 'edit', icon: EditIcon,
isSubmenuItem: true, isSubmenuItem: true,
submenu: <div role="menuitem">Submenu content</div>, submenu: <div role="menuitem">Submenu content</div>,
}); });
const rightItem = wrapper.find('[data-testid="right-item-container"]'); const rightItem = wrapper.find('[data-testid="right-item-container"]');
assert.isTrue(rightItem.find('EditIcon').exists());
assert.equal(rightItem.length, 1);
// The actual icon for the submenu should be shown on the right.
assert.equal(rightItem.at(0).children().length, 1);
}); });
it('does not render submenu content if `isSubmenuVisible` is undefined', () => { it('does not render submenu content if `isSubmenuVisible` is undefined', () => {
......
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