-
Notifications
You must be signed in to change notification settings - Fork 67
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
1 parent
262b7c2
commit 7c13754
Showing
8 changed files
with
456 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
Oops, something went wrong.