UNPKG

wix-style-react

Version:
496 lines (434 loc) • 13.4 kB
import styles from './DropdownLayout.scss'; import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import WixComponent from '../BaseComponents/WixComponent'; import scrollIntoView from '../utils/scrollIntoView'; import InfiniteScroll from '../utils/InfiniteScroll'; import Loader from '../Loader/Loader'; const modulu = (n, m) => { const remain = n % m; return remain >= 0 ? remain : remain + m; }; const NOT_HOVERED_INDEX = -1; export const DIVIDER_OPTION_VALUE = '-'; class DropdownLayout extends WixComponent { constructor(props) { super(props); this.state = { hovered: NOT_HOVERED_INDEX, selectedId: props.selectedId, }; this._onSelect = this._onSelect.bind(this); this._onMouseLeave = this._onMouseLeave.bind(this); this._onMouseEnter = this._onMouseEnter.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onClose = this._onClose.bind(this); this.onClickOutside = this.onClickOutside.bind(this); } _isControlled() { return ( typeof this.props.selectedId !== 'undefined' && typeof this.props.onSelect !== 'undefined' ); } componentDidMount() { super.componentDidMount(); if (this.props.focusOnSelectedOption) { this._focusOnSelectedOption(); } } _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); } } _onSelect(index) { const { options, onSelect } = this.props; const chosenOption = options[index]; const newState = { hovered: NOT_HOVERED_INDEX }; if (chosenOption) { const sameOptionWasPicked = chosenOption.id === this.state.selectedId; if (onSelect) { onSelect(chosenOption, sameOptionWasPicked); } } if (!this._isControlled()) { newState.selectedId = chosenOption && chosenOption.id; } this.setState(newState); return !!onSelect && chosenOption; } _onMouseEnter(index) { if (this._isSelectableOption(this.props.options[index])) { this.setState({ hovered: index }); } } _onMouseLeave() { this.setState({ hovered: 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.setState({ hovered: markedIndex }); const menuElement = this.options; const hoveredElement = 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); break; } case 'ArrowUp': { this._markNextStep(-1); break; } case ' ': case 'Spacebar': case 'Enter': { if (!this._onSelect(this.state.hovered)) { return false; } break; } case 'Tab': { if (this.props.closeOnSelect) { return this._onSelect(this.state.hovered); } else { event.preventDefault(); if (!this._onSelect(this.state.hovered)) { return false; } } break; } case 'Escape': { this._onClose(); break; } default: { return false; } } event.preventDefault(); event.stopPropagation(); return true; } _onClose() { this.setState({ hovered: NOT_HOVERED_INDEX, }); if (this.props.onClose) { this.props.onClose(); } } _renderNode(node) { return node ? <div className={styles.node}>{node}</div> : null; } _wrapWithInfiniteScroll = scrollableElement => ( <InfiniteScroll useWindow scrollElement={this.options} loadMore={this.props.loadMore} hasMore={this.props.hasMore} loader={ <div className={styles.loader}> <Loader dataHook={'dropdownLayout-loader'} size={'small'} /> </div> } > {scrollableElement} </InfiniteScroll> ); render() { const { options, visible, dropDirectionUp, tabIndex, onMouseEnter, onMouseLeave, fixedHeader, withArrow, fixedFooter, inContainer, } = this.props; const renderedOptions = options.map((option, idx) => this._renderOption({ option, idx }), ); const contentContainerClassName = classNames({ [styles.contentContainer]: true, [styles.shown]: visible, [styles.up]: dropDirectionUp, [styles.down]: !dropDirectionUp, [styles.withArrow]: withArrow, [styles.containerStyles]: !inContainer, }); return ( <div tabIndex={tabIndex} className={classNames( styles.wrapper, styles[`theme-${this.props.theme}`], )} onKeyDown={this._onKeyDown} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} > <div className={contentContainerClassName} style={{ maxHeight: this.props.maxHeightPixels + 'px', minWidth: this.props.minWidthPixels ? `${this.props.minWidthPixels}px` : undefined, }} > {this._renderNode(fixedHeader)} <div className={styles.options} style={{ maxHeight: this.props.maxHeightPixels - 35 + 'px' }} ref={_options => (this.options = _options)} data-hook="dropdown-layout-options" > {this.props.infiniteScroll ? this._wrapWithInfiniteScroll(renderedOptions) : renderedOptions} </div> {this._renderNode(fixedFooter)} </div> {this._renderTopArrow()} </div> ); } _renderOption({ option, idx }) { const { value, id, disabled, title, overrideStyle, linkTo } = option; if (value === DIVIDER_OPTION_VALUE) { return this._renderDivider(idx, `dropdown-divider-${id || idx}`); } const content = this._renderItem({ option, idx, selected: id === this.state.selectedId, hovered: idx === this.state.hovered, disabled: disabled || title, title, overrideStyle, dataHook: `dropdown-item-${id}`, }); return linkTo ? ( <a key={idx} data-hook="link-item" href={linkTo}> {content} </a> ) : ( content ); } _renderDivider(idx, dataHook) { return <div key={idx} className={styles.divider} data-hook={dataHook} />; } _renderItem({ option, idx, selected, hovered, disabled, title, overrideStyle, dataHook, }) { const { itemHeight, selectedHighlight } = this.props; const optionClassName = classNames({ [styles.option]: !overrideStyle, [styles.selected]: selected && !overrideStyle && selectedHighlight, wixstylereactSelected: selected && overrideStyle, //global class for items that use the overrideStyle [styles.hovered]: hovered && !overrideStyle, wixstylereactHovered: hovered && overrideStyle, //global class for items that use the overrideStyle [styles.disabled]: disabled, [styles.title]: title, [styles.smallHeight]: itemHeight === 'small', [styles.bigHeight]: itemHeight === 'big', }); return ( <div className={optionClassName} ref={node => this._setSelectedOptionNode(node, option)} onMouseDown={!disabled ? () => this._onSelect(idx) : null} key={idx} onMouseEnter={() => this._onMouseEnter(idx)} onMouseLeave={this._onMouseLeave} data-hook={dataHook} > {typeof option.value === 'function' ? option.value({ selected }) : option.value} </div> ); } _renderTopArrow() { const { withArrow, visible, dropDirectionUp } = this.props; const arrowClassName = classNames({ [styles.arrow]: true, [styles.up]: dropDirectionUp, [styles.down]: !dropDirectionUp, }); return withArrow && visible ? <div className={arrowClassName} /> : null; } componentWillReceiveProps(nextProps) { if (this.props.visible !== nextProps.visible) { this.setState({ hovered: 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.setState({ hovered: this.findIndex( nextProps.options, item => item.id === this.props.options[this.state.hovered].id, ), }); } } findIndex(arr, predicate) { return (Array.isArray(arr) ? arr : []).findIndex(predicate); } _isSelectableOption(option) { return option && option.value !== '-' && !option.disabled && !option.title; } } 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, overrideStyle: PropTypes.bool, }); 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`.', ); } } DropdownLayout.propTypes = { dropDirectionUp: PropTypes.bool, focusOnSelectedOption: PropTypes.bool, onClose: PropTypes.func, /** Callback function called whenever the user selects a different option in the list */ onSelect: PropTypes.func, visible: PropTypes.bool, /** Array of objects. Objects must have an Id and can can include value and node. If value is '-', a divider will be rendered instead (dividers do not require and id). */ options: PropTypes.arrayOf(optionValidator), /** The id of the selected option in the list */ selectedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), tabIndex: PropTypes.number, theme: PropTypes.string, onClickOutside: PropTypes.func, /** A fixed header to the list */ fixedHeader: PropTypes.node, /** A fixed footer to the list */ fixedFooter: PropTypes.node, maxHeightPixels: PropTypes.number, minWidthPixels: PropTypes.number, withArrow: PropTypes.bool, closeOnSelect: PropTypes.bool, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, itemHeight: PropTypes.oneOf(['small', 'big']), selectedHighlight: PropTypes.bool, inContainer: PropTypes.bool, infiniteScroll: PropTypes.bool, loadMore: PropTypes.func, hasMore: PropTypes.bool, }; DropdownLayout.defaultProps = { options: [], tabIndex: 0, maxHeightPixels: 260, closeOnSelect: true, itemHeight: 'small', selectedHighlight: true, inContainer: false, infiniteScroll: false, loadMore: null, hasMore: false, }; DropdownLayout.NONE_SELECTED_ID = NOT_HOVERED_INDEX; export default DropdownLayout;