UNPKG

@wix/design-system

Version:

@wix/design-system

307 lines 13.4 kB
import React, { PureComponent, } from 'react'; import PropTypes from 'prop-types'; import uniqueId from 'lodash/uniqueId'; import { st, classes } from './DropdownBase.st.css.js'; import Popover, { placements } from '../Popover'; import DropdownLayout from '../DropdownLayout'; class DropdownBase extends PureComponent { constructor(props) { super(props); this.uniqueId = uniqueId('DropdownBase'); this._dropdownLayoutRef = null; this._shouldCloseOnMouseLeave = false; this.state = { open: this.props.open, selectedId: this.props.selectedId ?? this.props.initialSelectedId ?? -1, activeDescendantId: undefined, listAutoFocus: false, }; /** * Return `true` if the `open` prop is being controlled */ this._isControllingOpen = (props = this.props) => { return typeof props.open !== 'undefined'; }; /** * Return `true` if the selection behaviour is being controlled */ this._isControllingSelection = (props = this.props) => { return (typeof props.selectedId !== 'undefined' && typeof props.onSelect !== 'undefined'); }; this._open = () => { if (this.state.open) { this._dropdownLayoutRef && this._dropdownLayoutRef._focusFirstOption(); return; } if (!this._isControllingOpen()) { this.setState({ open: true }); /** @ts-expect-error Typescript doesn't know about `DropdownBase.defaultProps` */ this.props.onShow(); } }; this._close = (e) => { if (this._isControllingOpen()) { return; } // If called within a `mouseleave` event on the target element, we would like to close the // popover only on the popover's `mouseleave` event if (e && e.type === 'mouseleave') { // We're not using `setState` since we don't want to wait for the next render this._shouldCloseOnMouseLeave = true; } else { this.setState({ open: false, listAutoFocus: false, activeDescendantId: undefined, }); } /** @ts-expect-error Typescript doesn't know about `DropdownBase.defaultProps` */ this.props.onHide(); }; this._toggle = () => { !this._isControllingOpen() && this.setState(({ open }) => { if (open) { /** @ts-expect-error Typescript doesn't know about `DropdownBase.defaultProps` */ this.props.onHide(); } else { /** @ts-expect-error Typescript doesn't know about `DropdownBase.defaultProps` */ this.props.onShow(); } return { open: !open, listAutoFocus: false, }; }); }; this._handleClickOutside = () => { const { onClickOutside } = this.props; this._close(); onClickOutside && onClickOutside(); }; this._handlePopoverMouseEnter = () => { const { onMouseEnter } = this.props; onMouseEnter && onMouseEnter(); }; this._handlePopoverMouseLeave = () => { const { onMouseLeave } = this.props; if (this._shouldCloseOnMouseLeave) { this._shouldCloseOnMouseLeave = false; this.setState({ open: false, activeDescendantId: undefined, }); } onMouseLeave && onMouseLeave(); }; this._handleSelect = (selectedOption) => { const newState = {}; if (!this._isControllingOpen()) { newState.open = false; /** @ts-expect-error Typescript doesn't know about `DropdownBase.defaultProps` */ this.props.onHide(); } if (!this._isControllingSelection()) { newState.selectedId = selectedOption.id; } this.setState(newState, () => { const { onSelect } = this.props; onSelect && onSelect(selectedOption); }); }; this._handleOptionMarked = (_option, optionElementId) => { this.setState({ activeDescendantId: optionElementId }); }; this._handleClose = () => { if (this.state.open) { this._close(); } if (this.triggerElementRef && this.triggerElementRef.current && this.triggerElementRef.current.focus) { this.triggerElementRef.current.focus(); } }; this._getSelectedOption = (selectedId) => { return this.props.options?.find(({ id }) => id === selectedId); }; /** * Determine if a certain key should open the DropdownLayout */ this._isOpenKey = (key) => { return ['Enter', ' ', 'ArrowDown'].includes(key); }; this._isClosingKey = (key) => { return ['Tab', 'Escape'].includes(key); }; /** * A common `keydown` event that can be used for the target elements. It will automatically * delegate the event to the underlying <DropdownLayout/>, and will determine when to open the * dropdown depending on the pressed key. */ this._handleKeyDown = (e) => { if (this._isControllingOpen()) { return; } const isHandledByDropdownLayout = this._delegateKeyDown(e); if (!isHandledByDropdownLayout) { if (this._isOpenKey(e.key)) { this.setState({ listAutoFocus: true }); this._open(); e.preventDefault(); } else if (this._isClosingKey(e.key)) { this._close(); } } // prevent toggle button onClick when pressing enter and dropdown is open else if (this.state.open && e.key === 'Enter') { e.preventDefault(); } }; /* * Delegate the event to the DropdownLayout. It'll handle the navigation, option selection and * closing of the dropdown. */ this._delegateKeyDown = (e) => { if (!this._dropdownLayoutRef) { return false; } return this._dropdownLayoutRef._onSelectListKeyDown(e); }; this._setDropdownLayoutRef = (ref) => { this._dropdownLayoutRef = ref; this.setState({ activeDescendantId: this._dropdownLayoutRef?.getActiveDescendentElementId(), }); }; this.triggerElementRef = React.createRef(); } UNSAFE_componentWillReceiveProps(nextProps) { // Keep internal state updated if needed if (this._isControllingOpen(nextProps) && this.props.open !== nextProps.open) { this.setState({ open: nextProps.open }); } if (this._isControllingSelection(nextProps) && this.props.selectedId !== nextProps.selectedId) { this.setState({ selectedId: nextProps.selectedId }); } } _renderChildren() { const { children } = this.props; const { selectedId, activeDescendantId, open } = this.state; if (!children) { return null; } return React.isValidElement(children) ? children // Returning the children as is when using in controlled mode : children({ open: this._open, close: this._close, toggle: this._toggle, isOpen: Boolean(open), ref: this.triggerElementRef, delegateKeyDown: this._delegateKeyDown, selectedOption: this._getSelectedOption(selectedId), listboxId: `${this.uniqueId}-listbox`, activeDescendantId, }); } render() { const { dataHook, placement, appendTo, showArrow, zIndex, moveBy, options, minWidth, maxWidth, fixed, flip, tabIndex, overflow, dynamicWidth, maxHeight, fluid, animate, className, focusOnSelectedOption, infiniteScroll, loadMore, hasMore, focusOnOption, scrollToOption, markedOption, onMouseDown, listType, fixedHeader, fixedFooter, autoFocus, } = this.props; const { open, selectedId } = this.state; const { onShow, onHide, ...popoverProps } = this.props; return (React.createElement(Popover, { "data-list-type": listType, ...popoverProps, animate: animate, dataHook: dataHook, shown: open, placement: placement, dynamicWidth: dynamicWidth, appendTo: appendTo, showArrow: showArrow, zIndex: zIndex, moveBy: moveBy, onKeyDown: this._handleKeyDown, onMouseEnter: this._handlePopoverMouseEnter, onMouseLeave: this._handlePopoverMouseLeave, onClickOutside: this._handleClickOutside, fixed: fixed, flip: flip, fluid: fluid, className: st(classes.root, { withWidth: Boolean(minWidth || maxWidth), }, className) }, React.createElement(Popover.Element, null, this._renderChildren()), React.createElement(Popover.Content, null, React.createElement("div", { style: { minWidth, maxWidth, } }, React.createElement(DropdownLayout, { dataHook: "dropdown-base-dropdownlayout", className: classes.list, ref: this._setDropdownLayoutRef, selectedId: selectedId, options: options, maxHeightPixels: maxHeight, onSelect: this._handleSelect, onOptionMarked: this._handleOptionMarked, onClose: this._handleClose, tabIndex: tabIndex, inContainer: true, visible: true, overflow: overflow, focusOnSelectedOption: focusOnSelectedOption, infiniteScroll: infiniteScroll, loadMore: loadMore, hasMore: hasMore, focusOnOption: focusOnOption, markedOption: markedOption, scrollToOption: scrollToOption, onMouseDown: onMouseDown, listType: listType, autoFocus: autoFocus || this.state.listAutoFocus, fixedHeader: fixedHeader, fixedFooter: fixedFooter, listboxId: `${this.uniqueId}-listbox` }))))); } } DropdownBase.displayName = 'DropdownBase'; DropdownBase.propTypes = { dataHook: PropTypes.string, className: PropTypes.string, open: PropTypes.bool, placement: PropTypes.oneOf(placements), appendTo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), showArrow: PropTypes.bool, onClickOutside: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, onShow: PropTypes.func, onHide: PropTypes.func, onSelect: PropTypes.func, dynamicWidth: PropTypes.bool, minWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), options: PropTypes.arrayOf(PropTypes.oneOfType([ 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, }), // A divider option without an id PropTypes.shape({ value: PropTypes.oneOf(['-']), }), ])), markedOption: PropTypes.oneOfType([ PropTypes.bool, PropTypes.string, PropTypes.number, ]), selectedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), overflow: PropTypes.string, tabIndex: PropTypes.number, initialSelectedId: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]), zIndex: PropTypes.number, moveBy: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }), flip: PropTypes.bool, fixed: PropTypes.bool, fluid: PropTypes.bool, animate: PropTypes.bool, focusOnSelectedOption: PropTypes.bool, infiniteScroll: PropTypes.bool, loadMore: PropTypes.func, hasMore: PropTypes.bool, focusOnOption: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), scrollToOption: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), listType: PropTypes.oneOf(['action', 'select']), fixedHeader: PropTypes.node, fixedFooter: PropTypes.node, autoFocus: PropTypes.bool, }; DropdownBase.defaultProps = { placement: 'bottom', appendTo: 'parent', showArrow: false, maxHeight: '260px', fluid: false, animate: false, listType: 'select', onShow: () => { }, onHide: () => { }, }; export default DropdownBase; //# sourceMappingURL=DropdownBase.js.map