wix-style-react
Version:
461 lines (398 loc) • 13.6 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { st, classes } from './DropdownBase.st.css';
import Popover, { placements } from '../Popover';
import DropdownLayout from '../DropdownLayout';
class DropdownBase extends React.PureComponent {
static displayName = 'DropdownBase';
static propTypes = {
/** Applies a data-hook HTML attribute that can be used in the tests */
dataHook: PropTypes.string,
/** Specifies a CSS class name to be appended to the component’s root element */
className: PropTypes.string,
/** Control whether the <Popover/> should be opened */
open: PropTypes.bool,
/** Control popover placement */
placement: PropTypes.oneOf(placements),
/** Specifies where popover should be inserted as a last child - whether `parent` or `window` containers */
appendTo: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
/** Specifies whether popover arrow should be shown */
showArrow: PropTypes.bool,
/** Defines a callback function which is called when user clicks outside of a dropdown */
onClickOutside: PropTypes.func,
/** Defines a callback function which is called on `onMouseEnter` event on the entire component */
onMouseEnter: PropTypes.func,
/** Defines a callback function which is called on `onMouseLeave` event on the entire component */
onMouseLeave: PropTypes.func,
/** Defines a callback function which is called when dropdown is opened */
onShow: PropTypes.func,
/** Defines a callback function which is called when dropdown is closed */
onHide: PropTypes.func,
/** Defines a callback function which is called whenever user selects a different option in the list */
onSelect: PropTypes.func,
/**
* Set popover's content width to a minimum width of a trigger element,
* but it can expand up to the defined value of `maxWidth`
*/
dynamicWidth: PropTypes.bool,
/** Controls the minimum width of dropdown layout */
minWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/** Controls the maximum width of dropdown layout */
maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/** Controls the maximum height of dropdown layout */
maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* Specifies a target component to be rendered. If a regular node is passed, it'll be rendered as-is.
* If a function is passed, it's expected to return a React element.
* The function accepts an object containing the following properties:
*
* * `open` - will open the Popover
* * `close` - will close the Popover
* * `toggle` - will toggle the Popover
* * `isOpen` - indicates whether the items list is currently open
* * `delegateKeyDown` - the underlying DropdownLayout's keydown handler. It can be called
* inside another keyDown event in order to delegate it.
* * `selectedOption` - the currently selected option
*
* Check inserted component documentation for more information on available properties.
*/
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
/**
* Specifies an array of options for a dropdown list. Objects must have an id and can include string value or node.
* If value is '-', a divider will be rendered instead (dividers do not require and id).
*/
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(['-']),
}),
]),
),
/** Sets the initial marking of an option in the list when opened:
* - `false` - no initially hovered list item
* - `true` - hover first selectable option
* - any `number/string` specify the id of an option to be hovered
*/
markedOption: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string,
PropTypes.number,
]),
/** Define the selected option in the list */
selectedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Handles container overflow behaviour */
overflow: PropTypes.string,
/** Indicates that element can be focused and where it participates in sequential keyboard navigation */
tabIndex: PropTypes.number,
/**
* Sets the initially selected option in the list. Used when selection
* behaviour is being controlled.
*/
initialSelectedId: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
/** Specifies the stack order (`z-index`) of a dropdown layout */
zIndex: PropTypes.number,
/** Moves dropdown content relative to the parent on X or Y axis by a defined amount of pixels */
moveBy: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
/**
* Specifies whether to flip the <Popover/> placement
* when it starts to overlap the target element (<Popover.Element/>)
*/
flip: PropTypes.bool,
/**
* Specifies whether to enable the fixed behaviour. If enabled, <Popover/> keep its
* original placement even when it's being positioned outside the boundary.
*/
fixed: PropTypes.bool,
/** Stretches trigger element to fill its parent container width */
fluid: PropTypes.bool,
/** Adds enter and exit animation */
animate: PropTypes.bool,
/** Scrolls to the selected option when dropdown is opened */
focusOnSelectedOption: PropTypes.bool,
/** Specifies whether lazy loading of the dropdown items is enabled */
infiniteScroll: PropTypes.bool,
/** Defines a callback function which is called on a request to render more list items */
loadMore: PropTypes.func,
/** Specifies whether there are more items to load */
hasMore: PropTypes.bool,
/** Scrolls to the specified option when dropdown is opened */
focusOnOption: PropTypes.number,
};
static defaultProps = {
placement: 'bottom',
appendTo: 'parent',
showArrow: false,
maxHeight: '260px',
fluid: false,
animate: false,
onShow: () => {},
onHide: () => {},
};
_dropdownLayoutRef = null;
_shouldCloseOnMouseLeave = false;
state = {
open: this.props.open,
selectedId: this.props.selectedId ?? this.props.initialSelectedId ?? -1,
};
/**
* Return `true` if the `open` prop is being controlled
*/
_isControllingOpen = (props = this.props) => {
return typeof props.open !== 'undefined';
};
/**
* Return `true` if the selection behaviour is being controlled
*/
_isControllingSelection = (props = this.props) => {
return (
typeof props.selectedId !== 'undefined' &&
typeof props.onSelect !== 'undefined'
);
};
_open = () => {
if (!this._isControllingOpen()) {
this.setState({ open: true });
this.props.onShow();
}
};
_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 });
}
this.props.onHide();
};
_toggle = () => {
!this._isControllingOpen() &&
this.setState(({ open }) => {
if (open) {
this.props.onHide();
} else {
this.props.onShow();
}
return {
open: !open,
};
});
};
_handleClickOutside = () => {
const { onClickOutside } = this.props;
this._close();
onClickOutside && onClickOutside();
};
_handlePopoverMouseEnter = () => {
const { onMouseEnter } = this.props;
onMouseEnter && onMouseEnter();
};
_handlePopoverMouseLeave = () => {
const { onMouseLeave } = this.props;
if (this._shouldCloseOnMouseLeave) {
this._shouldCloseOnMouseLeave = false;
this.setState({
open: false,
});
}
onMouseLeave && onMouseLeave();
};
_handleSelect = selectedOption => {
const newState = {};
if (!this._isControllingOpen()) {
newState.open = false;
this.props.onHide();
}
if (!this._isControllingSelection()) {
newState.selectedId = selectedOption.id;
}
this.setState(newState, () => {
const { onSelect } = this.props;
onSelect && onSelect(selectedOption);
});
};
_handleClose = () => {
if (this.state.open) {
this._close();
}
};
_getSelectedOption = selectedId => {
return this.props.options.find(({ id }) => id === selectedId);
};
/**
* Determine if a certain key should open the DropdownLayout
*/
_isOpenKey = key => {
return ['Enter', 'Spacebar', ' ', 'ArrowDown'].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.
*/
_handleKeyDown = e => {
if (this._isControllingOpen()) {
return;
}
const isHandledByDropdownLayout = this._delegateKeyDown(e);
if (!isHandledByDropdownLayout) {
if (this._isOpenKey(e.key)) {
this._open();
e.preventDefault();
}
}
};
/*
* Delegate the event to the DropdownLayout. It'll handle the navigation, option selection and
* closing of the dropdown.
*/
_delegateKeyDown = e => {
if (!this._dropdownLayoutRef) {
return false;
}
return this._dropdownLayoutRef._onKeyDown(e);
};
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, 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),
delegateKeyDown: this._delegateKeyDown,
selectedOption: this._getSelectedOption(selectedId),
});
}
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,
markedOption,
onMouseDown,
} = this.props;
const { open, selectedId } = this.state;
return (
<Popover
{...this.props} // backward compatible for migration stylable 1 to stylable 3
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),
withArrow: showArrow,
},
className,
)}
>
<Popover.Element>{this._renderChildren()}</Popover.Element>
<Popover.Content>
<div
style={{
minWidth,
maxWidth,
}}
>
<DropdownLayout
dataHook="dropdown-base-dropdownlayout"
className={classes.list}
ref={r => (this._dropdownLayoutRef = r)}
selectedId={selectedId}
options={options}
maxHeightPixels={maxHeight}
onSelect={this._handleSelect}
onClose={this._handleClose}
tabIndex={tabIndex}
inContainer
visible
overflow={overflow}
focusOnSelectedOption={focusOnSelectedOption}
infiniteScroll={infiniteScroll}
loadMore={loadMore}
hasMore={hasMore}
focusOnOption={focusOnOption}
markedOption={markedOption}
onMouseDown={onMouseDown}
/>
</div>
</Popover.Content>
</Popover>
);
}
}
export default DropdownBase;