UNPKG

@salesforce/design-system-react

Version:

Salesforce Lightning Design System for React

1,543 lines (1,435 loc) 60.2 kB
/* eslint-disable max-lines */ /* Copyright (c) 2015-present, salesforce.com, inc. All rights reserved */ /* Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license */ /* eslint-disable max-lines */ import React from 'react'; import PropTypes from 'prop-types'; import assign from 'lodash.assign'; import find from 'lodash.find'; import reject from 'lodash.reject'; import isEqual from 'lodash.isequal'; import findIndex from 'lodash.findindex'; import isFunction from 'lodash.isfunction'; import classNames from 'classnames'; import Button from '../button'; import Dialog from '../utilities/dialog'; import InnerInput from '../../components/input/private/inner-input'; import InputIcon from '../icon/input-icon'; import Menu from './private/menu'; import Label from '../forms/private/label'; import Popover from '../popover'; import SelectedListBox from '../pill-container/private/selected-listbox'; import FieldLevelHelpTooltip from '../tooltip/private/field-level-help-tooltip'; import KEYS from '../../utilities/key-code'; import KeyBuffer from '../../utilities/key-buffer'; import keyLetterMenuItemSelect from '../../utilities/key-letter-menu-item-select'; import mapKeyEventCallbacks from '../../utilities/key-callbacks'; import menuItemSelectScroll from '../../utilities/menu-item-select-scroll'; import checkProps from './check-props'; import { COMBOBOX } from '../../utilities/constants'; import generateId from '../../utilities/generate-id'; import componentDoc from './component.json'; import { IconSettingsContext } from '../icon-settings'; let currentOpenDropdown; const documentDefined = typeof document !== 'undefined'; const propTypes = { /** * **Assistive text for accessibility** * This object is merged with the default props object on every render. * * `label`: This is used as a visually hidden label if, no `labels.label` is provided. * * `loading`: Text added to loading spinner. * * `optionSelectedInMenu`: Added before selected menu items in Read-only variants (Picklists). The default is `Current Selection:`. * * `popoverLabel`: Used by popover variant, assistive text for the Popover aria-label. * * `removeSingleSelectedOption`: Used by inline-listbox, single-select variant to remove the selected item (pill). This is a button with focus. The default is `Remove selected option`. * * `removePill`: Used by multiple selection Comboboxes to remove a selected item (pill). Focus is on the pill. This is not a button. The default is `, Press delete or backspace to remove`. * * `selectedListboxLabel`: This is a label for the selected listbox. The grouping of pills for multiple selection Comboboxes. The default is `Selected Options:`. * _Tested with snapshot testing._ */ assistiveText: PropTypes.shape({ label: PropTypes.string, loadingMenuItems: PropTypes.string, optionSelectedInMenu: PropTypes.string, popoverLabel: PropTypes.string, removeSingleSelectedOption: PropTypes.string, removePill: PropTypes.string, selectedListboxLabel: PropTypes.string, }), /** * The `aria-describedby` attribute is used to indicate the IDs of the elements that describe the object. It is used to establish a relationship between widgets or groups and text that described them. * This is very similar to aria-labelledby: a label describes the essence of an object, while a description provides more information that the user might need. _Tested with snapshot testing._ */ 'aria-describedby': PropTypes.string, /** * CSS classes to be added to tag with `.slds-combobox`. Uses `classNames` [API](https://github.com/JedWatson/classnames). _Tested with snapshot testing._ */ className: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * CSS classes to be added to top level tag with `.slds-form-element` and not on `.slds-combobox_container`. Uses `classNames` [API](https://github.com/JedWatson/classnames). _Tested with snapshot testing._ */ classNameContainer: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * CSS classes to be added to tag with `.slds-dropdown`. Uses `classNames` [API](https://github.com/JedWatson/classnames). Autocomplete/bass variant menu height should not scroll and should be determined by number items which should be no more than 10. _Tested with snapshot testing._ */ classNameMenu: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * CSS classes to be added to tag with `.slds-dropdown__header`. Uses `classNames` [API](https://github.com/JedWatson/classnames). */ classNameMenuSubHeader: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Event Callbacks * * `onBlur`: Called when `input` removes focus. * * `onChange`: Called when keyboard events occur within `input` * * `onClose`: Triggered when the menu has closed. * * `onFocus`: Called when `input` receives focus. * * `onOpen`: Triggered when the menu has opened. * * `onRequestClose`: Function called when the menu would like to hide. Please use with `isOpen`. * * `onRequestOpen`: Function called when the menu would like to show. Please use with `isOpen`. * * `onRequestRemoveSelectedOption`: Function called when a single selection option is to be removed. * * `onSelect`: Function called when a menu item is selected. This includes header and footer items. * * `onSubmit`: Function called when user presses enter or submits the `input` * _Tested with Mocha testing._ */ events: PropTypes.shape({ onBlur: PropTypes.func, onChange: PropTypes.func, onClose: PropTypes.func, onFocus: PropTypes.func, onOpen: PropTypes.func, onRequestClose: PropTypes.func, onRequestOpen: PropTypes.func, onRequestRemoveSelectedOption: PropTypes.func, onSelect: PropTypes.func, onSubmit: PropTypes.func, }), /** * Message to display when the input is in an error state. When this is present, also visually highlights the component as in error. _Tested with snapshot testing._ */ errorText: PropTypes.string, /** * A [Tooltip](https://react.lightningdesignsystem.com/components/tooltips/) component that is displayed next to the `labels.label`. The props from the component will be merged and override any default props. */ fieldLevelHelpTooltip: PropTypes.node, /** * If true, `{ label: 'None': value: '' }` will be selected. */ hasDeselect: PropTypes.bool, /** * If true, loading spinner appears inside input on right hand side. */ hasInputSpinner: PropTypes.bool, /** * Add loading spinner below the options */ hasMenuSpinner: PropTypes.bool, /** * By default, dialogs will flip their alignment (such as bottom to top) if they extend beyond a boundary element such as a scrolling parent or a window/viewpoint. `hasStaticAlignment` disables this behavior and allows this component to extend beyond boundary elements. _Not tested._ */ hasStaticAlignment: PropTypes.bool, /** * HTML id for component. _Tested with snapshot testing._ */ id: PropTypes.string, /** * An [Input](https://react.lightningdesignsystem.com/components/inputs) component. * The props from this component will override any default props. */ input: PropTypes.node, /** * **Text labels for internationalization** * This object is merged with the default props object on every render. * * `deselectOption`: This label appears first in the menu items of a read-only, Picklist-like Combobox. Selecting it, deselects any currently selected value. * * `label`: This label appears above the input. * * `cancelButton`: This label is only used by the dialog variant for the cancel button in the footer of the dialog. The default is `Cancel` * * `doneButton`: This label is only used by the dialog variant for the done button in the footer of the dialog. The default is `Done` * * `multipleOptionsSelected`: This label is used by the readonly variant when multiple options are selected. The default is `${props.selection.length} options selected`. This will override the entire string. * * `noOptionsFound`: Custom message that renders when no matches found. The default empty state is just text that says, 'No matches found.'. * * `placeholder`: Input placeholder * * `placeholderReadOnly`: Placeholder for Picklist-like Combobox * * `removePillTitle`: Title on `X` icon * _Tested with snapshot testing._ */ labels: PropTypes.shape({ deselectOption: PropTypes.string, label: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), multipleOptionsSelected: PropTypes.string, noOptionsFound: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), placeholder: PropTypes.string, placeholderReadOnly: PropTypes.string, removePillTitle: PropTypes.string, }), /** * Forces the dropdown to be open or closed. See controlled/uncontrolled callback/prop pattern for more on suggested use view [Concepts and Best Practices](https://github.com/salesforce-ux/design-system-react/blob/master/CONTRIBUTING.md#concepts-and-best-practices) _Tested with snapshot testing._ */ isOpen: PropTypes.bool, /** * Sets the dialog width to the width of one of the following: * * `target`: Sets the dialog width to the width of the target. (Menus attached to `input` typically follow this UX pattern), * * `menu`: Consider setting a `menuMaxWidth` if using this value. If not, width will be set to width of largest menu item. * * `none`: Does not set a width on the dialog. _Tested with snapshot testing._ */ inheritWidthOf: PropTypes.oneOf(['target', 'menu', 'none']), /** * Accepts a custom menu item rendering function that becomes a custom component. It should return a React node. The checkmark is still rendered in readonly variants. This function is passed the following props: * * `assistiveText`: Object, `assistiveText` prop that is passed into Combobox * * `option`: Object, option data for item being rendered that is passed into Combobox * * `selected`: Boolean, allows rendering of `assistiveText.optionSelectedInMenu` in Readonly Combobox * * _Tested with snapshot testing._ */ onRenderMenuItem: PropTypes.func, /** * This callback exposes the input reference / DOM node to parent components. */ inputRef: PropTypes.func, /** * Please select one of the following: * * `absolute` - (default) The dialog will use `position: absolute` and style attributes to position itself. This allows inverted placement or flipping of the dialog. * * `overflowBoundaryElement` - The dialog will overflow scrolling parents. Use on elements that are aligned to the left or right of their target and don't care about the target being within a scrolling parent. Typically this is a popover or tooltip. Dropdown menus can usually open up and down if no room exists. In order to achieve this a portal element will be created and attached to `body`. This element will render into that detached render tree. * * `relative` - No styling or portals will be used. Menus will be positioned relative to their triggers. This is a great choice for HTML snapshot testing. */ menuPosition: PropTypes.oneOf([ 'absolute', 'overflowBoundaryElement', 'relative', ]), /** * Sets a maximum width that the menu will be used if `inheritWidthOf` is set to `menu`. (Example: 500px) _Tested with snapshot testing._ * */ menuMaxWidth: PropTypes.string, /** * Allows multiple selections _Tested with mocha testing._ */ multiple: PropTypes.bool, /** * **Array of item objects in the dropdown menu.** * Each object can contain: * * `icon`: An `Icon` component. (not used in read-only variant) * * `id`: A unique identifier string. * * `label`: A primary string of text for a menu item or group separator. * * `subTitle`: A secondary string of text added for clarity. (optional) * * `title`: A string of text shown as the title of the selected item (optional) * * `type`: 'separator' is the only type currently used * * `disabled`: Set to true to disable this menu item. * * `tooltipContent`: Content that is displayed in tooltip when item is disabled * ``` * { * id: '2', * label: 'Salesforce.com, Inc.', * subTitle: 'Account • San Francisco', * title: 'Salesforce', * type: 'account', * disabled: true, * tooltipContent: "You don't have permission to select this item." * }, * ``` * Note: At the moment, Combobox does not support two consecutive separators. _Tested with snapshot testing._ */ options: PropTypes.arrayOf( PropTypes.PropTypes.shape({ id: PropTypes.string.isRequired, icon: PropTypes.node, label: PropTypes.string, subTitle: PropTypes.string, title: PropTypes.string, type: PropTypes.string, disabled: PropTypes.bool, tooltipContent: PropTypes.node, }) ), /** * Determines the height of the menu based on SLDS CSS classes. This is a `number`. The default for a `readonly` variant is `5`. */ menuItemVisibleLength: PropTypes.oneOf([5, 7, 10]), /** * Limits auto-complete input submission to one of the provided options. _Tested with mocha testing._ */ predefinedOptionsOnly: PropTypes.bool, /** * A `Popover` component. The props from this popover will be merged and override any default props. This also allows a Combobox's Popover dialog to be a controlled component. _Tested with snapshot testing._ */ popover: PropTypes.node, /** * Applies label styling for a required form element. _Tested with snapshot testing._ */ required: PropTypes.bool, /** * Accepts an array of item objects. For single selection, pass in an array of one object. For item object keys, see `options` prop. _Tested with snapshot testing._ */ selection: PropTypes.arrayOf( PropTypes.PropTypes.shape({ id: PropTypes.string.isRequired, icon: PropTypes.node, label: PropTypes.string, subTitle: PropTypes.string, type: PropTypes.string, }) ).isRequired, /** * This callback exposes the selected listbox reference / DOM node to parent components. */ selectedListboxRef: PropTypes.func, /** * Disables the input and prevents editing the contents. This only applies for single readonly and inline-listbox variants. */ singleInputDisabled: PropTypes.bool, /** * Accepts a tooltip that is displayed when hovering on disabled menu items. */ tooltipMenuItemDisabled: PropTypes.element, /** * Value of input. _This is a controlled component,_ so you will need to control the input value by passing the `value` from `onChange` to a parent component or state manager, and then pass it back into the componet with this prop. Please see examples for more clarification. _Tested with snapshot testing._ */ value: PropTypes.string, /** * Changes styles of the input and menu. Currently `entity` is not supported. * The options are: * * `base`: An autocomplete Combobox also allows a user to select an option from a list, but that list can be affected by what the user types into the input of the Combobox. The SLDS website used to call the autocomplete Combobox its `base` variant. * * `inline-listbox`: An Entity Autocomplete Combobox or Lookup, is used to search for and select Salesforce Entities. * * `popover`: A dialog Combobox is best used when a listbox, tree, grid, or tree-grid is not the best solution. This variant allows custom content. * * `readonly`: A readonly text input that allows a user to select an option from a pre-defined list of options. It does not allow free form user input, nor does it allow the user to modify the selected value. * * _Tested with snapshot testing._ */ /** * Default value of input. Provide uncontroled behaviour */ defaultValue: PropTypes.string, /** * **Array of item objects in the dropdown menu that is displayed below the list of `options`. `onSelect` fires when selected.** * Each object can contain: * * `id`: A unique identifier string. * * `icon`: An [Icon](/components/icons/) component to be displayed to the left of the menu item `label`. * * `label`: A primary string of text for a menu item or a function that receives `inputValue` as function parameter and returns text to be displayed in for a menu item. * ``` * { * id: '1', * icon: ( * <Icon * assistiveText={{ label: 'add' }} * category="utility" * size="x-small" * name="add" * /> * ), * label: 'New Entity' * } * ``` * _Tested with snapshot testing._ */ optionsAddItem: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, icon: PropTypes.node, label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), }) ), /** * **Array of item objects in the dropdown menu that is displayed above the list of `options`. `onSelect` fires when selected. ** * Each object can contain: * * `id`: A unique identifier string. * * `icon`: An [Icon](/components/icons/) component to be displayed to the left of the menu item `label`. * * `label`: A primary string of text for a menu item or a function that receives `inputValue` as function parameter and returns text to be displayed in for a menu item. * ``` * { * id: '1', * icon: ( * <Icon * assistiveText={{ label: 'Add in Accounts' }} * size="x-small" * category="utility" * name="search" * /> * ), * label: (searchTerm) => { * return `${searchTerm} in Accounts`; * }, * } * ``` * _Tested with snapshot testing._ */ optionsSearchEntity: PropTypes.arrayOf( PropTypes.shape({ id: PropTypes.string, icon: PropTypes.node, label: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), }) ), /** * Node of type [Combobox](/components/comboboxes/) for creating grouped comboboxes. */ entityCombobox: PropTypes.node, /** * Defines Combobox variant styling and functionality */ variant: PropTypes.oneOf(['base', 'inline-listbox', 'popover', 'readonly']), }; const defaultProps = { assistiveText: { loadingMenuItems: 'Loading', optionSelectedInMenu: 'Current Selection:', removeSingleSelectedOption: 'Remove selected option', removePill: ', Press delete or backspace to remove', selectedListboxLabel: 'Selected Options:', }, deselectOption: false, events: {}, labels: { deselectOption: 'None', cancelButton: 'Cancel', doneButton: `Done`, noOptionsFound: 'No matches found.', optionDisabledTooltipLabel: 'This option is disabled.', placeholderReadOnly: 'Select an Option', removePillTitle: 'Remove', }, inheritWidthOf: 'target', menuPosition: 'absolute', optionsSearchEntity: [], optionsAddItem: [], required: false, selection: [], singleInputDisabled: false, variant: 'base', }; /** * A widget that provides a user with an input field that is either an autocomplete or readonly, accompanied with a listbox of pre-definfined options. */ class Combobox extends React.Component { constructor(props) { super(props); this.state = { activeOption: undefined, activeOptionIndex: -1, // seeding initial state with this.props.selection[0] activeSelectedOption: (this.props.selection && this.props.selection[0]) || undefined, activeSelectedOptionIndex: 0, listboxHasFocus: false, isOpen: typeof props.isOpen === 'boolean' ? props.isOpen : false, }; this.menuKeyBuffer = new KeyBuffer(); this.menuRef = undefined; this.selectedListboxRef = null; // `checkProps` issues warnings to developers about properties (similar to React's built in development tools) checkProps(COMBOBOX, props, componentDoc); this.generatedId = generateId(); this.generatedErrorId = generateId(); this.deselectId = `${this.getId()}-deselect`; } /** * Lifecycle methods */ componentDidUpdate(nextProps) { // This logic will maintain the active highlight even when the // option order changes. One example would be the server pushes // data out as the user has the menu open. This logic clears // `activeOption` if the active option is no longer in the options // list. If it's in the options list, then find the new index and // set `activeOptionIndex` if (!isEqual(this.getOptions(), this.getOptions(nextProps))) { const index = findIndex(this.getOptions(nextProps), (item) => isEqual(item, this.state.activeOption) ); if (index !== -1) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ activeOptionIndex: index }); } else { // eslint-disable-next-line react/no-did-update-set-state this.setState({ activeOption: undefined, activeOptionIndex: -1 }); } } else if (this.props.isOpen !== nextProps.isOpen) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ activeOption: undefined, activeOptionIndex: -1, isOpen: nextProps.isOpen, }); } // there may be issues with tabindex/focus if the app removes an item // from selection while the user is using the listbox const selectedOptionsRenderIsInitialRender = this.props.selection && this.props.selection.length === 0 && nextProps.selection.length > 0; if (selectedOptionsRenderIsInitialRender) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ activeSelectedOption: nextProps.selection[0], activeSelectedOptionIndex: 0, }); } } componentWillUnmount() { if (currentOpenDropdown === this) { currentOpenDropdown = undefined; } } getCustomPopoverProps = (body, { assistiveText, labels }) => { /* * Generate the popover props based on passed in popover props. Using the default behavior if not provided by passed in popover */ const popoverBody = ( <div> <div className="slds-assistive-text" id={`${this.getId()}-label`}> {assistiveText.popoverLabel} </div> {body} </div> ); const popoverFooter = ( <div> <Button label={labels.cancelButton} onClick={(e) => { this.handleClose(e, { trigger: 'cancel' }); }} /> <Button label={labels.doneButton} variant="brand" onClick={this.handleClose} /> </div> ); const defaultPopoverProps = { ariaLabelledby: `${this.getId()}-label`, align: 'bottom', body: popoverBody, className: 'slds-popover_full-width', footer: popoverFooter, footerClassName: 'slds-popover__footer_form', hasNoNubbin: true, id: this.getId(), isOpen: this.state.isOpen, hasNoTriggerStyles: true, onOpen: this.handleOpen, onClose: this.handleClose, onRequestClose: this.handleClose, }; /* Merge in passed popover's props if there is any to override the default popover props */ const popoverProps = assign( defaultPopoverProps, this.props.popover ? this.props.popover.props : {} ); popoverProps.body = popoverBody; // eslint-disable-next-line fp/no-delete delete popoverProps.children; return popoverProps; }; getDialog({ menuRenderer }) { // FOR BACKWARDS COMPATIBILITY const menuPosition = this.props.isInline ? 'relative' : this.props.menuPosition; // eslint-disable-line react/prop-types return !this.props.disabled && this.getIsOpen() ? ( <Dialog align="bottom left" context={this.context} hasStaticAlignment={this.props.hasStaticAlignment} inheritWidthOf={this.props.inheritWidthOf} onClose={this.handleClose} onMouseDown={(event) => { // prevent onBlur event.preventDefault(); }} onOpen={this.handleOpen} onRequestTargetElement={this.getTargetElement} position={menuPosition} containerProps={{ id: `${this.getId()}-listbox`, role: 'listbox', }} > {menuRenderer} </Dialog> ) : null; } getErrorId() { return ( this.props['aria-describedby'] || (this.props.errorText && this.generatedErrorId) ); } /** * Shared class property getter methods */ getId = () => this.props.id || this.generatedId; getIsActiveOption = () => this.state.activeOption && this.state.activeOptionIndex !== -1; getIsOpen = () => !!(typeof this.props.isOpen === 'boolean' ? this.props.isOpen : this.state.isOpen); getNewActiveOptionIndex = ({ activeOptionIndex, offset, options }) => { // used by menu listbox and selected options listbox const nextIndex = activeOptionIndex + offset; const skipIndex = options.length > nextIndex && nextIndex >= 0 && options[nextIndex].type === 'separator'; const newIndex = skipIndex ? nextIndex + offset : nextIndex; const hasNewIndex = options.length > nextIndex && nextIndex >= 0; return hasNewIndex ? newIndex : activeOptionIndex; }; getOptions = (props = this.props) => { const localProps = props; const labels = assign({}, defaultProps.labels, this.props.labels); const deselectOption = { id: this.deselectId, label: labels.deselectOption, value: '', type: 'deselect', }; const localOptionsSearchEntity = localProps.optionsSearchEntity.map( (entity) => ({ ...entity, type: 'header' }) ); const localOptionsAddItem = props.optionsAddItem.map((entity) => ({ ...entity, type: 'footer', })); const options = [ ...(localOptionsSearchEntity.length > 0 ? localOptionsSearchEntity : []), ...(props.hasDeselect ? [deselectOption] : []), ...(localProps.options && localProps.options.length > 0 ? localProps.options : []), ...(localOptionsAddItem.length > 0 ? localOptionsAddItem : []), ]; return options; }; getTargetElement = () => this.inputRef; setInputRef = (component) => { this.inputRef = component; // yes, this is a render triggered by a render. // Dialog/Popper.js cannot place the menu until // the trigger/target DOM node is mounted. This // way `findDOMNode` is not called and parent // DOM nodes are not queried. if (!this.state.inputRendered) { this.setState({ inputRendered: true }); } if (this.props.inputRef) { this.props.inputRef(component); } }; setSelectedListboxRef = (ref) => { this.selectedListboxRef = ref; if (this.props.selectedListboxRef) { this.props.selectedListboxRef(ref); } }; handleBlurPill = () => { this.setState({ listboxHasFocus: false }); }; /** * Menu open/close and sub-render methods */ handleClickOutside = (event) => { this.handleRequestClose(event, {}); }; handleClose = (event, { trigger } = {}) => { const isOpen = this.getIsOpen(); if (isOpen) { if (currentOpenDropdown === this) { currentOpenDropdown = undefined; } this.setState({ activeOption: undefined, activeOptionIndex: -1, isOpen: false, }); if (this.props.variant === 'popover' && trigger === 'cancel') { if (this.props.popover.props.onClose) { this.props.popover.props.onClose(event, { trigger }); } } if (this.props.events.onClose) { this.props.events.onClose(event, {}); } } }; handleInputBlur = (event) => { // If menu is open when the input's onBlur event fires, it will close before the onClick of the menu item can fire. setTimeout(() => { const activeElement = documentDefined ? document.activeElement : false; // detect if the scrollbar of the combobox-autocomplete/lookup menu is clicked in IE11. If it is, return focus to input, and do not close menu. if ( activeElement && activeElement.tagName === 'DIV' && activeElement.id === `${this.getId()}-listbox` ) { if (this.inputRef) { this.inputRef.focus(); } } else if (!this.props.popover) { this.handleClose(event); } }, 200); if (this.props.events.onBlur) { this.props.events.onBlur(event); } }; handleInputChange = (event) => { this.requestOpenMenu(); if (this.props.events && this.props.events.onChange) { this.props.events.onChange(event, { value: event.target.value }); } }; handleInputFocus = (event) => { if (this.props.events.onFocus) { this.props.events.onFocus(event, {}); } }; handleInputSubmit = (event) => { if ( this.state.activeOption === undefined && this.state.activeOptionIndex === -1 ) { if (this.state.isOpen === false) { if (!event.shiftKey) { this.openDialog(); } } else this.handleRequestClose(event, {}); } if (this.state.activeOption && this.state.activeOption.disabled) { return; } if ( this.state.activeOption && (this.state.activeOption.type === 'header' || this.state.activeOption.type === 'footer') ) { this.state.activeOption.onClick(event); return; } // use menu options if (this.getIsActiveOption()) { this.handleSelect(event, { option: this.state.activeOption, selection: this.props.selection, }); // use input value, if not limited to predefined options (in the menu) } else if ( !this.props.predefinedOptionsOnly && event.target.value !== '' && this.props.events.onSubmit ) { this.props.events.onSubmit(event, { value: event.target.value, }); } }; /** * Input and menu keyboard event methods */ handleKeyDown = (event) => { const callbacks = { [KEYS.DOWN]: { callback: this.handleKeyDownDown }, [KEYS.ENTER]: { callback: this.handleInputSubmit }, [KEYS.ESCAPE]: { callback: this.handleClose }, [KEYS.UP]: { callback: this.handleKeyDownUp }, }; if (this.props.variant === 'readonly') { if (this.props.selection.length > 2) { callbacks[KEYS.TAB] = { callback: this.handleKeyDownTab }; } else { callbacks[KEYS.TAB] = undefined; } callbacks.other = { callback: this.handleKeyDownOther, stopPropagation: false, }; } // Propagate events when menu is closed const stopPropagation = this.getIsOpen(); // Helper function that takes an object literal of callbacks that are triggered with a key event mapKeyEventCallbacks(event, { callbacks, stopPropagation }); }; handleKeyDownDown = (event) => { // Don't open if user is selecting text if (!event.shiftKey) { this.openDialog(); } if (this.props.variant !== 'popover') { this.handleNavigateListboxMenu(event, { direction: 'next' }); } }; handleKeyDownTab = () => { if (this.selectedListboxRef) { this.setState({ listboxHasFocus: true, }); } }; handleKeyDownUp = (event) => { // Don't open if user is selecting text if (!event.shiftKey && this.getIsOpen()) { this.handleNavigateListboxMenu(event, { direction: 'previous' }); } }; handleKeyDownOther = (event) => { const activeOptionIndex = keyLetterMenuItemSelect({ key: event.key, keyBuffer: this.menuKeyBuffer, keyCode: event.keyCode, options: this.getOptions(), }); if (activeOptionIndex !== undefined) { if (this.getIsOpen()) { menuItemSelectScroll({ container: this.menuRef, focusedIndex: activeOptionIndex, }); } this.setState({ activeOption: this.getOptions()[activeOptionIndex], activeOptionIndex, }); } }; handleNavigateListboxMenu = (event, { direction }) => { const offsets = { next: 1, previous: -1 }; // takes current/previous state and returns an object with the new state this.setState((prevState) => { const newIndex = this.getNewActiveOptionIndex({ activeOptionIndex: prevState.activeOptionIndex, offset: offsets[direction], options: this.getOptions(), }); // eslint-disable-next-line react/no-access-state-in-setstate if (this.getIsOpen()) { menuItemSelectScroll({ container: this.menuRef, focusedIndex: newIndex, }); } return { activeOption: this.getOptions()[newIndex], activeOptionIndex: newIndex, }; }); }; handleNavigateSelectedListbox = (event, { direction }) => { const offsets = { next: 1, previous: -1 }; this.setState((prevState) => { const isLastOptionAndRightIsPressed = prevState.activeSelectedOptionIndex + 1 === this.props.selection.length && direction === 'next'; const isFirstOptionAndLeftIsPressed = prevState.activeSelectedOptionIndex === 0 && direction === 'previous'; let newState; if (isLastOptionAndRightIsPressed) { newState = { activeSelectedOption: this.props.selection[0], activeSelectedOptionIndex: 0, listboxHasFocus: true, }; } else if (isFirstOptionAndLeftIsPressed) { newState = { activeSelectedOption: this.props.selection[ this.props.selection.length - 1 ], activeSelectedOptionIndex: this.props.selection.length - 1, listboxHasFocus: true, }; } else { const newIndex = this.getNewActiveOptionIndex({ activeOptionIndex: prevState.activeSelectedOptionIndex, offset: offsets[direction], options: this.props.selection, }); newState = { activeSelectedOption: this.props.selection[newIndex], activeSelectedOptionIndex: newIndex, listboxHasFocus: true, }; } return newState; }); }; handleOpen = (event, data) => { const isOpen = this.getIsOpen(); if (!isOpen) { if (currentOpenDropdown && isFunction(currentOpenDropdown.handleClose)) { currentOpenDropdown.handleClose(); } } else { currentOpenDropdown = this; this.setState({ isOpen: true, }); if (this.props.events.onOpen) { this.props.events.onOpen(event, data); } if (this.props.variant === 'readonly') { const activeOptionIndex = findIndex(this.getOptions(), (item) => isEqual(item, this.props.selection[0]) ); this.setState({ activeOptionIndex, activeOption: this.props.selection[0], }); if (this.menuRef !== null) { menuItemSelectScroll({ container: this.menuRef, focusedIndex: activeOptionIndex, }); } } } }; handlePillFocus = (event, { option, index }) => { if (!this.state.listboxHasFocus) { this.setState({ activeSelectedOption: option, activeSelectedOptionIndex: index, listboxHasFocus: true, }); } }; /** * Selected options with selected listbox event methods */ handleRemoveSelectedOption = (event, { option, index }) => { event.preventDefault(); const onlyOnePillAndInputExists = this.props.selection.length === 1; const isReadOnlyAndTwoPillsExists = this.props.selection.length === 2 && this.props.variant === 'readonly' && this.props.multiple; const lastPillWasRemoved = index + 1 === this.props.selection.length; if ( (onlyOnePillAndInputExists || isReadOnlyAndTwoPillsExists) && this.inputRef ) { this.inputRef.focus(); } else if (lastPillWasRemoved) { // set focus to previous option and index this.setState({ activeSelectedOption: this.props.selection[index - 1], activeSelectedOptionIndex: index - 1, listboxHasFocus: true, }); } else { // set focus to next option, but same index this.setState({ activeSelectedOption: this.props.selection[index + 1], activeSelectedOptionIndex: index, listboxHasFocus: true, }); } if (this.props.events.onRequestRemoveSelectedOption) { this.props.events.onRequestRemoveSelectedOption(event, { selection: reject(this.props.selection, option), }); } }; handleRequestClose = (event, data) => { if (this.props.events.onRequestClose) { this.props.events.onRequestClose(event, data); } if (this.getIsOpen()) { this.handleClose(event, { trigger: 'cancel' }); } }; handleRequestFocusSelectedListbox = (event, { ref }) => { if (ref) { this.activeSelectedOptionRef = ref; this.activeSelectedOptionRef.focus(); } }; handleSelect = (event, { selection, option }) => { let newSelection; const isSelected = this.isSelected({ selection, option }); const singleSelectAndSelectedWasNotClicked = !this.props.multiple && !isSelected; const multiSelectAndSelectedWasNotClicked = this.props.multiple && !isSelected; const deselectWasClicked = option.id === this.deselectId; if (deselectWasClicked) { newSelection = []; } else if (singleSelectAndSelectedWasNotClicked) { newSelection = [option]; } else if (multiSelectAndSelectedWasNotClicked) { newSelection = [...this.props.selection, option]; } else { newSelection = reject(this.props.selection, option); } if (this.props.events.onSelect) { this.props.events.onSelect(event, { selection: newSelection }); } this.handleClose(); // if (this.inputRef) { // this.inputRef.focus(); // } }; isSelected = ({ selection, option }) => !!find(selection, option); openDialog = () => { if (this.props.events.onRequestOpen) { this.props.events.onRequestOpen(); } else { this.setState({ isOpen: true, }); } }; requestOpenMenu = () => { const isInlineSingleSelectionAndIsNotSelected = !this.props.multiple && this.props.selection.length === 0 && this.props.variant === 'inline-listbox'; if ( isInlineSingleSelectionAndIsNotSelected || this.props.multiple || this.props.variant === 'readonly' ) { this.openDialog(); } }; /** * Combobox variant subrenders * (these can probably be broken into function components * if state is passed in as a prop) */ renderBase = ({ assistiveText, labels, props, userDefinedProps }) => ( <div className="slds-form-element__control"> <div className="slds-combobox_container"> <div className={classNames( 'slds-combobox', 'slds-dropdown-trigger', 'slds-dropdown-trigger_click', 'ignore-react-onclickoutside', { 'slds-is-open': this.getIsOpen(), }, { 'slds-has-error': props.errorText, }, props.className )} // Not in ARIA 1.2 spec, temporary for SLDS styles role="combobox" // eslint-disable-line jsx-a11y/role-supports-aria-props, jsx-a11y/role-has-required-aria-props aria-expanded={this.getIsOpen()} aria-haspopup="listbox" // eslint-disable-line jsx-a11y/aria-proptypes // used on menu's listbox aria-owns={this.getIsOpen() ? `${this.getId()}-listbox` : undefined} // eslint-disable-line jsx-a11y/aria-proptypes > <InnerInput aria-autocomplete="list" aria-controls={ this.getIsOpen() ? `${this.getId()}-listbox` : undefined } aria-activedescendant={ this.state.activeOption ? `${this.getId()}-listbox-option-${this.state.activeOption.id}` : null } aria-describedby={this.getErrorId()} autoComplete="off" className="slds-combobox__input" containerProps={{ className: 'slds-combobox__form-element', role: 'none', }} hasSpinner={this.props.hasInputSpinner} iconRight={ <InputIcon category="utility" name="search" title={labels.inputIconTitle} /> } id={this.getId()} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} onKeyDown={this.handleKeyDown} inputRef={this.setInputRef} onClick={() => { this.openDialog(); }} onChange={this.handleInputChange} placeholder={labels.placeholder} defaultValue={props.defaultValue} readOnly={ !!(props.predefinedOptionsOnly && this.state.activeOption) } required={props.required} role="textbox" value={ props.predefinedOptionsOnly ? (this.state.activeOption && this.state.activeOption.label) || props.value : props.value } {...userDefinedProps.input} /> {this.getDialog({ menuRenderer: this.renderMenu({ assistiveText, labels }), })} </div> </div> <SelectedListBox activeOption={this.state.activeSelectedOption} activeOptionIndex={this.state.activeSelectedOptionIndex} assistiveText={assistiveText} events={{ onBlurPill: this.handleBlurPill, onPillFocus: this.handlePillFocus, onRequestFocus: this.handleRequestFocusSelectedListbox, onRequestFocusOnNextPill: this.handleNavigateSelectedListbox, onRequestFocusOnPreviousPill: this.handleNavigateSelectedListbox, onRequestRemove: this.handleRemoveSelectedOption, }} id={`${this.getId()}-selected-listbox`} labels={labels} selectedListboxRef={this.setSelectedListboxRef} selection={props.selection} listboxHasFocus={this.state.listboxHasFocus} /> {props.errorText && ( <div className="slds-has-error"> <div id={this.getErrorId()} className="slds-form-element__help slds-has-error" > {props.errorText} </div> </div> )} </div> ); renderInlineMultiple = ({ assistiveText, labels, props, userDefinedProps, }) => ( <div className="slds-form-element__control"> <div className={classNames('slds-combobox_container', { 'slds-has-inline-listbox': props.selection.length, })} > {props.selection.length ? ( <SelectedListBox activeOption={this.state.activeSelectedOption} activeOptionIndex={this.state.activeSelectedOptionIndex} assistiveText={assistiveText} containerRole="listbox" containerAriaOrientation="horizontal" listboxRole="group" listboxAriaOrientation={null} events={{ onBlurPill: this.handleBlurPill, onPillFocus: this.handlePillFocus, onRequestFocus: this.handleRequestFocusSelectedListbox, onRequestFocusOnNextPill: this.handleNavigateSelectedListbox, onRequestFocusOnPreviousPill: this.handleNavigateSelectedListbox, onRequestRemove: this.handleRemoveSelectedOption, }} id={`${this.getId()}-selected-listbox`} labels={labels} selectedListboxRef={this.setSelectedListboxRef} selection={props.selection} listboxHasFocus={this.state.listboxHasFocus} /> ) : null} <div className={classNames( 'slds-combobox', 'slds-dropdown-trigger', 'slds-dropdown-trigger_click', 'ignore-react-onclickoutside', { 'slds-is-open': this.getIsOpen(), }, { 'slds-has-error': props.errorText, }, props.className )} // Not in ARIA 1.2 spec, temporary for SLDS styles role="combobox" // eslint-disable-line jsx-a11y/role-supports-aria-props, jsx-a11y/role-has-required-aria-props aria-expanded={this.getIsOpen()} aria-haspopup="listbox" // eslint-disable-line jsx-a11y/aria-proptypes > <InnerInput aria-autocomplete="list" aria-controls={ this.getIsOpen() ? `${this.getId()}-listbox` : undefined } aria-activedescendant={ this.state.activeOption ? `${this.getId()}-listbox-option-${this.state.activeOption.id}` : null } aria-describedby={this.getErrorId()} defaultValue={props.defaultValue} autoComplete="off" className="slds-combobox__input" containerProps={{ className: 'slds-combobox__form-element', role: 'none', }} hasSpinner={this.props.hasInputSpinner} iconRight={ <InputIcon category="utility" name="search" title={labels.inputIconTitle} /> } id={this.getId()} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} onKeyDown={this.handleKeyDown} inputRef={this.setInputRef} onClick={() => { this.openDialog(); }} onChange={this.handleInputChange} placeholder={labels.placeholder} readOnly={ !!(props.predefinedOptionsOnly && this.state.activeOption) } required={props.required} role="textbox" value={ props.predefinedOptionsOnly ? (this.state.activeOption && this.state.activeOption.label) || props.value : props.value } {...userDefinedProps.input} /> {this.getDialog({ menuRenderer: this.renderMenu({ assistiveText, labels }), })} {props.errorText && ( <div id={this.getErrorId()} className="slds-form-element__help"> {props.errorText} </div> )} </div> </div> </div> ); renderInlineSingle = ({ assistiveText, labels, props, userDefinedProps }) => { const iconLeft = props.selection[0] && props.selection[0].icon ? React.cloneElement(props.selection[0].icon, { containerClassName: 'slds-combobox__input-entity-icon', }) : null; const value = props.selection[0] && props.selection[0].label ? props.selection[0].label : props.value; return ( <div className="slds-form-element__control"> <div className={classNames('slds-combobox_container', { 'slds-has-inline-listbox': props.selection.length, })} > <div className={classNames( 'slds-combobox', 'slds-dropdown-trigger', 'slds-dropdown-trigger_click', 'ignore-react-onclickoutside', { 'slds-is-open': this.getIsOpen(), }, { 'slds-has-error': props.errorText, }, props.className )} // Not in ARIA 1.2 spec, temporary for SLDS styles role="combobox" // eslint-disable-line jsx-a11y/role-supports-aria-props, jsx-a11y/role-has-required-aria-props aria-expanded={this.getIsOpen()} aria-haspopup="listbox" // eslint-disable-line jsx-a11y/aria-proptypes > <InnerInput defaultValue={props.defaultValue} aria-autocomplete="list" aria-controls={ this.getIsOpen() ? `${this.getId()}-listbox` : undefined } aria-activedescendant={ this.state.activeOption ? `${this.getId()}-listbox-option-${ this.state.activeOption.id }` : null } aria-describedby={this.getErrorId()} autoComplete="off" className="slds-combobox__input" containerProps={{ className: 'slds-combobox__form-element', role: 'none', }} disabled={this.props.singleInputDisabled} hasSpinner={this.props.hasInputSpinner} iconRight={ props.selection.length ? ( <InputIcon assistiveText={{ icon: assistiveText.removeSingleSelectedOption, }} buttonRef={(component) => { this.buttonRef = component; }} category="utility" iconPosition="right" name="close" onClick={(event) => { this.handleRemoveSelectedOption(event, { option: props.selection[0], }); }} /> ) : ( <InputIcon category="utility" name="search" /> ) } iconLeft={iconLeft} id={this.getId()} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} onKeyDown={this.handleKeyDown} inputRef={this.setInputRef} onClick={() => { this.requestOpenMenu(); }} onChange={(event) => { if (!props.selection.length) { this.handleInputChange(event); } }} placeholder={labels.placeholder} readOnly={ !!(props.predefinedOptionsOnly && this.state.activeOption) || !!props.selection.length } required={props.required} role="textbox" value={ props.predefinedOptionsOnly ? (this.state.activeOption && this.state.activeOption.label) || props.value : value } {...userDefinedProps.input} /> {this.getDialog({ menuRenderer: this.renderMenu({ assistiveText, labels }), })} </div> </div> {props.errorText && ( <div className="slds-has-error"> <div id={this.getErrorId()} className="slds-form-element__help"> {props.errorText} </div> </div> )} </div> ); }; renderMenu = ({ assistiveText, labels }) => { const menuVariant = { base: 'icon-title-subtitle', 'inline-listbox': 'icon-title-subtitle', readonly: 'checkbox', }; const readonlyItemVisibleLength = this.props.variant === 'readonly' ? 5 : null; return ( <Menu assistiveText={assistiveText} activeOption={this.state.activeOption} activeOptionIndex={this.state.activeOptionIndex} classNameMenu={this.props.classNameMenu} classNameMenuSubHeader={this.props.classNameMenuSubHeader} clearActiveOption={this.clearActiveOption} deselectId={this.deselectId} inheritWidthOf={this.props.inheritWidthOf} inputId={this.getId()} inputValue={this.props.value} isSelected={this.isSelected} itemVisibleLength={ this.props.menuItemVisibleLength || readonlyItemVisibleLength } labels={labels} hasMenuSpinner={this.props.hasMenuSpinner} hasDeselect={this.props.hasDeselect} menuItem={this.props.menuItem} menuPosition={this.props.menuPosition} menuRef={(ref) => { this.menuRef = ref; }} maxWidth={this.props.menuMaxWidth} options={this.getOptions()} optionsAddItem={this.props.optionsAddItem} optionsSearchEntity={this.props.optionsSearchEntity} onSelect={this.handleSelect} // For backward compatibility, 'menuItem' prop will be deprecated soon onRenderMenuItem={ this.props.onRenderMenuItem ? this.props.onRenderMenuItem : this.props.menuItem } selection={this.props.selection} tooltipMenuItemDisabled={this.props.tooltipMenuItemDisabled} variant={menuVariant[this.props.variant]} /> ); }; renderPopover = ({ assistiveText, labels, props }) => { const popoverProps = this.getCustomPopoverProps( this.props.popover.props.body, { assistiveText, labels } ); return ( <div className="slds-form-element__control"> <div className="slds-combobox_container"> <div className={classNames( 'slds-combobox', 'slds-dropdown-trigger', 'slds-dropdown-trigger_click', 'ignore-react-onclickoutside', { 'slds-is-open': this.getIsOpen(), }, { 'slds-has-error': props.errorText, }, props.className )} // Not in ARIA 1.2 spec, temporary for SLDS styles role="combobox" // eslint-disable-line jsx-a11y/role-supports-aria-props, jsx-a11y/role-has-required-aria-props aria-expanded={this.getIsOpen()} aria-haspopup="dialog" // eslint-disable-line jsx-a11y/ar