UNPKG

@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
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)); } }