Skip to content

Commit

Permalink
feat: create select menu (#809)
Browse files Browse the repository at this point in the history
 Per Specs

Jira Ticket: https://openedx.atlassian.net/browse/PAR-456

The default menu component is triggered on the click of a button or standalone link, and expands from the center. The menu contains a list of MenuItems, with a white background, and level 2 elevation. The menu also remembers the user鈥檚 selection and displays it as the label for the button trigger.

Any button or standalone link style can be used as a trigger, not just the secondary button in the example.

TODO: Unit Tests, Make Seperate readme for selectMenu with more detail
  • Loading branch information
connorhaugh committed Sep 2, 2021
1 parent 262b7c2 commit 7c13754
Show file tree
Hide file tree
Showing 8 changed files with 456 additions and 8 deletions.
28 changes: 26 additions & 2 deletions src/Menu/Menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
justify-content: flex-start;
width: 100%;
min-width: 288px;
max-width: 450px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: $btn-font-family;
font-weight: $btn-font-weight;
color: $body-color;
Expand Down Expand Up @@ -69,7 +73,27 @@
.pgn__menu{
border-radius: 0.25em;
box-shadow: $box-shadow;
background-color: $btn-tertiary-bg;
background-color: $white;
border-color: transparent;
overflow: hidden;
overflow: scroll;
min-width: 288px;
max-width: 450px;
max-height: 264px;
}
.pgn__menu-select-popup{
max-height: 267px;
color: $btn-tertiary-bg;
}
.pgn__menu-select-trigger-btn{
background: $white;
border: 1px solid #F2F0EF;
box-sizing: border-box;
color: $dark;

@include hover {
color: $white;
background: $primary;
border: 1px solid $white;
box-sizing: border-box;
}
}
3 changes: 3 additions & 0 deletions src/Menu/MenuItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Icon } from '..';
const MenuItem = ({
as,
children,
defaultSelected,
iconAfter,
iconBefore,
...props
Expand All @@ -30,6 +31,7 @@ const MenuItem = ({
};

MenuItem.propTypes = {
defaultSelected: PropTypes.bool,
className: PropTypes.string,
children: PropTypes.node,
as: PropTypes.elementType,
Expand All @@ -38,6 +40,7 @@ MenuItem.propTypes = {
};

MenuItem.defaultProps = {
defaultSelected: false,
as: 'button',
className: undefined,
children: null,
Expand Down
37 changes: 35 additions & 2 deletions src/Menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ components:
- Menu
categories:
- Navigation
- Buttonlike
status: 'New'
designStatus: 'Done'
devStatus: 'Done'
Expand All @@ -26,7 +25,7 @@ An arrow-key naivgable Menu which consists of MenuItems. A Menu can be employed
return (
<Menu>
<MenuItem> A Menu Item</MenuItem>
<MenuItem iconBefore={Add}>A Menu Item With an Icon Before</MenuItem>
<MenuItem iconBefore={Check}>A Menu Item With an Icon Before</MenuItem>
<MenuItem iconAfter={Check}>A Menu Item With an Icon After </MenuItem>
<MenuItem disabled>A Disabled Menu Item</MenuItem>
<MenuItem as={Hyperlink} href="https://en.wikipedia.org/wiki/Hyperlink">A Link Menu Item</MenuItem>
Expand Down Expand Up @@ -89,4 +88,38 @@ A Menu can be implemented to appear inside a `modalpopup` for a wide variety of
)
}
```
### Selectmenu
The selectMenu component is triggered on the click of a button or your choice of a standalone link using the `isLink` prop, and expands from the center if not close to the edge of the page. The menu contains a list of MenuItems, with a white background, and level 2 elevation. The menu also remembers the user鈥檚 selection and displays it as the label for the button/link trigger.
The Modal brings focus to the first menu element upon the click of the trigger, and can be escaped on click away or key press. Set a default message with the `defaultMessage` prop string. Use the `defaultSelected` prop to signify that a menuItem is the default to open to.
```jsx live
() => {

const defaultSelectedRef = React.useRef();


return (
<div className="mb-2">
<SelectMenu>
<MenuItem> A Menu Item</MenuItem>
<MenuItem iconBefore={Add}>A Menu Item With an Icon Before</MenuItem>
<MenuItem iconAfter={Check}>A Menu Item With an Icon After </MenuItem>
<MenuItem disabled>A Disabled Menu Item</MenuItem>
<MenuItem as={Hyperlink} href="https://en.wikipedia.org/wiki/Hyperlink">A Link Menu Item</MenuItem>
</SelectMenu>
<SelectMenu isLink={true} defaultMessage="Choose Your New Best Friend">
<MenuItem >Falstaff</MenuItem>
<MenuItem >Scipio</MenuItem>
<MenuItem defaultSelected>Faustus</MenuItem>
<MenuItem >Cordelia</MenuItem>
<MenuItem >Renfrancine</MenuItem>
<MenuItem >Stovern</MenuItem>
<MenuItem >Kainian</MenuItem>
<MenuItem >M. Hortens</MenuItem>
</SelectMenu>
</div>
);
}
```
168 changes: 168 additions & 0 deletions src/Menu/SelectMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Button from '../Button/index';
import ModalPopup from '../Modal/ModalPopup';
import useToggle from '../hooks/useToggle';
import Menu from '.';
import { ExpandMore } from '../../icons';

const SelectMenu = ({
defaultMessage,
isLink,
children,
...props
}) => {
const triggerTarget = React.useRef(null);
const triggerRef = React.useRef(null);
const className = classNames(props.className, 'pgn__menu-select');
const [selected, setSelected] = useState();
const [isOpen, open, close] = useToggle(false);
const [vertOffset, setOffset] = useState(0);
const link = isLink; // allow inline link styling

const prevOpenRef = React.useRef();
useEffect(() => {
// logic to always center the selected item.
if (isOpen && selected) {
const index = parseInt(selected.id.slice(0, selected.id.indexOf('_')), 10);
const numItems = children.length;

const boundingRect = document.getElementById(selected.id).parentElement.getBoundingClientRect();
if (boundingRect.bottom >= window.innerHeight - 150 || boundingRect.top <= 150) {
setOffset(0); // if too close to the edge, don't do centering fancyness
} else {
switch (true) {
case numItems < 6: {
// on small lists, center each element
setOffset(
parseInt(selected.id.slice(0, selected.id.indexOf('_') + 1), 10) * -48 + 20,
);
break;
}
case index < 2: {
// On first two elements, set offset based on position
setOffset((index) * -48);
break;
}
case numItems - index < 3: {
// on n-1 and n-2 elelements, set offset to put most modal elements on top.
setOffset((6 - (numItems - index)) * -48);
break;
}
case index > 1 && numItems - index > 2: {
// on "middle elements", set offset to center of block and scroll to center
document.getElementById(selected.id).scrollIntoView({
block: 'center',
});
setOffset(-125);
break;
}
default: break;
}
}
}
// set focus on open
if (isOpen && !prevOpenRef.current) {
if (selected) { document.getElementById(selected.id).focus(); } else {
React.Children.forEach(children, (child) => {
if (child.props.defaultSelected) {
const buttonTags = document.getElementsByTagName('button');
for (let i = 0; i < buttonTags.length; i++) {
if (buttonTags[i].textContent === child.props.children) {
setSelected(buttonTags[i]);
buttonTags[i].focus({
preventScroll: true,
});
break;
}
}
}
});
}
}
prevOpenRef.current = isOpen;
});

return React.createElement(
className,
{
...props,
className,
},
<>
<span ref={triggerTarget} />
<Button
aria-haspopup="true"
aria-expanded={isOpen}
ref={triggerRef}
className="pgn__menu-select-trigger-btn"
variant={link ? 'link' : 'tertiary'}
iconAfter={link ? undefined : ExpandMore}
onClick={open}
>{ selected ? selected.innerText : defaultMessage}
</Button>
<div className="pgn__menu-select-popup">
<ModalPopup
placement="right-start"
positionRef={triggerTarget}
isOpen={isOpen}
onClose={close}
modifiers={
[
{
name: 'flip',
enabled: true,
},
{
name: 'offset',
options: {
enabled: true,
offset: [vertOffset, 0],
},
},
]
}
>
<Menu aria-label="Select Menu">
{
React.Children.map(children, (child) => {
const newProps = {
onClick(e) {
if (child.props.onClick) {
child.props.onClick(e);
}
setSelected(e.target);
close();
triggerRef.current.focus();
},
id: `${children.indexOf(child).toString()}_pgn__menu-item`,
role: 'link',
};
if (selected && selected.id === `${children.indexOf(child).toString()}_pgn__menu-item`) {
newProps['aria-current'] = 'page';
}
return React.cloneElement(child, newProps);
})
}
</Menu>
</ModalPopup>
</div>
</>,
);
};

SelectMenu.propTypes = {
defaultMessage: PropTypes.string,
isLink: PropTypes.bool,
children: PropTypes.node.isRequired,
className: PropTypes.string,
};

SelectMenu.defaultProps = {
defaultMessage: 'Select...',
isLink: false,
className: undefined,
};

export default SelectMenu;
105 changes: 105 additions & 0 deletions src/Menu/SelectMenu.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import { mount } from 'enzyme';
import renderer from 'react-test-renderer';
import { MenuItem, SelectMenu } from '..';
import { Add, Check } from '../../icons';
import Hyperlink from '../Hyperlink';
import Button from '../Button';

const selectMenu = mount((
<SelectMenu>
<MenuItem> A Menu Item</MenuItem>
<MenuItem iconBefore={Add}>A Menu Item With an Icon Before</MenuItem>
<MenuItem iconAfter={Check}>A Menu Item With an Icon After </MenuItem>
<MenuItem disabled>A Disabled Menu Item</MenuItem>
<MenuItem as={Hyperlink} href="https://en.wikipedia.org/wiki/Hyperlink">A Link Menu Item</MenuItem>
<MenuItem>Falstaff</MenuItem>
<MenuItem>Scipio</MenuItem>
<MenuItem>Faustus</MenuItem>
<MenuItem>Cordelia</MenuItem>
<MenuItem>Renfrancine</MenuItem>
<MenuItem>Stovern</MenuItem>
<MenuItem>Kainian</MenuItem>
<MenuItem>M. Hortens</MenuItem>
</SelectMenu>
));

const menuTrigger = selectMenu.find(Button);

const menuOpen = (isOpen, wrapper) => {
expect(wrapper.find(Button).prop('aria-expanded')).toEqual(isOpen);
};

describe('Rendering Beahvior', () => {
it('Renders as expected', () => {
const tree = renderer
.create(<SelectMenu> <MenuItem> A Menu Item</MenuItem></SelectMenu>)
.toJSON();
expect(tree).toMatchSnapshot();
});
it('Renders with a default message you set', () => {
const wrapper = mount((<SelectMenu defaultMessage="Pick Me"> <MenuItem> A Menu Item</MenuItem></SelectMenu>));
expect(wrapper.find(Button).text() === 'Pick Me').toBe(true);
});
it('Renders with a button as link', () => {
const wrapper = mount((<SelectMenu isLink> <MenuItem> A Menu Item</MenuItem></SelectMenu>));
expect(wrapper.find(Button).prop('variant')).toEqual('link');
});
});

describe('Mouse Behavior & keyboard behavior', () => {
menuTrigger.simulate('click');
const menuItems = selectMenu.find('.pgn__menu-item');

it('opens on trigger click', () => {
menuTrigger.simulate('click'); // Open
menuOpen(true, selectMenu);
});

it('should focus on the first item after opening', () => {
expect(menuItems.first().is(':focus')).toBe(true);
});
it(' returns focus to trigger on close', () => {
menuItems.at(7).simulate('click');
expect(menuTrigger.is(':focus')).toBe(true);
});
});

describe('Keyboard Interactions', () => {
menuTrigger.simulate('click'); // Open
const menuItems = selectMenu.find('.pgn__menu-item');
const menuContainer = selectMenu.find('.pgn__menu');

it('should focus on the first item after opening', () => {
expect(menuItems.at(0) === document.activeElement);
});

it('should focus the next item after ArrowDown keyDown', () => {
menuContainer.simulate('keyDown', { key: 'ArrowDown' });
expect(menuItems.at(1) === document.activeElement);
});
it('should focus the next item after ArrowDown right', () => {
menuContainer.simulate('keyDown', { key: 'ArrowRight' });
expect(menuItems.at(2) === document.activeElement);
});
it('should focus the previous item after ArrowDown up', () => {
menuContainer.simulate('keyDown', { key: 'ArrowUp' });
expect(menuItems.at(1) === document.activeElement);
});
it('should focus the previous item after ArrowDown left', () => {
menuContainer.simulate('keyDown', { key: 'ArrowLeft' });
expect(menuItems.at(0) === document.activeElement);
});
it('edge behavior should loop', () => {
menuContainer.simulate('keyDown', { key: 'ArrowUp' });
expect(menuItems.at(menuItems.length - 1) === document.activeElement);
menuContainer.simulate('keyDown', { key: 'ArrowDown' });
expect(menuItems.at(0) === document.activeElement);
});
it('Home should go to first, End to last', () => {
menuContainer.simulate('keyDown', { key: 'End' });
expect(menuItems.at(menuItems.length - 1) === document.activeElement);
menuContainer.simulate('keyDown', { key: 'Home' });
expect(menuItems.at(0) === document.activeElement);
});
});

0 comments on commit 7c13754

Please sign in to comment.