react-16-dropdown
Version:
A zero-dependency, lightweight and fully customizable dropdown (not select) for React.
251 lines (211 loc) • 6.61 kB
JavaScript
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Option from './Option';
/**
* Default menu renderer
*
* @param {Object} props - React props
* @param {ReactElement} props.children - Options to render
* @returns {ReactElement} menu
*/
function MenuRenderer(props) {
return props.children;
}
/**
* Default component for menu section
*
* @param {Object} props - React props
* @param {String} props.title - Section title
* @param {ReactElement} props.children - Options in the section
* @returns {ReactElement} menu section
*/
function MenuSectionRenderer(props) {
const className = 'menu-section' +
(props.className ? ` ${props.className}` : '');
return (
<div className={className}>
<div className='menu-section__title'>{props.title}</div>
<div className='menu-section__body'>
{props.children}
</div>
</div>
);
}
/**
* Default menu component
*
* @param {Object} props - React props
* @param {ReactElement} props.renderer - Menu renderer
* @param {ReactRef} props.menuRef - Ref for the menu component
* @param {Object} props.style - Inline styles for menu
* @param {Function} props.onKeyDown - Handler for keyboard events
* @param {ReactElement} props.children - Option elements
*/
function Menu(props) {
const Renderer = props.renderer;
return (
<div
className='menu'
role='listbox'
ref={props.menuRef}
tabIndex={-1}
style={props.style}
onKeyDown={props.onKeyDown}
>
<Renderer>{props.children}</Renderer>
</div>
);
}
/**
* Portal for the menu
*
* @help https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Using_tabindex
*/
export default class MenuPortal extends Component {
constructor(props) {
super(props);
this.state = { focused: -1 };
this.optionRefs = {};
this.el = document.createElement('div');
this.el.classList.add('react-16-dropdown-portal');
props.portalClassName && this.el.classList.add(props.portalClassName);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.getAlignment = this.getAlignment.bind(this);
this.setOptionRefs = this.setOptionRefs.bind(this);
this.getOptions = this.getOptions.bind(this);
this.getOptionElements = this.getOptionElements.bind(this);
}
componentDidMount() {
document.querySelector(this.props.menuPortalTarget).appendChild(this.el);
this.props.controlled && this.props.focused && this.optionRefs[this.props.focused].focus();
if (!this.props.controlled || this.props.autoFocusMenu) {
this.props.menuRef.current.focus();
}
}
componentDidUpdate() {
const options = this.getOptions();
let key;
if (!this.props.controlled) {
const selected = options[this.state.focused];
key = selected && selected.value;
} else {
key = this.props.focused;
}
key && this.optionRefs[key].focus();
}
componentWillUnmount() {
document.querySelector(this.props.menuPortalTarget).removeChild(this.el);
}
getAlignment() {
// @todo allow other alignments
const boundingRect = this.props.triggerBoundingRect;
const top = boundingRect.top + boundingRect.height;
if (this.props.align === 'left') {
return {
top,
left: boundingRect.left,
};
}
if (this.props.align === 'right') {
return {
top,
right: window.innerWidth - boundingRect.right - window.scrollX,
};
}
return {};
}
getOptions() {
const { options, sections } = this.props;
if (sections.length) {
return sections.reduce((res, sec) => res.concat(sec.options), []);
}
return options;
}
getOptionElements() {
const { sections } = this.props;
const options = this.getOptions();
const OptionElement = this.props.optionComponent;
const SectionRenderer = this.props.menuSectionRenderer;
const focused = this.props.controlled ?
options.map(o => o.value).indexOf(this.props.focused) :
this.state.focused;
if (sections.length) {
return sections.map((sec, i) => (
<SectionRenderer
{...sec}
key={sec.id}
>
{sec.options.map((option, j) => (
<OptionElement
className={option.className}
data={option}
focused={focused === (i * (i + 1)) + j}
key={option.value}
optionRef={node => this.setOptionRefs(node, option.value)}
renderer={this.props.optionRenderer}
onClick={() => { this.props.onClick(option); }}
/>
))}
</SectionRenderer>
));
}
return options.map((option, i) => (
<OptionElement
className={option.className}
data={option}
focused={focused === i}
key={option.value}
optionRef={node => this.setOptionRefs(node, option.value)}
renderer={this.props.optionRenderer}
onClick={() => { this.props.onClick(option); }}
/>
));
}
setOptionRefs(node, key) {
node && (this.optionRefs[key] = node);
}
handleKeyDown(e) {
typeof this.props.onMenuKeyDown === 'function' && this.props.onMenuKeyDown(e);
if (this.props.controlled) {
return;
}
const options = this.getOptions();
const maxFocus = options.length - 1;
const focusedOption = options[this.state.focused];
// NOTE: This method is called when the menu is
// opened with the keyboard. This case handles it
if (e.key === 'Enter' && focusedOption && !focusedOption.disabled) {
this.props.onClick(focusedOption.value);
} else if (e.key === 'ArrowDown') {
this.setState(prevState => ({
focused: prevState.focused < maxFocus ? prevState.focused + 1 : maxFocus,
}));
} else if (e.key === 'ArrowUp') {
this.setState(prevState => ({
focused: prevState.focused > 0 ? prevState.focused - 1 : 0,
}));
}
e.preventDefault();
}
render() {
const MenuElement = this.props.menuComponent;
const menu = (
<MenuElement
menuRef={this.props.menuRef}
renderer={this.props.menuRenderer}
style={this.getAlignment()}
onKeyDown={this.handleKeyDown}
>
{this.getOptionElements()}
</MenuElement>
);
return ReactDOM.createPortal(menu, this.el);
}
}
MenuPortal.defaultProps = {
menuComponent: Menu,
optionComponent: Option,
menuRenderer: MenuRenderer,
menuSectionRenderer: MenuSectionRenderer,
menuPortalTarget: 'body',
};