@atlaskit/select
Version:
Select allows users to make a single selection or multiple selections from a list of options.
554 lines (544 loc) • 19.1 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import React, { PureComponent } from 'react';
import { bind } from 'bind-event-listener';
import { createPortal } from 'react-dom';
import FocusLock from 'react-focus-lock';
import { Manager as LegacyManager, Popper as LegacyPopper, Reference as LegacyReference } from 'react-popper';
import { shallowEqualObjects } from 'shallow-equal';
import { IdProvider } from '@atlaskit/ds-lib/id-provider';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { Manager as WrappedManager, Popper as WrappedPopper, Reference as WrappedReference } from '@atlaskit/popper/react-popper';
import { mergeStyles } from '@atlaskit/react-select/styles';
import Select from '../select';
import { defaultComponents } from './components';
import { DummyControl } from './dummy-control';
import { MenuDialog } from './menu-dialog';
import { NotifyOpenLayerObserver } from './notify-open-layer-observer';
import { PopupSelectTopLayer } from './popup-select-top-layer';
/**
* Are we rendering on the client or server?
*/
const canUseDOM = () => Boolean(typeof window !== 'undefined' && window.document && window.document.createElement);
function getPopperComponents() {
if (fg('platform-dst-popper-consolidation')) {
return {
Manager: WrappedManager,
Popper: WrappedPopper,
Reference: WrappedReference
};
}
return {
Manager: LegacyManager,
Popper: LegacyPopper,
Reference: LegacyReference
};
}
// ==============================
// Types
// ==============================
// ==============================
// Class
// ==============================
const modifiers = [{
name: 'offset',
options: {
offset: [0, 8]
}
}, {
name: 'preventOverflow',
enabled: true,
options: {
padding: 5,
boundary: 'clippingParents',
altAxis: true,
altBoundary: true
}
}];
const defaultPopperProps = {
modifiers,
placement: 'bottom-start'
};
const isEmpty = obj => Object.keys(obj).length === 0;
// eslint-disable-next-line @repo/internal/react/no-class-components
class PopupSelectLegacy extends PureComponent {
constructor(...args) {
var _this$defaultOpenStat;
super(...args);
_defineProperty(this, "menuRef", null);
_defineProperty(this, "selectRef", null);
_defineProperty(this, "targetRef", null);
_defineProperty(this, "unbindWindowClick", null);
_defineProperty(this, "unbindWindowKeydown", null);
_defineProperty(this, "defaultStyles", {
groupHeading: provided => ({
...provided,
color: "var(--ds-text-subtlest, #6B6E76)"
})
});
_defineProperty(this, "isOpenControlled", this.props.isOpen !== undefined);
_defineProperty(this, "defaultOpenState", this.isOpenControlled ? this.props.isOpen : this.props.defaultIsOpen);
_defineProperty(this, "state", {
focusLockEnabled: false,
isOpen: (_this$defaultOpenStat = this.defaultOpenState) !== null && _this$defaultOpenStat !== void 0 ? _this$defaultOpenStat : false,
mergedComponents: defaultComponents,
mergedPopperProps: defaultPopperProps
});
// Event Handlers
// ==============================
_defineProperty(this, "handleTargetKeyDown", event => {
switch (event.key) {
case 'ArrowDown':
this.open();
break;
default:
}
});
_defineProperty(this, "handleKeyDown", event => {
//We shouldn't close PopupSelect on tab event if there are custom interactive element.
const tabEvent = event.key === 'Tab' && event.shiftKey || event.key === 'Tab';
if (this.props.shouldCloseMenuOnTab && tabEvent) {
this.close();
}
switch (event.key) {
case 'Escape':
case 'Esc':
this.close();
if (this.props.shouldPreventEscapePropagation) {
event.stopPropagation();
}
break;
default:
}
if (this.props.onKeyDown) {
/* @ts-ignore - updating type of event React.KeyboardEvent effects the unbindWindowsKeyDown listener. Check if this can be fixed once the component gets refactor to functional */
this.props.onKeyDown(event);
}
});
_defineProperty(this, "handleClick", ({
target
}) => {
const {
isOpen
} = this.state;
// appease flow
if (!(target instanceof Element)) {
return;
}
// NOTE: Why not use the <Blanket /> component to close?
// We don't want to interupt the user's flow. Taking this approach allows
// user to click "through" to other elements and close the popout.
if (isOpen && this.menuRef && !this.menuRef.contains(target)) {
this.close();
}
// open on target click -- we can't trust consumers to spread the onClick
// property to the target
if (!isOpen && this.targetRef && this.targetRef.contains(target)) {
this.open();
}
});
_defineProperty(this, "handleSelectChange", (value, actionMeta) => {
const {
closeMenuOnSelect,
onChange
} = this.props;
if (closeMenuOnSelect && actionMeta.action !== 'clear') {
this.close();
}
if (onChange) {
onChange(value, actionMeta);
}
});
_defineProperty(this, "handleFirstPopperUpdate", () => {
// When the popup opens it's focused into. Since the popup is inside a portal, it's position is
// initially set to 0,0 - this causes the window scroll position to jump to the top. To prevent
// this we defer enabling the focus-lock until after Popper has positioned the popup the first time.
this.setState({
focusLockEnabled: true
});
});
// Internal Lifecycle
// ==============================
/**
* Opens the popup
*
* @param options.controlOverride - Force the popup to open when it's open state is being controlled
*/
_defineProperty(this, "open", options => {
const {
onOpen,
onMenuOpen
} = this.props;
if (!(options !== null && options !== void 0 && options.controlOverride) && this.isOpenControlled) {
// Prevent popup opening if it's open state is already being controlled
return;
}
if (onOpen) {
onOpen();
}
if (onMenuOpen) {
onMenuOpen();
}
this.setState({
isOpen: true
}, () => {
if (this.selectRef) {
var _this$selectRef$selec;
(_this$selectRef$selec = this.selectRef.select) === null || _this$selectRef$selec === void 0 ? void 0 : _this$selectRef$selec.openMenu('first');
}
});
if (typeof window === 'undefined') {
return;
}
this.unbindWindowKeydown = bind(window, {
type: 'keydown',
listener: this.handleKeyDown,
options: {
capture: true
}
});
});
/**
* Closes the popup
*
* @param options.controlOverride - Force the popup to close when it's open state is being controlled
*/
_defineProperty(this, "close", options => {
var _this$unbindWindowKey;
const {
onClose,
onMenuClose
} = this.props;
if (!(options !== null && options !== void 0 && options.controlOverride) && this.isOpenControlled) {
// Prevent popup closing if it's open state is already being controlled
return;
}
if (onClose) {
onClose();
}
if (onMenuClose) {
onMenuClose();
}
this.setState({
isOpen: false
});
this.setState({
focusLockEnabled: false
});
if (this.targetRef != null) {
this.targetRef.focus();
}
if (typeof window === 'undefined') {
return;
}
(_this$unbindWindowKey = this.unbindWindowKeydown) === null || _this$unbindWindowKey === void 0 ? void 0 : _this$unbindWindowKey.call(this);
this.unbindWindowKeydown = null;
});
// Refs
// ==============================
_defineProperty(this, "resolveTargetRef", popperRef => ref => {
// avoid thrashing fn calls
if (!this.targetRef && popperRef && ref) {
this.targetRef = ref;
if (typeof popperRef === 'function') {
popperRef(ref);
} else {
popperRef.current = ref;
}
}
});
_defineProperty(this, "resolveMenuRef", popperRef => ref => {
this.menuRef = ref;
if (typeof popperRef === 'function') {
popperRef(ref);
} else {
popperRef.current = ref;
}
});
_defineProperty(this, "getSelectRef", ref => {
this.selectRef = ref;
});
// Utils
// ==============================
// account for groups when counting options
// this may need to be recursive, right now it just counts one level
_defineProperty(this, "getItemCount", () => {
const {
options
} = this.props;
let count = 0;
options.forEach(groupOrOption => {
if (groupOrOption.options) {
groupOrOption.options.forEach(() => count++);
} else {
count++;
}
});
return count;
});
_defineProperty(this, "getMaxHeight", () => {
var _this$selectRef$selec2;
const {
maxMenuHeight
} = this.props;
if (!this.selectRef) {
return maxMenuHeight;
}
// subtract the control height to maintain consistency
const showSearchControl = this.showSearchControl();
const controlRef = (_this$selectRef$selec2 = this.selectRef.select) === null || _this$selectRef$selec2 === void 0 ? void 0 : _this$selectRef$selec2.controlRef;
const offsetHeight = showSearchControl && controlRef ? controlRef.offsetHeight : 0;
const maxHeight = maxMenuHeight - offsetHeight;
return maxHeight;
});
// if the threshold is exceeded, AND isSearchable is true, then display the search control
_defineProperty(this, "showSearchControl", () => {
const {
searchThreshold,
isSearchable
} = this.props;
return isSearchable && this.getItemCount() > searchThreshold;
});
// Renderers
// ==============================
_defineProperty(this, "renderSelect", id => {
const {
footer,
label,
maxMenuWidth,
minMenuWidth,
placeholder,
target: _target,
testId,
onMenuOpen: _onMenuOpen,
onMenuClose: _onMenuClose,
...props
} = this.props;
const {
focusLockEnabled,
isOpen,
mergedComponents,
mergedPopperProps
} = this.state;
const {
Popper
} = getPopperComponents();
const showSearchControl = this.showSearchControl();
const portalDestination = canUseDOM() ? document.body : null;
if (!portalDestination || !isOpen) {
return null;
}
const selectComponents = {
...mergedComponents,
Control: showSearchControl ? mergedComponents.Control : DummyControl
};
const getLabel = () => {
if (label) {
return label;
} else if (typeof placeholder === 'string') {
return placeholder;
}
return undefined;
};
const popper = /*#__PURE__*/React.createElement(Popper, _extends({}, mergedPopperProps, {
onFirstUpdate: state => {
var _mergedPopperProps$on;
this.handleFirstPopperUpdate();
(_mergedPopperProps$on = mergedPopperProps.onFirstUpdate) === null || _mergedPopperProps$on === void 0 ? void 0 : _mergedPopperProps$on.call(mergedPopperProps, state);
}
}), ({
placement,
ref,
style
}) => /*#__PURE__*/React.createElement(MenuDialog
// There is not a limited amount of values for the widths, so they need to remain dynamic.
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- Ignored via go/DSP-18766
, {
style: {
...style,
maxWidth: maxMenuWidth,
minWidth: minMenuWidth
},
"data-placement": placement,
id: id,
testId: testId,
ref: this.resolveMenuRef(ref)
}, /*#__PURE__*/React.createElement(FocusLock, {
disabled: !focusLockEnabled,
returnFocus: true
}, /*#__PURE__*/React.createElement(Select, _extends({
label: getLabel(),
backspaceRemovesValue: false,
controlShouldRenderValue: false,
isClearable: false,
tabSelectsValue: false,
menuIsOpen: true,
placeholder: placeholder,
ref: this.getSelectRef
}, props, {
isSearchable: showSearchControl,
styles: mergeStyles(this.defaultStyles, props.styles || {}),
maxMenuHeight: this.getMaxHeight(),
components: selectComponents,
onChange: this.handleSelectChange,
testId: testId
// Note: We are intentionally not using `onMenuClose={this.close}` here, due to state management conflicts
// between PopupSelect's popup and the internal select menu. This can result in the PopupSelect closing
// or opening unexpectedly.
})), footer)));
return mergedPopperProps.strategy === 'fixed' ? popper : /*#__PURE__*/createPortal(popper, portalDestination);
});
_defineProperty(this, "handleOpenLayerObserverCloseSignal", () => {
this.close();
});
}
static getDerivedStateFromProps(props, state) {
const newState = {};
// Merge consumer and default popper props
const mergedPopperProps = {
...defaultPopperProps,
...props.popperProps
};
if (!shallowEqualObjects(mergedPopperProps, state.mergedPopperProps)) {
newState.mergedPopperProps = mergedPopperProps;
}
// Merge consumer and default components
const mergedComponents = {
...defaultComponents,
...props.components
};
if (!shallowEqualObjects(mergedComponents, state.mergedComponents)) {
newState.mergedComponents = mergedComponents;
}
if (!isEmpty(newState)) {
return newState;
}
return null;
}
componentDidMount() {
if (typeof window === 'undefined') {
return;
}
this.unbindWindowClick = bind(window, {
type: 'click',
listener: this.handleClick,
options: {
capture: true
}
});
}
componentWillUnmount() {
var _this$unbindWindowCli, _this$unbindWindowKey2;
if (typeof window === 'undefined') {
return;
}
(_this$unbindWindowCli = this.unbindWindowClick) === null || _this$unbindWindowCli === void 0 ? void 0 : _this$unbindWindowCli.call(this);
this.unbindWindowClick = null;
(_this$unbindWindowKey2 = this.unbindWindowKeydown) === null || _this$unbindWindowKey2 === void 0 ? void 0 : _this$unbindWindowKey2.call(this);
this.unbindWindowKeydown = null;
}
componentDidUpdate(prevProps) {
const {
isOpen
} = this.props;
if (prevProps.isOpen !== isOpen) {
if (isOpen === true) {
this.open({
controlOverride: true
});
} else if (isOpen === false) {
this.close({
controlOverride: true
});
}
}
}
render() {
const {
target
} = this.props;
const {
isOpen
} = this.state;
const {
Manager,
Reference
} = getPopperComponents();
return /*#__PURE__*/React.createElement(Manager, null, /*#__PURE__*/React.createElement(IdProvider, {
postfix: "-popup-select"
}, ({
id
}) => /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Reference, null, ({
ref
}) => target && target({
isOpen,
onKeyDown: this.handleTargetKeyDown,
ref: this.resolveTargetRef(ref),
// Should technically be 'dialog' by default instead of 'true' via AriaAttributes, but will require further changes.
// See go/DSP-22283
'aria-haspopup': 'true',
'aria-expanded': isOpen,
'aria-controls': isOpen ? id : undefined
})), this.renderSelect(id))), /*#__PURE__*/React.createElement(NotifyOpenLayerObserver, {
isOpen: this.state.isOpen,
onClose: this.handleOpenLayerObserverCloseSignal
}));
}
}
// This facade preserves the historical class type and imperative ref API while the feature gate
// selects between independently implemented legacy and top-layer render paths.
// We cannot just branch inside the `render()` method of `PopupSelectLegacy` class because
// it has life cycle methods which would still affect the top layer rendering path.
// eslint-disable-next-line @repo/internal/react/no-class-components
_defineProperty(PopupSelectLegacy, "defaultProps", {
closeMenuOnSelect: true,
shouldCloseMenuOnTab: true,
components: {},
maxMenuHeight: 300,
maxMenuWidth: 440,
minMenuWidth: 220,
popperProps: {},
isSearchable: true,
searchThreshold: 5,
styles: {},
options: []
});
export class PopupSelect extends PureComponent {
constructor(...args) {
super(...args);
_defineProperty(this, "implementation", null);
_defineProperty(this, "setImplementationRef", implementation => {
this.implementation = implementation;
});
_defineProperty(this, "open", options => {
var _this$implementation;
(_this$implementation = this.implementation) === null || _this$implementation === void 0 ? void 0 : _this$implementation.open(options);
});
_defineProperty(this, "close", options => {
var _this$implementation2;
(_this$implementation2 = this.implementation) === null || _this$implementation2 === void 0 ? void 0 : _this$implementation2.close(options);
});
}
get selectRef() {
var _this$implementation$, _this$implementation3;
return (_this$implementation$ = (_this$implementation3 = this.implementation) === null || _this$implementation3 === void 0 ? void 0 : _this$implementation3.selectRef) !== null && _this$implementation$ !== void 0 ? _this$implementation$ : null;
}
get menuRef() {
var _this$implementation$2, _this$implementation4;
return (_this$implementation$2 = (_this$implementation4 = this.implementation) === null || _this$implementation4 === void 0 ? void 0 : _this$implementation4.menuRef) !== null && _this$implementation$2 !== void 0 ? _this$implementation$2 : null;
}
get targetRef() {
var _this$implementation$3, _this$implementation5;
return (_this$implementation$3 = (_this$implementation5 = this.implementation) === null || _this$implementation5 === void 0 ? void 0 : _this$implementation5.targetRef) !== null && _this$implementation$3 !== void 0 ? _this$implementation$3 : null;
}
render() {
if (fg('platform-dst-top-layer')) {
return /*#__PURE__*/React.createElement(PopupSelectTopLayer, _extends({
ref: this.setImplementationRef
}, this.props));
}
return /*#__PURE__*/React.createElement(PopupSelectLegacy, _extends({
ref: this.setImplementationRef
}, this.props));
}
}