lucid-ui
Version:
A UI component library from Xandr.
585 lines • 25 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { lucidClassNames, uniqueName } from '../../util/style-helpers';
import { getFirst, rejectTypes, findTypes, } from '../../util/component-types';
import { scrollParentTo } from '../../util/dom-helpers';
import { buildModernHybridComponent } from '../../util/state-management';
import * as KEYCODE from '../../constants/key-code';
import * as reducers from './DropMenu.reducers';
import ContextMenu from '../ContextMenu/ContextMenu';
function joinArray(array, getSeparator) {
return _.reduce(array, (newArray, element, index) => {
newArray.push(element);
if (index < _.size(array) - 1) {
newArray.push(getSeparator(element, index, array));
}
return newArray;
}, []);
}
function isOptionVisible(option) {
return !option.optionProps.isHidden;
}
const cx = lucidClassNames.bind('&-DropMenu');
const { any, arrayOf, bool, func, node, number, object, oneOf, string } = PropTypes;
const Header = (_props) => null;
Header.displayName = 'DropMenu.Header';
Header.peek = {
description: `
An optional header to be displayed within the expanded Flyout,
above all \`Option\`s.
`,
};
Header.propName = 'Header';
Header.propTypes = {};
const Control = (_props) => null;
Control.displayName = 'DropMenu.Control';
Control.peek = {
description: `Renders a \`<div>\` that acts as the control target which the flyout menu is anchored to. Only one \`Control\` is used.`,
};
Control.propName = 'Control';
Control.propTypes = {};
const OptionGroup = (_props) => null;
OptionGroup.displayName = 'DropMenu.OptionGroup';
OptionGroup.peek = {
description: `A special kind of \`Option\` that is always rendered at the top of the menu and has an \`optionIndex\` of \`null\`. Useful for unselect.`,
};
OptionGroup.propName = 'OptionGroup';
OptionGroup.propTypes = {
/**
hides the \`OptionGroup\` from the list.
*/
isHidden: bool,
};
OptionGroup.defaultProps = {
isHidden: false,
};
const Option = (_props) => null;
Option.displayName = 'DropMenu.Option';
Option.peek = {
description: `
Renders a \`<div>\` that acts as an option in the menu.
`,
};
Option.propName = 'Option';
Option.propTypes = {
/**
disables selection of the \`Option\`.
*/
isDisabled: bool,
/**
hides the \`Option\` from the list.
*/
isHidden: bool,
/**
controls wrapping of the text.
*/
isWrapped: bool,
};
Option.defaultProps = {
isDisabled: false,
isHidden: false,
isWrapped: true,
};
const NullOption = (_props) => null;
NullOption.displayName = 'DropMenu.NullOption';
NullOption.peek = {
description: `A special kind of \`Option\` that is always rendered at the top of the menu and has an \`optionIndex\` of \`null\` used for deselecting.`,
};
NullOption.propName = 'NullOption';
NullOption.propTypes = {};
const FixedOption = (_props) => null;
FixedOption.displayName = 'DropMenu.FixedOption';
FixedOption.peek = {
description: `
A special kind of \`Option\` that is always rendered at the top of
the menu.
`,
};
FixedOption.propName = 'FixedOption';
FixedOption.propTypes = {
/**
disables selection of the \`Option\`.
*/
isDisabled: bool,
/**
hides the \`Option\` from the list.
*/
isHidden: bool,
/**
controls wrapping of the text.
*/
isWrapped: bool,
};
FixedOption.defaultProps = {
isDisabled: false,
isHidden: false,
isWrapped: true,
};
const DropMenuContextMenu = (_props) => null;
DropMenuContextMenu.displayName = 'DropMenu.ContextMenu';
DropMenuContextMenu.peek = {
description: `Props that are passed through to the underlying ContextMenu.`,
};
DropMenuContextMenu.propName = 'ContextMenu';
DropMenuContextMenu.propTypes = {
children: node /**/,
};
/** TODO: Remove the nonPassThroughs when the component is converted to a functional component */
const nonPassThroughs = [
'children',
'className',
'style',
'isDisabled',
'isExpanded',
'direction',
'alignment',
'selectedIndices',
'focusedIndex',
'portalId',
'flyOutStyle',
'optionContainerStyle',
'onExpand',
'onCollapse',
'onSelect',
'onFocusNext',
'onFocusPrev',
'onFocusOption',
'Control',
'Option',
'OptionGroup',
'NullOption',
'Header',
'ContextMenu',
'FixedOption',
'initialState',
'callbackId',
];
class DropMenu extends React.Component {
constructor(props) {
super(props);
this.getPreprocessedOptionData = (props) => {
return DropMenu.preprocessOptionData(props, DropMenu);
};
this.handleKeydown = (event) => {
const { props, props: { isExpanded, focusedIndex, onExpand, onCollapse, onSelect, onFocusOption, }, } = this;
const { flattenedOptionsData, nullOptions } = this.state;
this.setState({
isMouseTriggered: false,
});
if (isExpanded) {
if (event.keyCode === KEYCODE.Enter) {
event.preventDefault();
const focusedOptionData = _.get(flattenedOptionsData, _.isNil(focusedIndex) ? '' : focusedIndex, null);
const focusedOptionProps = _.get(focusedOptionData, 'optionProps', Option.defaultProps);
if (focusedOptionData && !focusedOptionProps.isDisabled) {
onSelect &&
onSelect(focusedIndex, { props: focusedOptionProps, event });
}
else if (_.isNull(focusedIndex)) {
onSelect && onSelect(null, { props: _.first(nullOptions), event });
}
}
if (event.keyCode === KEYCODE.Escape) {
event.preventDefault();
onCollapse && onCollapse({ props, event });
}
if (event.keyCode === KEYCODE.ArrowUp) {
if (_.isNumber(focusedIndex) || _.isNull(focusedIndex)) {
if (focusedIndex === 0) {
if (!_.isEmpty(nullOptions)) {
event.preventDefault();
onFocusOption && onFocusOption(null, { props, event });
}
}
if (!_.isNil(focusedIndex) && focusedIndex > 0) {
event.preventDefault();
onFocusOption &&
onFocusOption(_.findLastIndex(flattenedOptionsData, isOptionVisible, focusedIndex - 1), { props, event });
}
}
else {
event.preventDefault();
focusedIndex &&
onFocusOption &&
onFocusOption(_.findLastIndex(flattenedOptionsData, isOptionVisible, focusedIndex - 1), { props, event });
}
}
if (event.keyCode === KEYCODE.ArrowDown) {
if (_.isNumber(focusedIndex)) {
if (focusedIndex < _.size(flattenedOptionsData) - 1) {
event.preventDefault();
onFocusOption &&
onFocusOption(_.findIndex(flattenedOptionsData, isOptionVisible, focusedIndex + 1), { props, event });
}
}
else {
event.preventDefault();
onFocusOption &&
onFocusOption(_.findIndex(flattenedOptionsData, isOptionVisible, !_.isNil(focusedIndex) ? focusedIndex : undefined), { props, event });
}
}
}
else {
if (event.keyCode === KEYCODE.ArrowDown) {
event.preventDefault();
onExpand && onExpand({ props, event });
}
}
};
this.handleClick = (event) => {
const { props, props: { isExpanded, onExpand, onCollapse }, } = this;
if (isExpanded) {
onCollapse && onCollapse({ props, event });
}
else {
onExpand && onExpand({ props, event });
}
};
this.handleMouseFocusOption = (optionIndex, optionProps, event) => {
const { focusedIndex, onFocusOption } = this.props;
this.setState({
isMouseTriggered: true,
});
if (!optionProps.isDisabled && focusedIndex !== optionIndex) {
onFocusOption && onFocusOption(optionIndex, { props: this.props, event });
}
};
this.handleSelectOption = (optionIndex, optionProps, event) => {
const { onSelect } = this.props;
if (!optionProps.isDisabled) {
onSelect && onSelect(optionIndex, { props: optionProps, event });
}
};
this.renderOption = (optionProps, optionIndex, isGrouped = false) => {
const { selectedIndices, focusedIndex } = this.props;
const { isMouseTriggered } = this.state;
const { isDisabled, isHidden, isWrapped } = optionProps;
const isFocused = optionIndex === focusedIndex;
const isSelected = _.includes(selectedIndices, optionIndex);
return isHidden ? null : (React.createElement("div", { key: 'DropMenuOption' + optionIndex, ..._.omit(optionProps, [
'isDisabled',
'isHidden',
'isWrapped',
'Selected',
'Selection',
'initialState',
'callbackId',
]), onClick: (event) => this.handleSelectOption(optionIndex, optionProps, event), onMouseMove: (event) => this.handleMouseFocusOption(optionIndex, optionProps, event), className: cx('&-Option', {
'&-Option-is-grouped': isGrouped,
'&-Option-is-focused': isFocused,
'&-Option-is-selected': isSelected,
'&-Option-is-disabled': isDisabled,
'&-Option-is-null': _.isNull(optionIndex),
'&-Option-is-wrapped': isWrapped,
}, optionProps.className), ref: (optionHTMLElement) => {
if (isFocused && !isMouseTriggered) {
scrollParentTo(optionHTMLElement, this._header && this._header.current
? this._header.current.offsetHeight
: undefined);
}
} }));
};
this.UNSAFE_componentWillReceiveProps = (nextProps) => {
// only preprocess options data when it changes (via new props) - better performance than doing this each render
this.setState(this.getPreprocessedOptionData(nextProps));
};
this.state = {
optionGroups: [],
flattenedOptionsData: [],
ungroupedOptionData: [],
optionGroupDataLookup: {},
nullOptions: [],
fixedOptionData: [],
isMouseTriggered: false,
portalId: this.props.portalId || uniqueName('DropMenu-Portal-'),
isExpanded: false,
focusedIndex: null,
optionGroupIndex: null,
optionProps: [],
selectedIndices: [],
...this.getPreprocessedOptionData(props), // TODO: typescript hack that should be removed
};
this._header = React.createRef();
}
render() {
const { className, style, isDisabled, isExpanded, direction, alignment, onCollapse, flyOutStyle, optionContainerStyle, ...passThroughs } = this.props;
const { optionGroups, fixedOptionData, ungroupedOptionData, optionGroupDataLookup, nullOptions, portalId, } = this.state;
const contextMenuProps = _.get(getFirst(this.props, DropMenu.ContextMenu), 'props', {});
const controlProps = _.get(getFirst(this.props, DropMenu.Control), 'props', {});
const headerProps = _.get(getFirst(this.props, DropMenu.Header), 'props', {});
return (React.createElement("div", { className: cx('&', '&-base', {
'&-is-expanded': isExpanded,
'&-direction-down': isExpanded && direction === 'down',
'&-direction-up': isExpanded && direction === 'up',
}, className), style: style, ..._.omit(passThroughs, nonPassThroughs) },
React.createElement(ContextMenu, { ...contextMenuProps, portalId: portalId, isExpanded: isExpanded, direction: direction, alignment: alignment, onClickOut: onCollapse },
React.createElement(ContextMenu.Target, null,
React.createElement("div", { ...(!isDisabled
? {
tabIndex: 0,
onClick: this.handleClick,
onKeyDown: this.handleKeydown,
}
: null), ...controlProps, className: cx('&-Control', _.get(controlProps, 'className')) })),
React.createElement(ContextMenu.FlyOut, { className: cx('&', className), style: flyOutStyle },
!_.isEmpty(headerProps) && (React.createElement("div", { ...headerProps, className: cx('&-Header', headerProps.className), onKeyDown: this.handleKeydown, ref: this._header })),
React.createElement("div", { className: cx('&-option-container'), style: _.assign({}, flyOutStyle, optionContainerStyle) },
_.map(nullOptions, (optionProps) => this.renderOption(optionProps, null)).concat(_.isEmpty(nullOptions)
? []
: [
React.createElement("div", { key: 'OptionGroup-divider-NullOption', className: cx('&-OptionGroup-divider') }),
]),
// fixed options go first
_.map(fixedOptionData, ({ optionProps, optionIndex }) => this.renderOption(optionProps, optionIndex)),
joinArray(
// for each option group,
_.map(optionGroups, (optionGroupProps, optionGroupIndex) => {
const groupedOptions = optionGroupDataLookup[optionGroupIndex];
if (optionGroupProps.isHidden ||
_.every(groupedOptions, {
optionProps: { isHidden: true },
})) {
return null;
}
const labelElements = rejectTypes(optionGroupProps.children, [
DropMenu.Control,
DropMenu.OptionGroup,
DropMenu.Option,
DropMenu.NullOption,
]);
// render label if there is one
return (_.isEmpty(labelElements)
? []
: [
React.createElement("div", { ..._.omit(optionGroupProps, [
'isHidden',
'initialState',
'callbackId',
]), key: 'OptionGroup-label' + optionGroupIndex, className: cx('&-label', optionGroupProps.className) }, labelElements),
// render the options in the group
]).concat(_.map(optionGroupDataLookup[optionGroupIndex], ({ optionProps, optionIndex }) => this.renderOption(optionProps, optionIndex, true)));
// append all ungrouped options as another unlabeled group
}).concat(_.isEmpty(ungroupedOptionData)
? []
: [
_.map(ungroupedOptionData, ({ optionProps, optionIndex }) => this.renderOption(optionProps, optionIndex)),
]), (element, index) => element && (React.createElement("div", { key: `OptionGroup-divider-${index}`, className: cx('&-OptionGroup-divider') })) // separate each group with divider
))))));
}
}
DropMenu.displayName = 'DropMenu';
DropMenu.ContextMenu = DropMenuContextMenu;
DropMenu.FixedOption = FixedOption;
DropMenu.NullOption = NullOption;
DropMenu.Option = Option;
DropMenu.OptionGroup = OptionGroup;
DropMenu.Control = Control;
DropMenu.Header = Header;
DropMenu.peek = {
ContextMenu: DropMenuContextMenu,
description: `\`DropMenu\` is a helper component used to render a menu of options attached to any control. Supports option groups with and without labels as well as special options with a \`null\` index for unselect.`,
categories: ['helpers'],
madeFrom: ['ContextMenu'],
};
DropMenu.reducers = reducers;
DropMenu.propTypes = {
/**
Should be instances of: \`DropMenu.Control\`, \`DropMenu.Option\`,
\`DropMenu.OptionGroup\`, \`DropMenu.Nulloption\`. Other direct child
elements will not render.
*/
children: node,
className: string /**
Appended to the component-specific class names set on the root elements.
Applies to *both* the control and the flyout menu.
*/,
/**
Styles that are passed through to root element.
*/
style: object,
/**
Disables the DropMenu from being clicked or focused.
*/
isDisabled: bool,
/**
Renders the flyout menu adjacent to the control.
*/
isExpanded: bool,
/**
Sets the direction the flyout menu will render relative to the control.
*/
direction: oneOf(['down', 'up']),
/**
Sets the alignment the flyout menu will render relative to the control.
*/
alignment: oneOf(['start', 'center', 'end']),
/**
An array of currently selected \`DropMenu.Option\` indices.
*/
selectedIndices: arrayOf(number),
/**
The currently focused index of \`DropMenu.Option\`. Can also be \`null\`.
*/
focusedIndex: number,
/**
The \`id\` of the flyout menu portal element that is appended to
\`document.body\`. Defaults to a generated id.
*/
portalId: string,
/**
Styles that are passed through to the ContextMenu FlyOut element.
*/
flyOutStyle: object,
/**
Styles that are passed through to the option container element.
*/
optionContainerStyle: object,
/**
Called when collapsed and the control is clicked, or when the control has
focus and the Down Arrow is pressed. Has the signature
\`({ props, event }) => {}\`
*/
onExpand: func,
/**
Called when expanded and the user clicks the control or outside of the
menu, or when the control has focus and the Escape key is pressed Has the
signature \`({ props, event }) => {}\`
*/
onCollapse: func,
/**
Called when an option is clicked, or when an option has focus and the
Enter key is pressed. Has the signature
\`(optionIndex, {props, event}) => {}\`
where optionIndex can be a number or \`null\`.
*/
onSelect: func,
/**
Called when expanded and the the Down Arrow key is pressed. Not called
when focus is on the last option. Has the signature
\`({ props, event }) => {}\`
*/
onFocusNext: func,
/**
Called when expanded and the the Up Arrow key is pressed. Not called when
focus is on the first option. Has the signature
\`({ props, event }) => {}\`
*/
onFocusPrev: func,
/**
Called when the mouse moves over an option. Has the signature
\`(optionIndex) => {}\` where optionIndex can be a number or \`null\`.
*/
onFocusOption: func,
Control: any /**
*Child Element* - The control target which the flyout menu is anchored
to. Only one \`Control\` is used.
*/,
Option: any /**
*Child Element* - These are menu options. The \`optionIndex\` is in-order
of rendering regardless of group nesting, starting with index \`0\`.
Each \`Option\` may be passed a prop called \`isDisabled\` to disable
selection of that \`Option\`. Any other props pass to Option will be
available from the \`onSelect\` handler.
*/,
OptionGroup: any /**
*Child Element* - Used to group \`Option\`s within the menu. Any
non-\`Option\`s passed in will be rendered as a label for the group.
*/,
NullOption: any /**
*Child Element* - A special kind of \`Option\` that is always rendered at
the top of the menu and has an \`optionIndex\` of \`null\`. Useful for
unselect.
*/,
Header: any /**
*Child Element* - An optional header to be displayed within the expanded
Flyout, above all \`Option\`s.
*/,
ContextMenu: any /**
*Child Element* - props pass through to the underlying ContextMenu
component
*/,
FixedOption: any /**
*Child Element* - A special kind of \`Option\` that is always rendered at the top of
the menu.
*/,
};
DropMenu.defaultProps = {
isDisabled: false,
isExpanded: false,
direction: 'down',
alignment: 'start',
selectedIndices: [],
focusedIndex: null,
flyOutStyle: { maxHeight: '18em' },
onExpand: _.noop,
onCollapse: _.noop,
onSelect: _.noop,
onFocusNext: _.noop,
onFocusPrev: _.noop,
onFocusOption: _.noop,
portalId: '',
optionContainerStyle: {},
ContextMenu: ContextMenu.defaultProps,
};
DropMenu.preprocessOptionData = (props, ParentType) => {
const { OptionGroup, Option, NullOption, FixedOption } = ParentType;
const optionGroups = _.map(findTypes(props, OptionGroup), 'props'); // find all OptionGroup props
const fixedOptions = _.map(findTypes(props, FixedOption), 'props'); // find all FixedOption props
const ungroupedOptions = _.map(findTypes(props, Option), 'props'); // find all ungrouped Option props
const nullOptions = NullOption
? _.map(findTypes(props, NullOption), 'props')
: []; // find all NullOption props
const fixedOptionData = _.map(fixedOptions, (optionProps, localOptionIndex) => {
return {
localOptionIndex,
optionIndex: localOptionIndex,
optionGroupIndex: null,
optionProps,
};
});
// flatten grouped options into array of objects to associate { index, group index, and props } for each option
const groupedOptionData = _.reduce(optionGroups, (memo, optionGroupProps, optionGroupIndex) => {
const groupedOptions = _.map(findTypes(optionGroupProps, Option), 'props'); // find all Option props for current group
return memo.concat(_.map(groupedOptions, (optionProps, localOptionIndex) => {
return {
localOptionIndex,
optionIndex: _.size(memo) + _.size(fixedOptionData) + localOptionIndex,
optionGroupIndex,
optionProps: {
isHidden: false,
...optionProps,
},
};
}));
}, []);
// create lookup object for options by their group index
const optionGroupDataLookup = _.groupBy(groupedOptionData, 'optionGroupIndex');
// store ungrouped options into array of objects to associate { index, and props } for each option
const ungroupedOptionData = _.map(ungroupedOptions, (optionProps, localOptionIndex) => {
return {
localOptionIndex,
optionIndex: _.size(groupedOptionData) +
_.size(fixedOptionData) +
localOptionIndex,
optionGroupIndex: null,
optionProps: {
isHidden: false,
...optionProps,
},
};
});
// concatenate grouped options array with ungrouped options array to get flat list of all options
const flattenedOptionsData = _.concat(fixedOptionData, groupedOptionData, ungroupedOptionData);
return {
optionGroups,
optionGroupDataLookup,
fixedOptionData,
ungroupedOptionData,
flattenedOptionsData,
nullOptions,
};
};
export default buildModernHybridComponent(DropMenu, { reducers });
export { DropMenu as DropMenuDumb, NullOption, OptionGroup };
//# sourceMappingURL=DropMenu.js.map