Commit 8f154c08 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Migrate Menu to tailwind

Update a few components to account for style adjustments
parent d23de12f
...@@ -46,8 +46,11 @@ function AnnotationPublishControl({ ...@@ -46,8 +46,11 @@ function AnnotationPublishControl({
); );
const menuLabel = ( const menuLabel = (
<div className="p-2.5 text-color-text-inverted" style={buttonStyle}> <div
<Icon name="expand-menu" /> className="w-9 h-9 flex items-center justify-center text-color-text-inverted"
style={buttonStyle}
>
<Icon name="expand-menu" classes="w-4 h-4" />
</div> </div>
); );
......
import classnames from 'classnames'; import classnames from 'classnames';
import { import {
SvgIcon, Icon,
normalizeKeyName, normalizeKeyName,
useElementShouldClose, useElementShouldClose,
} from '@hypothesis/frontend-shared'; } from '@hypothesis/frontend-shared';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import MenuArrow from './MenuArrow';
import MenuKeyboardNavigation from './MenuKeyboardNavigation'; import MenuKeyboardNavigation from './MenuKeyboardNavigation';
/**
* The triangular indicator below the menu toggle button that visually links it
* to the menu content.
*
* @param {object} props
* @param {string} [props.className]
*/
function MenuArrow({ className }) {
return (
<svg className={classnames('Menu__arrow', className)} width={15} height={8}>
<path d="M0 8 L7 0 L15 8" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
/** /**
* Flag indicating whether the next click event on the menu's toggle button * Flag indicating whether the next click event on the menu's toggle button
* should be ignored, because the action it would trigger has already been * should be ignored, because the action it would trigger has already been
...@@ -171,7 +157,8 @@ export default function Menu({ ...@@ -171,7 +157,8 @@ export default function Menu({
// See https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events // See https://github.com/evcohen/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div <div
className="Menu" className="relative"
data-testid="menu-container"
ref={menuRef} ref={menuRef}
// Add inline styles for positioning // Add inline styles for positioning
style={containerStyle} style={containerStyle}
...@@ -186,7 +173,15 @@ export default function Menu({ ...@@ -186,7 +173,15 @@ export default function Menu({
<button <button
aria-expanded={isOpen ? 'true' : 'false'} aria-expanded={isOpen ? 'true' : 'false'}
aria-haspopup={true} aria-haspopup={true}
className="Menu__toggle" className={classnames(
'hyp-u-outline-on-keyboard-focus',
'flex items-center justify-center rounded-sm transition-colors',
{
'text-grey-7 hover:text-grey-9': !isOpen,
'text-brand': isOpen,
}
)}
data-testid="menu-toggle-button"
onMouseDown={toggleMenu} onMouseDown={toggleMenu}
onClick={toggleMenu} onClick={toggleMenu}
aria-label={title} aria-label={title}
...@@ -194,27 +189,43 @@ export default function Menu({ ...@@ -194,27 +189,43 @@ export default function Menu({
> >
<span <span
// wrapper is needed to serve as the flex layout for the label and indicator content. // wrapper is needed to serve as the flex layout for the label and indicator content.
className="Menu__toggle-wrapper" className="flex items-center gap-x-1"
> >
{label} {label}
{menuIndicator && ( {menuIndicator && (
<span <span
className={classnames('Menu__toggle-arrow', isOpen && 'is-open')} className={classnames({
'rotate-180 text-color-text': isOpen,
})}
> >
<SvgIcon name="expand-menu" className="Menu__toggle-icon" /> <Icon name="expand-menu" classes="w-2.5 h-2.5" />
</span> </span>
)} )}
</span> </span>
</button> </button>
{isOpen && ( {isOpen && (
<> <>
<MenuArrow className={arrowClass} /> <MenuArrow
direction="up"
classes={classnames(
// Position menu-arrow caret near bottom right of menu label/toggle control
'right-0 top-[calc(100%-6px)] w-[15px]',
arrowClass
)}
/>
<div <div
className={classnames( className={classnames(
'Menu__content', 'hyp-u-outline-on-keyboard-focus',
`Menu__content--align-${align}`, // Position menu content near bottom of menu label/toggle control
'absolute top-[calc(100%+5px)] z-1 border shadow',
'bg-white text-lg',
{
'left-0': align === 'left',
'right-0': align === 'right',
},
contentClass contentClass
)} )}
data-testid="menu-content"
role="menu" role="menu"
tabIndex={-1} tabIndex={-1}
onClick={closeMenu} onClick={closeMenu}
......
...@@ -10,6 +10,10 @@ import { checkAccessibility } from '../../../test-util/accessibility'; ...@@ -10,6 +10,10 @@ import { checkAccessibility } from '../../../test-util/accessibility';
describe('Menu', () => { describe('Menu', () => {
let container; let container;
const menuSelector = '[data-testid="menu-container"]';
const contentSelector = '[data-testid="menu-content"]';
const toggleSelector = 'button[data-testid="menu-toggle-button"]';
const TestLabel = () => 'Test label'; const TestLabel = () => 'Test label';
const TestMenuItem = () => 'Test item'; const TestMenuItem = () => 'Test item';
...@@ -25,7 +29,7 @@ describe('Menu', () => { ...@@ -25,7 +29,7 @@ describe('Menu', () => {
}; };
function isOpen(wrapper) { function isOpen(wrapper) {
return wrapper.exists('.Menu__content'); return wrapper.exists(contentSelector);
} }
beforeEach(() => { beforeEach(() => {
...@@ -43,9 +47,9 @@ describe('Menu', () => { ...@@ -43,9 +47,9 @@ describe('Menu', () => {
it('opens and closes when the toggle button is clicked', () => { it('opens and closes when the toggle button is clicked', () => {
const wrapper = createMenu(); const wrapper = createMenu();
assert.isFalse(isOpen(wrapper)); assert.isFalse(isOpen(wrapper));
wrapper.find('button').simulate('click'); wrapper.find(toggleSelector).simulate('click');
assert.isTrue(isOpen(wrapper)); assert.isTrue(isOpen(wrapper));
wrapper.find('button').simulate('click'); wrapper.find(toggleSelector).simulate('click');
assert.isFalse(isOpen(wrapper)); assert.isFalse(isOpen(wrapper));
}); });
...@@ -56,16 +60,16 @@ describe('Menu', () => { ...@@ -56,16 +60,16 @@ describe('Menu', () => {
assert.isTrue(isOpen(wrapper)); assert.isTrue(isOpen(wrapper));
wrapper.find('button').simulate('click'); wrapper.find(toggleSelector).simulate('click');
assert.isTrue(isOpen(wrapper)); assert.isTrue(isOpen(wrapper));
}); });
it('calls `onOpenChanged` prop when menu is opened or closed', () => { it('calls `onOpenChanged` prop when menu is opened or closed', () => {
const onOpenChanged = sinon.stub(); const onOpenChanged = sinon.stub();
const wrapper = createMenu({ onOpenChanged }); const wrapper = createMenu({ onOpenChanged });
wrapper.find('button').simulate('click'); wrapper.find(toggleSelector).simulate('click');
assert.calledWith(onOpenChanged, true); assert.calledWith(onOpenChanged, true);
wrapper.find('button').simulate('click'); wrapper.find(toggleSelector).simulate('click');
assert.calledWith(onOpenChanged, false); assert.calledWith(onOpenChanged, false);
}); });
...@@ -73,9 +77,9 @@ describe('Menu', () => { ...@@ -73,9 +77,9 @@ describe('Menu', () => {
const wrapper = createMenu(); const wrapper = createMenu();
assert.isFalse(isOpen(wrapper)); assert.isFalse(isOpen(wrapper));
wrapper.find('button').simulate('mousedown'); wrapper.find(toggleSelector).simulate('mousedown');
// Make sure the follow-up click doesn't close the menu. // Make sure the follow-up click doesn't close the menu.
wrapper.find('button').simulate('click'); wrapper.find(toggleSelector).simulate('click');
assert.isTrue(isOpen(wrapper)); assert.isTrue(isOpen(wrapper));
}); });
...@@ -96,12 +100,6 @@ describe('Menu', () => { ...@@ -96,12 +100,6 @@ describe('Menu', () => {
assert.isTrue(wrapper.exists(TestMenuItem)); assert.isTrue(wrapper.exists(TestMenuItem));
}); });
it('flips toggle arrow when open', () => {
const wrapper = createMenu({ defaultOpen: true });
const toggle = wrapper.find('.Menu__toggle-arrow');
assert.isTrue(toggle.hasClass('is-open'));
});
let e; let e;
[ [
new Event('mousedown'), new Event('mousedown'),
...@@ -136,13 +134,13 @@ describe('Menu', () => { ...@@ -136,13 +134,13 @@ describe('Menu', () => {
it('does not close menu if user presses mouse on menu content', () => { it('does not close menu if user presses mouse on menu content', () => {
const wrapper = createMenu({ defaultOpen: true }); const wrapper = createMenu({ defaultOpen: true });
let content = wrapper.find('.Menu__content'); let content = wrapper.find(contentSelector);
act(() => { act(() => {
content content
.getDOMNode() .getDOMNode()
.dispatchEvent(new Event('mousedown', { bubbles: true })); .dispatchEvent(new Event('mousedown', { bubbles: true }));
wrapper.update(); wrapper.update();
content = wrapper.find('.Menu__content'); content = wrapper.find(contentSelector);
}); });
assert.isTrue(isOpen(wrapper)); assert.isTrue(isOpen(wrapper));
}); });
...@@ -180,7 +178,7 @@ describe('Menu', () => { ...@@ -180,7 +178,7 @@ describe('Menu', () => {
const clock = sinon.useFakeTimers(); const clock = sinon.useFakeTimers();
try { try {
const wrapper = createMenu({ defaultOpen: true }); const wrapper = createMenu({ defaultOpen: true });
wrapper.find('.Menu__content').simulate(eventType, { key }); wrapper.find(contentSelector).simulate(eventType, { key });
// The close event is delayed by a minimal amount of time in // The close event is delayed by a minimal amount of time in
// order to allow links to say in the DOM long enough to be // order to allow links to say in the DOM long enough to be
// followed on a click. Therefore, this test must simulate // followed on a click. Therefore, this test must simulate
...@@ -199,19 +197,20 @@ describe('Menu', () => { ...@@ -199,19 +197,20 @@ describe('Menu', () => {
// The event may be received either by the top `<div>` or the arrow element // The event may be received either by the top `<div>` or the arrow element
// itself. // itself.
wrapper.find('.Menu').simulate('mousedown'); wrapper.find(menuSelector).simulate('mousedown');
wrapper.find('.Menu__arrow').simulate('mousedown');
assert.isTrue(isOpen(wrapper));
}); });
it('aligns menu content depending on `align` prop', () => { it('aligns menu content depending on `align` prop', () => {
const wrapper = createMenu({ defaultOpen: true }); const wrapper = createMenu({ defaultOpen: true });
assert.isTrue(wrapper.exists('.Menu__content--align-left')); assert.isTrue(wrapper.find(contentSelector).hasClass('left-0'));
wrapper.setProps({ align: 'left' }); wrapper.setProps({ align: 'left' });
assert.isTrue(wrapper.exists('.Menu__content--align-left')); assert.isTrue(wrapper.find(contentSelector).hasClass('left-0'));
wrapper.setProps({ align: 'right' }); wrapper.setProps({ align: 'right' });
assert.isTrue(wrapper.exists('.Menu__content--align-right')); assert.isTrue(wrapper.find(contentSelector).hasClass('right-0'));
}); });
it('applies custom content class', () => { it('applies custom content class', () => {
...@@ -219,7 +218,7 @@ describe('Menu', () => { ...@@ -219,7 +218,7 @@ describe('Menu', () => {
defaultOpen: true, defaultOpen: true,
contentClass: 'special-menu', contentClass: 'special-menu',
}); });
const content = wrapper.find('.Menu__content'); const content = wrapper.find(contentSelector);
assert.isTrue(content.hasClass('special-menu')); assert.isTrue(content.hasClass('special-menu'));
}); });
...@@ -228,16 +227,16 @@ describe('Menu', () => { ...@@ -228,16 +227,16 @@ describe('Menu', () => {
arrowClass: 'my-arrow-class', arrowClass: 'my-arrow-class',
defaultOpen: true, defaultOpen: true,
}); });
const arrow = wrapper.find('.Menu__arrow'); const arrow = wrapper.find('MenuArrow');
assert.isTrue(arrow.hasClass('my-arrow-class')); assert.include(arrow.props().classes, 'my-arrow-class');
}); });
it('has relative positioning if `containerPositioned` is `true`', () => { it('has relative positioning if `containerPositioned` is `true`', () => {
const wrapper = createMenu({ const wrapper = createMenu({
containerPositioned: true, // default containerPositioned: true, // default
}); });
const menuContainer = wrapper.find('.Menu'); const menuContainer = wrapper.find(menuSelector);
assert.include({ position: 'relative' }, menuContainer.prop('style')); assert.include({ position: 'relative' }, menuContainer.prop('style'));
}); });
...@@ -246,7 +245,7 @@ describe('Menu', () => { ...@@ -246,7 +245,7 @@ describe('Menu', () => {
const wrapper = createMenu({ const wrapper = createMenu({
containerPositioned: false, containerPositioned: false,
}); });
const menuContainer = wrapper.find('.Menu'); const menuContainer = wrapper.find(menuSelector);
assert.include({ position: 'static' }, menuContainer.prop('style')); assert.include({ position: 'static' }, menuContainer.prop('style'));
}); });
......
@use '@hypothesis/frontend-shared/styles/mixins/focus';
@use '../../mixins/buttons';
@use '../../mixins/layout';
@use '../../mixins/molecules';
@use '../../mixins/utils';
@use '../../variables' as var;
.Menu {
position: relative;
}
// Toggle button that opens the menu.
.Menu__toggle {
// Override $coarse-min-size so the button's size won't change in mobile view.
@include buttons.button--icon-only($coarse-min-size: inherit);
appearance: none;
background: none;
padding: 0;
// "block" display is needed so it can take up the
// full height of its parent container
display: block;
height: 100%;
align-items: center;
// nb. This selector is nested to ensure it has higher specificity than the
// default icon size set by the `buttons.button--icon-only` mixin above.
.Menu__toggle-icon {
@include utils.icon--xsmall;
}
}
.Menu__toggle-wrapper {
@include layout.row($align: center);
height: 100%;
}
// 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;
&.is-open {
// Flip the indicator when the menu is open.
transform: rotateX(180deg);
color: var.$color-text;
}
}
// Triangular indicator at the top of the menu that associates it with the
// toggle button.
.Menu__arrow {
@include molecules.menu-arrow;
// Position the arrow so that it appears flush with the right edge of the
// content when the menu is right-aligned, and the bottom of the arrow just
// overlaps the content's border. The effect is that the menu's border is a
// rounded rect with a notch at the top.
top: calc(100% - 2px); // nb. Adjust this if changing the <svg> size.
right: 0;
}
// Content area of the menu.
.Menu__content {
@include utils.font--large;
@include utils.border;
@include utils.shadow;
@include focus.outline-on-keyboard-focus;
background-color: white;
position: absolute;
top: calc(100% + 5px);
z-index: 1;
&--align-left {
left: 0;
}
&--align-right {
right: 0;
}
}
...@@ -12,7 +12,6 @@ ...@@ -12,7 +12,6 @@
@use './FilterSelect'; @use './FilterSelect';
@use './GroupList'; @use './GroupList';
@use './GroupListItem'; @use './GroupListItem';
@use './Menu';
@use './StyledText'; @use './StyledText';
// TODO: Evaluate all classes below after components have been converted to // TODO: Evaluate all classes below after components have been converted to
......
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