UNPKG

wix-style-react

Version:
843 lines (733 loc) • 24.4 kB
import PropTypes from 'prop-types'; import React from 'react'; import Loader from '../Loader/Loader'; import InfiniteScroll from '../utils/InfiniteScroll'; import scrollIntoView from '../utils/scrollIntoView'; import { DATA_HOOKS, DATA_OPTION, DATA_SHOWN, DATA_DIRECTION, DROPDOWN_LAYOUT_DIRECTIONS, OPTION_DATA_HOOKS, DROPDOWN_LAYOUT_LOADER, DATA_SELECTED_OPTION_ID, } from './DataAttr'; import { st, classes } from './DropdownLayout.st.css'; import deprecationLog from '../utils/deprecationLog'; import { filterObject } from '../utils/filterObject'; import ReactDOM from 'react-dom'; import { listItemSectionBuilder } from '../ListItemSection'; import { listItemSelectBuilder } from '../ListItemSelect'; import { isString } from '../utils/StringUtils'; const MOUSE_EVENTS_SUPPORTED = ['mouseup', 'touchend']; const modulu = (n, m) => { const remain = n % m; return remain >= 0 ? remain : remain + m; }; const getUnit = value => (isString(value) ? value : `${value}px`); const NOT_HOVERED_INDEX = -1; export const DIVIDER_OPTION_VALUE = '-'; const deprecatedPropsLogs = props => { const deprecatedProps = [ { propName: 'onClickOutside', deprecationMsg: '<DropdownLayout/> - onClickOutside prop is deprecated and will be removed soon, please use dropdown base instead.', }, { propName: 'itemHeight', deprecationMsg: '<DropdownLayout/> - itemHeight prop is deprecated and will be removed in the next major release. In order to set a different height than 36px, please use a builder.', }, { propName: 'withArrow', deprecationMsg: '<DropdownLayout/>- withArrow prop is deprecated and will be removed in the next major release, please use DropdownBase (with the prop "showArrow") or Popover component instead.', }, { propName: 'dropDirectionUp', deprecationMsg: '<DropdownLayout/>- dropDirectionUp prop is deprecated and will be removed in the next major release, please use DropdownBase (with the prop "showArrow") or Popover component instead.', }, ]; deprecatedProps.forEach(({ propName, deprecationMsg }) => { if (props.hasOwnProperty(propName)) { deprecationLog(deprecationMsg); } }); }; class DropdownLayout extends React.PureComponent { constructor(props) { super(props); this.containerRef = React.createRef(); this.state = { hovered: NOT_HOVERED_INDEX, selectedId: props.selectedId, }; deprecatedPropsLogs(props); } componentDidMount() { const { focusOnSelectedOption } = this.props; if (focusOnSelectedOption) { this._focusOnSelectedOption(); } else if (this.props.hasOwnProperty('focusOnOption')) { this._focusOnOption(); } this._markOptionByProperty(this.props); // Deprecated MOUSE_EVENTS_SUPPORTED.forEach(eventName => { document.addEventListener(eventName, this._onMouseEventsHandler, true); }); this._boundEvents = MOUSE_EVENTS_SUPPORTED; } componentWillUnmount() { if (this._boundEvents && typeof document !== 'undefined') { this._boundEvents.forEach(eventName => { document.removeEventListener( eventName, this._onMouseEventsHandler, true, ); }); } } componentDidUpdate(prevProps) { const { focusOnOption } = this.props; if (prevProps.focusOnOption !== focusOnOption) { this._focusOnOption(); } } UNSAFE_componentWillReceiveProps(nextProps) { if (this.props.visible !== nextProps.visible) { this._markOption(NOT_HOVERED_INDEX); } if (this.props.selectedId !== nextProps.selectedId) { this.setState({ selectedId: nextProps.selectedId }); } // make sure the same item is hovered if options changed if ( this.state.hovered !== NOT_HOVERED_INDEX && (!nextProps.options[this.state.hovered] || this.props.options[this.state.hovered].id !== nextProps.options[this.state.hovered].id) ) { this._markOption( this._findIndex( nextProps.options, item => item.id === this.props.options[this.state.hovered].id, ), ); } this._markOptionByProperty(nextProps); } // Deprecated _checkIfEventOnElements(e, elem) { let current = e.target; while (current.parentNode) { if (elem.indexOf(current) > -1) { return true; } current = current.parentNode; } return current !== document; } // Deprecated _onMouseEventsHandler = e => { if (!this._checkIfEventOnElements(e, [ReactDOM.findDOMNode(this)])) { this._onClickOutside(e); } }; // Deprecated _renderTopArrow() { const { withArrow, visible } = this.props; return withArrow && visible ? ( <div data-hook={DATA_HOOKS.TOP_ARROW} className={classes.arrow} /> ) : null; } _convertOptionToListItemSectionBuilder({ option, idx }) { const { value, id, title: isTitle } = option; if (value === DIVIDER_OPTION_VALUE) { deprecationLog( 'to render a divider, please use `listItemSectionBuilder`', ); return listItemSectionBuilder({ dataHook: OPTION_DATA_HOOKS.DIVIDER, id: id || idx, type: 'divider', }); } if (isTitle) { deprecationLog('to render a title, please use `listItemSectionBuilder`'); return listItemSectionBuilder({ dataHook: OPTION_DATA_HOOKS.TITLE, id, type: 'subheader', title: value, }); } } _isControlled() { return ( typeof this.props.selectedId !== 'undefined' && typeof this.props.onSelect !== 'undefined' ); } _focusOnSelectedOption() { if (this.selectedOption) { this.options.scrollTop = Math.max( this.selectedOption.offsetTop - this.selectedOption.offsetHeight, 0, ); } } _setSelectedOptionNode(optionNode, option) { if (option.id === this.state.selectedId) { this.selectedOption = optionNode; } } _onClickOutside = event => { const { visible, onClickOutside } = this.props; if (visible && onClickOutside) { onClickOutside(event); } }; _markOption(index, options) { const { onOptionMarked } = this.props; options = options || this.props.options; this.setState({ hovered: index }); onOptionMarked && onOptionMarked(options[index] || null); } _onSelect = (index, e) => { const { options, onSelect } = this.props; const chosenOption = options[index]; if (chosenOption) { const sameOptionWasPicked = chosenOption.id === this.state.selectedId; if (onSelect) { e.stopPropagation(); onSelect(chosenOption, sameOptionWasPicked); } } if (!this._isControlled()) { this.setState({ selectedId: chosenOption && chosenOption.id }); } return !!onSelect && chosenOption; }; _onMouseEnter = index => { if (this._isSelectableOption(this.props.options[index])) { this._markOption(index); } }; _onMouseLeave = () => this._markOption(NOT_HOVERED_INDEX); _getMarkedIndex() { const { options } = this.props; const useHoverIndex = this.state.hovered > NOT_HOVERED_INDEX; const useSelectedIdIndex = typeof this.state.selectedId !== 'undefined'; let markedIndex; if (useHoverIndex) { markedIndex = this.state.hovered; } else if (useSelectedIdIndex) { markedIndex = options.findIndex( option => option.id === this.state.selectedId, ); } else { markedIndex = NOT_HOVERED_INDEX; } return markedIndex; } _markNextStep(step) { const { options } = this.props; if (!options.some(this._isSelectableOption)) { return; } let markedIndex = this._getMarkedIndex(); do { markedIndex = Math.abs( modulu(Math.max(markedIndex + step, -1), options.length), ); } while (!this._isSelectableOption(options[markedIndex])); this._markOptionAtIndex(markedIndex); } _focusOnOption = () => { const { focusOnOption, options } = this.props; const markedIndex = options.findIndex( option => option.id === focusOnOption, ); if (markedIndex !== -1) { this._markOptionAtIndex(markedIndex); } else { // Remove focus this._markOption(markedIndex); } }; _markOptionAtIndex = markedIndex => { const { infiniteScroll } = this.props; this._markOption(markedIndex); const menuElement = this.options; const hoveredElement = infiniteScroll ? this.options.childNodes[0].childNodes[markedIndex] : this.options.childNodes[markedIndex]; scrollIntoView(menuElement, hoveredElement); }; /** * Handle keydown events for the DropdownLayout, mostly for accessibility * * @param {SyntheticEvent} event - The keydown event triggered by React * @returns {boolean} - Whether the event was handled by the component */ _onKeyDown = event => { if (!this.props.visible || this.props.isComposing) { return false; } switch (event.key) { case 'ArrowDown': { this._markNextStep(1); event.preventDefault(); break; } case 'ArrowUp': { this._markNextStep(-1); event.preventDefault(); break; } case ' ': case 'Spacebar': case 'Enter': { if (!this._onSelect(this.state.hovered, event)) { return false; } break; } case 'Tab': { if (this.props.closeOnSelect) { return this._onSelect(this.state.hovered, event); } else { if (this._onSelect(this.state.hovered, event)) { event.preventDefault(); return true; } else { return false; } } break; } case 'Escape': { this._onClose(); break; } default: { return false; } } event.stopPropagation(); return true; }; _onClose = () => { this._markOption(NOT_HOVERED_INDEX); if (this.props.onClose) { this.props.onClose(); } }; _renderNode(node) { return node ? <div>{node}</div> : null; } _wrapWithInfiniteScroll = scrollableElement => ( <InfiniteScroll useWindow dataHook={DATA_HOOKS.INFINITE_SCROLL_CONTAINER} scrollElement={this.options} loadMore={this.props.loadMore} hasMore={this.props.hasMore} data={this.props.options} loader={ <div className={classes.loader}> <Loader dataHook={DROPDOWN_LAYOUT_LOADER} size="small" /> </div> } > {scrollableElement} </InfiniteScroll> ); /** for testing purposes only */ _getDataAttributes = () => { const { visible, dropDirectionUp } = this.props; const { selectedId } = this.state; return filterObject( { 'data-hook': DATA_HOOKS.CONTENT_CONTAINER, [DATA_SHOWN]: visible, [DATA_SELECTED_OPTION_ID]: selectedId === 0 ? `${selectedId}` : selectedId, [DATA_DIRECTION]: dropDirectionUp ? DROPDOWN_LAYOUT_DIRECTIONS.UP : DROPDOWN_LAYOUT_DIRECTIONS.DOWN, }, (key, value) => !!value, ); }; _convertCustomOptionToBuilder({ option }) { const { value, id, disabled, overrideOptionStyle, overrideStyle } = option; if (overrideStyle) { deprecationLog( 'this prop is deprecated. Please use overrideOptionStyle to override all option styles', ); return { id, disabled, overrideStyle, value: props => <div data-hook={DATA_HOOKS.OPTION}>{value}</div>, }; } if (overrideOptionStyle) { return { id, disabled, overrideOptionStyle, value: props => <div data-hook={DATA_HOOKS.OPTION}>{value}</div>, }; } } _convertOptionToListItemSelectBuilder({ option }) { const { value, id, disabled } = option; const { selectedId } = this.state; const { itemHeight, selectedHighlight } = this.props; return listItemSelectBuilder({ id, title: <div data-hook={DATA_HOOKS.OPTION}>{value}</div>, disabled, selected: id === selectedId && selectedHighlight, className: st(classes.selectableOption, { itemHeight }), }); } _isBuilderOption({ option }) { const { value } = option; return typeof value === 'function'; } _isCustomOption({ option }) { const { overrideOptionStyle, overrideStyle } = option; return overrideOptionStyle || overrideStyle; } _isItemSection({ option }) { const { value, title: isTitle } = option; return value === DIVIDER_OPTION_VALUE || isTitle; } _convertOptionToBuilder(option, idx) { if (this._isBuilderOption({ option })) { return option; } else if (this._isItemSection({ option })) { return this._convertOptionToListItemSectionBuilder({ option, idx }); } else if (this._isCustomOption({ option })) { return this._convertCustomOptionToBuilder({ option }); } else { return this._convertOptionToListItemSelectBuilder({ option }); } } _renderOption({ option, idx }) { const builderOption = this._convertOptionToBuilder(option, idx); const content = this._renderOptionContent({ option: builderOption, idx, hasLink: !!option.linkTo, }); return option.linkTo ? ( <a className={classes.linkItem} key={idx} data-hook={DATA_HOOKS.LINK_ITEM} href={option.linkTo} role="option" aria-selected={option.id === this.state.selectedId} > {content} </a> ) : ( content ); } // For testing purposes only _getItemDataAttr = ({ hovered, selected, disabled }) => { const { itemHeight, selectedHighlight } = this.props; return filterObject( { [DATA_OPTION.DISABLED]: disabled, [DATA_OPTION.SELECTED]: selected && selectedHighlight, [DATA_OPTION.HOVERED]: hovered, /* deprecated */ [DATA_OPTION.SIZE]: itemHeight, }, (key, value) => !!value, ); }; _renderOptionContent({ option, idx, hasLink }) { const { itemHeight, selectedHighlight } = this.props; const { selectedId, hovered } = this.state; const { id, disabled, overrideStyle, overrideOptionStyle } = option; const optionState = { selected: id === selectedId, hovered: idx === hovered, disabled, }; return ( <div {...this._getItemDataAttr({ ...optionState })} role={hasLink ? undefined : 'option'} aria-selected={hasLink ? undefined : optionState.selected} className={ overrideOptionStyle ? null : st(classes.option, { ...optionState, selected: optionState.selected && selectedHighlight, itemHeight, overrideStyle, }) } ref={node => this._setSelectedOptionNode(node, option)} onClick={!disabled ? e => this._onSelect(idx, e) : null} key={idx} onMouseEnter={() => this._onMouseEnter(idx)} onMouseLeave={this._onMouseLeave} data-hook={`dropdown-item-${id}`} > {option.value(optionState)} </div> ); } _markOptionByProperty(props) { if (this.state.hovered === NOT_HOVERED_INDEX && props.markedOption) { const selectableOptions = props.options.filter(this._isSelectableOption); if (selectableOptions.length) { const idToMark = props.markedOption === true ? selectableOptions[0].id : props.markedOption; this._markOption( this._findIndex(props.options, item => item.id === idToMark), props.options, ); } } } _findIndex(arr, predicate) { return (Array.isArray(arr) ? arr : []).findIndex(predicate); } _isSelectableOption(option) { return ( option && option.value !== DIVIDER_OPTION_VALUE && !option.disabled && !option.title ); } render() { const { className, options, visible, dropDirectionUp, tabIndex, onMouseEnter, onMouseLeave, onMouseDown, fixedHeader, withArrow, fixedFooter, inContainer, overflow, maxHeightPixels, minWidthPixels, infiniteScroll, dataHook, } = this.props; const renderedOptions = options.map((option, idx) => this._renderOption({ option, idx }), ); return ( <div data-hook={dataHook} className={st( classes.root, { visible, withArrow, direction: dropDirectionUp ? DROPDOWN_LAYOUT_DIRECTIONS.UP : DROPDOWN_LAYOUT_DIRECTIONS.DOWN, containerStyles: !inContainer, }, className, )} tabIndex={tabIndex} onKeyDown={this._onKeyDown} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onMouseDown={onMouseDown} ref={this.containerRef} > <div {...this._getDataAttributes()} className={classes.contentContainer} style={{ overflow, maxHeight: getUnit(maxHeightPixels), minWidth: getUnit(minWidthPixels), }} > {this._renderNode(fixedHeader)} <div className={classes.options} style={{ maxHeight: getUnit(parseInt(maxHeightPixels, 10) - 35), overflow, }} ref={_options => (this.options = _options)} data-hook={DATA_HOOKS.DROPDOWN_LAYOUT_OPTIONS} role="listbox" > {infiniteScroll ? this._wrapWithInfiniteScroll(renderedOptions) : renderedOptions} </div> {this._renderNode(fixedFooter)} </div> {this._renderTopArrow()} </div> ); } } const optionPropTypes = PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, value: PropTypes.oneOfType([PropTypes.node, PropTypes.string, PropTypes.func]) .isRequired, disabled: PropTypes.bool, /** @deprecated*/ overrideStyle: PropTypes.bool, /** @deprecated*/ title: PropTypes.bool, overrideOptionStyle: PropTypes.bool, /* the string displayed within the input when the option is selected */ label: PropTypes.string, }); export function optionValidator(props, propName, componentName) { const option = props[propName]; // Notice: We don't use Proptypes.oneOf() to check for either option OR divider, because then the failure message would be less informative. if (typeof option === 'object' && option.value === DIVIDER_OPTION_VALUE) { return; } const optionError = PropTypes.checkPropTypes( { option: optionPropTypes }, { option }, 'option', componentName, ); if (optionError) { return optionError; } if (option.id && option.id.toString().trim().length === 0) { return new Error( 'Warning: Failed option type: The option `option.id` should be non-empty after trimming in `DropdownLayout`.', ); } if (option.value && option.value.toString().trim().length === 0) { return new Error( 'Warning: Failed option type: The option `option.value` should be non-empty after trimming in `DropdownLayout`.', ); } if (option.label && option.label.toString().trim().length === 0) { return new Error( 'Warning: Failed option type: The option `option.label` should be non-empty after trimming in `DropdownLayout`.', ); } } DropdownLayout.propTypes = { /** A single CSS class name to be appended to the root element. */ className: PropTypes.string, /** @deprecated */ dropDirectionUp: PropTypes.bool, /** Scroll view to the selected option on opening the dropdown */ focusOnSelectedOption: PropTypes.bool, /** Callback function called whenever the user press the `Escape` keyboard.*/ onClose: PropTypes.func, /** Callback function called whenever the user selects a different option in the list */ onSelect: PropTypes.func, /** Callback function called whenever an option becomes focused (hovered/active). Receives the relevant option object from the original props.options array. */ onOptionMarked: PropTypes.func, /** Set overflow of container */ overflow: PropTypes.string, /** Should show or hide the component */ visible: PropTypes.bool, /** Array of objects: * - id `<string / number>` *required*: the id of the option, should be unique. * - value `<function / string / node>` *required*: can be a string, react element or a builder function. * - disabled `<bool>` *default value- false*: whether this option is disabled or not * - linkTo `<string>`: when provided the option will be an anchor to the given value * - title `<bool>` *default value- false* **deprecated**: please use `listItemSectionBuilder` for rendering a title. * - overrideStyle `<bool>` *default value- false* **deprecated**: please use `overrideOptionStyle` for override option styles. * - overrideOptionStyle `<bool>` *default value- false* - when set to `true`, the option will be responsible to its own styles. No styles will be applied from the DropdownLayout itself. * - label `<string>`: the string displayed within an input when the option is selected. This is used when using `<DropdownLayout/>` with an `<Input/>`. */ options: PropTypes.arrayOf(optionValidator), /** The id of the selected option in the list */ selectedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Specifies the tab order of the component. */ tabIndex: PropTypes.number, /** @deprecated Do not use this prop. */ onClickOutside: PropTypes.func, /** A fixed header to the list */ fixedHeader: PropTypes.node, /** A fixed footer to the list */ fixedFooter: PropTypes.node, /** Set the max height of the dropdownLayout in pixels */ maxHeightPixels: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Set the min width of the dropdownLayout in pixels */ minWidthPixels: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** @deprecated Do not use this prop. */ withArrow: PropTypes.bool, /** Closes DropdownLayout on option selection */ closeOnSelect: PropTypes.bool, /** Callback function called whenever the user entered with the mouse to the dropdown layout.*/ onMouseEnter: PropTypes.func, /** Callback function called whenever the user exited with the mouse from the dropdown layout.*/ onMouseLeave: PropTypes.func, /** @deprecated Do not use this prop. */ itemHeight: PropTypes.oneOf(['small', 'big']), /** Whether the selected option will be highlighted when dropdown reopened. */ selectedHighlight: PropTypes.bool, /** Whether the `<DropdownLayout/>` is in a container component. If `true`, some styles such as shadows, positioning and padding will be added the the component contentContainer. */ inContainer: PropTypes.bool, /** Set this prop for lazy loading of the dropdown layout items.*/ infiniteScroll: PropTypes.bool, /** A callback called when more items are requested to be rendered. */ loadMore: PropTypes.func, /** Whether there are more items to be loaded. */ hasMore: PropTypes.bool, /** Sets the default hover behavior when: * 1. `false` means no default * 2. `true` means to hover the first selectable option * 3. Any number/string represents the id of option to hover */ markedOption: PropTypes.oneOfType([ PropTypes.bool, PropTypes.string, PropTypes.number, ]), /** Marks (not selects) and scrolls view to the option on opening the dropdown by option id */ focusOnOption: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), }; DropdownLayout.defaultProps = { options: [], tabIndex: 0, maxHeightPixels: 260, closeOnSelect: true, itemHeight: 'small', selectedHighlight: true, inContainer: false, infiniteScroll: false, loadMore: null, hasMore: false, markedOption: false, overflow: 'auto', }; DropdownLayout.displayName = 'DropdownLayout'; DropdownLayout.NONE_SELECTED_ID = NOT_HOVERED_INDEX; export default DropdownLayout;