@wix/design-system
Version:
@wix/design-system
307 lines • 13.4 kB
JavaScript
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