UNPKG

@salesforce/design-system-react

Version:

Salesforce Lightning Design System for React

1,107 lines (976 loc) 33.5 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 */ // Implements the [Dropdown design pattern](https://www.lightningdesignsystem.com/components/menus/#flavor-dropdown) in React. Child elements that do not have the display name of the value of `MENU_DROPDOWN_TRIGGER` in `components/constants.js` will be considered custom content and rendered in the popover. import React from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import requiredIf from 'react-required-if'; import classNames from 'classnames'; // ### isFunction import isFunction from 'lodash.isfunction'; import isEqual from 'lodash.isequal'; // ### Children import Dialog from '../utilities/dialog'; import List from '../utilities/menu-list'; import ListItem from '../utilities/menu-list/item'; import ListItemLabel from '../utilities/menu-list/item-label'; // This is the the default Dropdown Trigger, which expects one button as a child. import DefaultTrigger from './button-trigger'; // This component's `checkProps` which issues warnings to developers about properties // when in development mode (similar to React's built in development tools) import checkProps from './check-props'; import componentDoc from './component.json'; import EventUtil from '../../utilities/event'; import generateId from '../../utilities/generate-id'; import keyboardNavigate from '../../utilities/keyboard-navigate'; import KeyBuffer from '../../utilities/key-buffer'; import KEYS from '../../utilities/key-code'; import { MENU_DROPDOWN, MENU_DROPDOWN_TRIGGER, LIST, } from '../../utilities/constants'; import { IconSettingsContext } from '../icon-settings'; const documentDefined = typeof document !== 'undefined'; // The overlay is an optional way to allow the dropdown to close on outside // clicks even when those clicks are over areas that wouldn't normally fire // click or touch events (for example, iframes). A single overlay is shared // between all dropdowns in the app. const overlay = documentDefined ? document.createElement('span') : { style: {} }; overlay.style.top = 0; overlay.style.left = 0; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.position = 'absolute'; let currentOpenDropdown; const DropdownNubbinPositions = [ 'top left', 'top', 'top right', 'bottom left', 'bottom', 'bottom right', ]; // # Keyboard Navigable mixin const noop = () => {}; const itemIsSelectable = (item) => item.type !== 'header' && item.type !== 'divider' && !item.disabled; const getNavigableItems = (items) => { const navigableItems = []; navigableItems.indexes = []; navigableItems.keyBuffer = new KeyBuffer(); if (Array.isArray(items)) { items.forEach((item, index) => { if (itemIsSelectable(item)) { // eslint-disable-next-line fp/no-mutating-methods navigableItems.push({ index, text: `${item.label}`.toLowerCase(), }); // eslint-disable-next-line fp/no-mutating-methods navigableItems.indexes.push(index); } }); } return navigableItems; }; function getMenu(componentRef) { return ReactDOM.findDOMNode(componentRef).querySelector('ul.dropdown__list'); // eslint-disable-line react/no-find-dom-node } function getMenuItem(menuItemId, context = document) { let menuItem; if (menuItemId) { menuItem = context.getElementById(menuItemId); } return menuItem; } /* * Dropdowns with nubbins have a different API from other Dialogs * * Dialog receives an alignment position and whether it has a nubbin. The nubbin position is inferred from the align. * Dropdowns have a nubbinPosition which dictates the align, but in an inverse fashion which then gets inversed back by the Dialog. * * Since Dialog is the future API and we don't want to break backwards compatability, we currently map to the Dialog api here. Even if Dialog will map it again. * TODO - deprecate nubbinPosition in favor for additional `align` values and a flag to show a nubbin. */ const DropdownToDialogNubbinMapping = { top: 'bottom', 'top left': 'bottom left', 'top right': 'bottom right', bottom: 'top', 'bottom left': 'top left', 'bottom right': 'top right', }; const propTypes = { /** * Aligns the menu center, right, or left respective to the trigger. This is not intended for use with `nubbinPosition`. */ align: PropTypes.oneOf(['center', 'left', 'right']), /** * This prop is passed onto the triggering `Button`. Text that is visually hidden but read aloud by screenreaders to tell the user what the icon means. You can omit this prop if you are using the `label` prop. */ assistiveText: PropTypes.object, /** * CSS classes to be added to triggering button. */ buttonClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * If true, button/icon is white. Meant for buttons or utility icons on dark backgrounds. */ buttonInverse: PropTypes.bool, /** * This prop is passed onto the triggering `Button`. Determines variant of the Button component that triggers dropdown. */ buttonVariant: PropTypes.oneOf([ 'base', 'neutral', 'brand', 'destructive', 'icon', ]), /** * If true, renders checkmark icon on the selected Menu Item. */ checkmark: PropTypes.bool, /** * If you need custom content _and_ a list, use a `<Popover>` instead. */ children: PropTypes.node, /** * CSS classes to be added to dropdown menu. */ className: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * By default, these class names will be added to the absolutely-positioned `Dialog` component. */ containerClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * This prop is passed onto the triggering `Button`. Prevent dropdown menu from opening. Also applies disabled styling to trigger button. */ disabled: PropTypes.bool, /** * Prevents the dropdown from changing position based on the viewport/window. If set to true your dropdowns can extend outside the viewport _and_ overflow outside of a scrolling parent. If this happens, you might want to consider making the dropdowns contents scrollable to fit the menu on the screen. `hasStaticAlignment` disables this behavior and allows this component to extend beyond boundary elements. _Not tested._ */ hasStaticAlignment: PropTypes.bool, /** * This prop is passed onto the triggering `Button`. Associates an icon button with another element on the page by changes the color of the SVG. Please reference <a href="http://www.lightningdesignsystem.com/components/buttons/#hint">Lightning Design System Buttons > Hint</a>. */ hint: PropTypes.bool, /** * Delay on menu closing in milliseconds. */ hoverCloseDelay: PropTypes.number, /** * Name of the icon category. Visit <a href="http://www.lightningdesignsystem.com/resources/icons">Lightning Design System Icons</a> to reference icon categories. */ iconCategory: requiredIf( PropTypes.oneOf(['action', 'custom', 'doctype', 'standard', 'utility']), (props) => !!props.iconName ), /** * Name of the icon. Visit <a href="http://www.lightningdesignsystem.com/resources/icons">Lightning Design System Icons</a> to reference icon names. */ iconName: PropTypes.string, /** * If omitted, icon position is centered. */ iconPosition: PropTypes.oneOf(['left', 'right']), /** * For icon variants, please reference <a href="http://www.lightningdesignsystem.com/components/buttons/#icon">Lightning Design System Icons</a>. */ iconVariant: PropTypes.oneOf([ 'bare', 'container', 'border', 'border-filled', 'small', 'more', ]), /** * Determines the size of the icon. */ iconSize: PropTypes.oneOf(['x-small', 'small', 'medium', 'large']), /** * A unique ID is needed in order to support keyboard navigation, ARIA support, and connect the dropdown to the triggering button. */ id: PropTypes.string, /** * Adds inverse class to the dropdown */ inverse: PropTypes.bool, /** * 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) */ isOpen: PropTypes.bool, /** * This prop is passed onto the triggering `Button`. Text within the trigger button. */ label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), /** * Custom element that overrides the default Menu Item component. */ listItemRenderer: PropTypes.func, /** * This prop is passed into the List for the menu. Pass null to make it the size of the content, or a string with an integer from here: https://www.lightningdesignsystem.com/components/menus/#flavor-dropdown-height */ length: PropTypes.oneOf([null, '5', '7', '10', 5, 7, 10]), /** * 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', ]), /** * Style applied to menu element (that is the `.slds-dropdown` element) */ menuStyle: PropTypes.object, /** * Positions dropdown menu with a nubbin--that is the arrow notch. The placement options correspond to the placement of the nubbin. This is implemeted with CSS classes and is best used with a `Button` with "icon container" styling (`iconVariant="container"`). Use with `isInline` prop, since positioning is determined by CSS via absolute-relative positioning, and using an absolutely positioned menu will not position the menu correctly without manual offsets. */ nubbinPosition: PropTypes.oneOf([ 'top left', 'top', 'top right', 'bottom left', 'bottom', 'bottom right', ]), /** * Called when the triggering button loses focus. */ onBlur: PropTypes.func, /** * This prop is passed onto the triggering `Button`. Triggered when the trigger button is clicked. */ onClick: PropTypes.func, /** * Called when the triggering button gains focus. */ onFocus: PropTypes.func, /** * Determines if mouse hover or click opens or closes the dropdown menu. The default of `click` opens the menu on click, touch, or keyboard navigation and is highly recommended to comply with accessibility standards. The other options are `hover` which opens when the mouse enters the focusable area, and `hybrid` which causes the menu to open on clicking of the trigger, but closes the menu when the mouse leaves the menu and trigger area. If you are planning on using `hover` or `hybrid`, please pause a moment and reconsider. */ openOn: PropTypes.oneOf(['hover', 'click', 'hybrid']), /** * Called when a key pressed. */ onKeyDown: PropTypes.func, /** * Called when mouse clicks down on the trigger button. */ onMouseDown: PropTypes.func, /** * Called when mouse hovers over the trigger button. This is only called if `this.props.openOn` is set to `hover`. */ onMouseEnter: PropTypes.func, /** * Called when mouse hover leaves the trigger button. This is only called if `this.props.openOn` is set to `hover`. */ onMouseLeave: PropTypes.func, /** * Triggered when an item in the menu is clicked. */ onSelect: PropTypes.func, /** * Triggered when the dropdown is opened. */ onOpen: PropTypes.func, /** * Triggered when the dropdown is closed. */ onClose: PropTypes.func, /** * An array of menu item objects. `className` and `id` object keys are applied to the `li` DOM node. `divider` key can have a value of `top` or `bottom`. `rightIcon` and `leftIcon` are not actually `Icon` components, but prop objects that get passed to an `Icon` component. The `href` key will be added to the `a` and its default click event will be prevented. Here is a sample: * ``` * [{ * className: 'custom-li-class', * divider: 'bottom', * label: 'A Header', * type: 'header' * }, { * href: 'http://sfdc.co/', * id: 'custom-li-id', * label: 'Has a value', * leftIcon: { * name: 'settings', * category: 'utility' * }, * rightIcon: { * name: 'settings', * category: 'utility' * }, * type: 'item', * value: 'B0' * }, { * tooltipContent: 'Displays a tooltip when hovered over with this content. The `tooltipMenuItem` prop must be set for this to work.', * type: 'divider' * }] * ``` */ options: PropTypes.array, /** * An object of CSS styles that are applied to the triggering button. */ style: PropTypes.object, /** * Write <code>"-1"</code> if you don't want the user to tab to the button. */ tabIndex: PropTypes.string, /** * If `true`, adds a transparent overlay when the menu is open to handle outside clicks. Allows clicks on iframes to be captured, but also forces a double-click to interact with other elements. If a function is passed, custom overlay logic may be defined by the app. */ overlay: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), /** * Current selected menu item. */ value: PropTypes.oneOfType([ PropTypes.number, PropTypes.string, PropTypes.array, ]), /** * This prop is passed onto the triggering `Button`. It creates a tooltip with the content of the `node` provided. */ tooltip: PropTypes.node, /** * Accepts a `Tooltip` component to be used as the template for menu item tooltips that appear via the `tooltipContent` options object attribute. Must be present for `tooltipContent` to work */ tooltipMenuItem: PropTypes.node, /** * CSS classes to be added to wrapping trigger `div` around the button. */ triggerClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Whether this dropdown supports multi select. */ multiple: PropTypes.bool, /** * To adjust the width of the menu dropdown */ width: PropTypes.oneOf([ 'xx-small', 'x-small', 'small', 'medium', 'bottom', 'large', ]), }; const defaultProps = { align: 'left', hoverCloseDelay: 300, length: '5', menuPosition: 'absolute', openOn: 'click', width: 'medium', inverse: false, }; /** * The MenuDropdown component is a variant of the Lightning Design System Menu component. This component * may require a polyfill such as [classList](https://github.com/yola/classlist-polyfill) due to * [react-onclickoutside](https://github.com/Pomax/react-onclickoutside) if Internet Explorer 11 * support is needed. * * This component is wrapped in a [higher order component to listen for clicks outside itself](https://github.com/kentor/react-click-outside) and thus requires use of `ReactDOM`. */ class MenuDropdown extends React.Component { static displayName = MENU_DROPDOWN; constructor(props) { super(props); // `checkProps` issues warnings to developers about properties (similar to React's built in development tools) checkProps(MENU_DROPDOWN, props, componentDoc); this.generatedId = generateId(); const currentSelectedIndices = this.getCurrentSelectedIndices(props); this.state = { focusedIndex: -1, selectedIndex: -1, selectedIndices: [], ...currentSelectedIndices, }; this.navigableItems = getNavigableItems(props.options); } componentDidUpdate(prevProps) { if (prevProps.value !== this.props.value) { const nextState = this.getCurrentSelectedIndices(this.props); // eslint-disable-next-line react/no-did-update-set-state this.setState(nextState); } if (prevProps.isOpen !== this.props.isOpen) { this.setFocus(); } if (!isEqual(prevProps.options, this.props.options)) { this.navigableItems = getNavigableItems(this.props.options); } } componentWillUnmount() { if (currentOpenDropdown === this) { currentOpenDropdown = undefined; } this.isUnmounting = true; this.renderOverlay(false); } getId = () => this.props.id || this.generatedId; getIsOpen = () => !!(typeof this.props.isOpen === 'boolean' ? this.props.isOpen : this.state.isOpen); getIndexByValue = (value, options) => { let foundIndex = -1; if (options && options.length) { options.some((element, index) => { if (element && element.value === value) { foundIndex = index; return true; } return false; }); } return foundIndex; }; getValueByIndex = (index) => this.props.options[index]; getListItemRenderer = () => this.props.listItemRenderer ? this.props.listItemRenderer : ListItemLabel; getListItemId = (index) => { let menuItemId; if (index !== undefined) { const menuId = isFunction(this.getId) ? this.getId() : this.props.id; menuItemId = `${menuId}-item-${index}`; } return menuItemId; }; setFocus = () => { if (!this.isHover && !this.isUnmounting && this.trigger) { ReactDOM.findDOMNode(this.trigger).focus(); // eslint-disable-line react/no-find-dom-node } }; getMenu = () => ReactDOM.findDOMNode(this.list); // eslint-disable-line react/no-find-dom-node getMenuItem = (index) => { if (index !== undefined && this.listItems) { return ReactDOM.findDOMNode(this.listItems[index]); // eslint-disable-line react/no-find-dom-node } return undefined; }; getCurrentSelectedIndices = (nextProps) => { if (this.props.multiple === true) { let values = []; let currentIndices = []; if (!Array.isArray(nextProps.value)) { // eslint-disable-next-line fp/no-mutating-methods values.push(nextProps.value); } else { values = nextProps.value; } values = values.filter( (value) => this.getIndexByValue(value, nextProps.options) !== -1 ); currentIndices = values.map((value) => this.getIndexByValue(value, nextProps.options) ); return { selectedIndices: currentIndices, }; } return { selectedIndex: this.getIndexByValue(nextProps.value, nextProps.options), }; }; // Trigger opens, closes, and recieves focus on close saveRefToTrigger = (trigger) => { this.trigger = trigger; if (!this.state.triggerRendered) { this.setState({ triggerRendered: true }); } if (trigger && this.props.requestFocus && this.props.onRequestFocus) { this.props.onRequestFocus(trigger); } }; // TriggerContainer is the wrapping outer DOM element which may differ from the actual trigger which is most likely a `button`. saveRefToTriggerContainer = (triggerContainer) => { this.triggerContainer = triggerContainer; if (!this.trigger) this.trigger = triggerContainer; }; saveRefToList = (list) => { this.list = list; }; saveRefToListItem = (listItem, index) => { if (!this.listItems) { this.listItems = {}; } this.listItems[index] = listItem; if (index === this.state.focusedIndex) { this.handleKeyboardFocus(this.state.focusedIndex); } }; handleClose = () => { const isOpen = this.getIsOpen(); if (isOpen) { if (currentOpenDropdown === this) { currentOpenDropdown = undefined; } this.setState({ isOpen: false, }); this.isHover = false; if (this.props.onClose) { this.props.onClose(); } } }; handleOpen = () => { const isOpen = this.getIsOpen(); if (!isOpen) { if (currentOpenDropdown && isFunction(currentOpenDropdown.handleClose)) { currentOpenDropdown.handleClose(); } currentOpenDropdown = this; this.setState({ isOpen: true, }); if (this.props.onOpen) { this.props.onOpen(); } } }; handleMouseEnter = (event) => { const isOpen = this.getIsOpen(); this.isHover = true; if (!isOpen && this.props.openOn === 'hover') { this.handleOpenForHover(); } else { // we want this clear when openOn is hover or hybrid clearTimeout(this.isClosing); } if (this.props.onMouseEnter) { this.props.onMouseEnter(event); } }; handleMouseLeave = (event) => { const isOpen = this.getIsOpen(); if (isOpen) { this.isClosing = setTimeout(() => { this.handleCloseForHover(); }, this.props.hoverCloseDelay); } if (this.props.onMouseLeave) { this.props.onMouseLeave(event); } }; // Special handlers for openOn === hover // calling onClick inside onMouseEnter/Leave used to cause double clicking the trigger on hover which caused closing and reopening of the dropdown handleCloseForHover = () => { const isOpen = this.getIsOpen(); if (isOpen) { this.handleClose(); } }; handleOpenForHover = () => { const isOpen = this.getIsOpen(); if (!isOpen) { this.handleOpen(); this.setFocus(); } }; handleClick = (event) => { const isOpen = this.getIsOpen(); if (!isOpen) { this.handleOpen(); this.setFocus(); } else { this.handleClose(); } if (this.props.onClick) { this.props.onClick(event); } }; handleFocus = (event) => { if (this.props.onFocus) { this.props.onFocus(event); } }; handleClickCustomContent = () => { this.setFocus(); this.handleClose(); if (this.props.onSelect) { this.props.onSelect(); } }; handleSelect = (index) => { if (!this.props.multiple) { this.setState({ selectedIndex: index }); this.handleClose(); this.setFocus(); } else if ( this.props.multiple && this.state.selectedIndices.indexOf(index) === -1 ) { // eslint-disable-next-line react/no-access-state-in-setstate const currentIndices = this.state.selectedIndices.concat(index); this.setState({ selectedIndices: currentIndices, }); } else if (this.props.multiple) { const deselectIndex = this.state.selectedIndices.indexOf(index); // eslint-disable-next-line react/no-access-state-in-setstate const currentSelected = this.state.selectedIndices; // eslint-disable-next-line fp/no-mutating-methods currentSelected.splice(deselectIndex, 1); this.setState({ selectedIndices: currentSelected, }); } if (this.props.onSelect) { const option = this.getValueByIndex(index); this.props.onSelect(option, { option, optionIndex: index }); } }; handleKeyDown = (event) => { if (event.keyCode) { if (event.keyCode === KEYS.TAB) { this.handleCancel(); } else if ( event.keyCode === KEYS.ENTER || event.keyCode === KEYS.SPACE || event.keyCode === KEYS.DOWN || event.keyCode === KEYS.UP || event.keyCode === KEYS.ESCAPE ) { EventUtil.trap(event); const isOpen = this.getIsOpen(); this.handleKeyboardNavigate({ event, isOpen, key: event.key, keyCode: event.keyCode, onSelect: this.handleSelect, target: event.target, toggleOpen: this.toggleOpen, }); } if (this.props.onKeyDown) { this.props.onKeyDown(event); } } }; handleCancel = () => { this.setFocus(); this.handleClose(); }; handleClickOutside = () => { this.handleClose(); }; // Handling open / close toggling is optional, and a default implementation is provided for handling focus, but selection _must_ be handled handleKeyboardNavigate = ({ event, isOpen = true, keyCode, onFocus = this.handleKeyboardFocus, onSelect, target, toggleOpen = noop, }) => { keyboardNavigate({ componentContext: this, currentFocusedIndex: this.state.focusedIndex, event, isOpen, keyCode, navigableItems: this.navigableItems, onFocus, onSelect, target, toggleOpen, }); }; // This is a bit of an anti-pattern, but it has the upside of being a nice default. Component authors can always override to only set state and do their own focusing in their subcomponents. handleKeyboardFocus = (focusedIndex) => { if ( this.state.focusedIndex !== focusedIndex && focusedIndex !== undefined ) { this.setState({ focusedIndex }); } const menu = isFunction(this.getMenu) ? this.getMenu() : getMenu(this); const menuItem = isFunction(this.getMenuItem) ? this.getMenuItem(focusedIndex, menu) : getMenuItem(this.getListItemId(focusedIndex)); if (menuItem) { this.focusMenuItem(menuItem); this.scrollToMenuItem(menu, menuItem); } }; focusMenuItem = (menuItem) => { menuItem.getElementsByTagName('a')[0].focus(); }; scrollToMenuItem = (menu, menuItem) => { if (menu && menuItem) { const menuHeight = menu.offsetHeight; const menuTop = menu.scrollTop; const menuItemTop = menuItem.offsetTop - menu.offsetTop; if (menuItemTop < menuTop) { // eslint-disable-next-line no-param-reassign menu.scrollTop = menuItemTop; } else { const menuBottom = menuTop + menuHeight + menu.offsetTop; const menuItemBottom = menuItemTop + menuItem.offsetHeight + menu.offsetTop; if (menuItemBottom > menuBottom) { // eslint-disable-next-line no-param-reassign menu.scrollTop = menuItemBottom - menuHeight - menu.offsetTop; } } } }; toggleOpen = () => { const isOpen = this.getIsOpen(); this.setFocus(); if (isOpen) { this.handleClose(); } else { this.handleOpen(); } }; renderDefaultMenuContent = (customListProps) => ( <List key={`${this.getId()}-dropdown-list`} checkmark={this.props.checkmark} getListItemId={this.getListItemId} itemRefs={this.saveRefToListItem} itemRenderer={this.getListItemRenderer()} onCancel={this.handleCancel} onSelect={this.handleSelect} options={this.props.options} ref={this.saveRefToList} selectedIndex={ !this.props.multiple ? this.state.selectedIndex : undefined } selectedIndices={ this.props.multiple ? this.state.selectedIndices : undefined } tooltipMenuItem={this.props.tooltipMenuItem} triggerId={this.getId()} length={this.props.length} {...customListProps} /> ); renderMenuContent = (customContent) => { /** * Custom content for dropdown was a hack done in the past. If there's more than a listbox within a dropdown, then it should be a popover as explained for the `children` prop. * * This code block shows how things are done in the past: * ``` * <Dropdown> * <Trigger> * <Button iconCategory="utility" iconName="settings" /> * </Trigger> * <div>Look ma! This is Custom Content.</div> * <List options={[myArray]}/> * </Dropdown> * ``` */ let customContentWithListPropInjection = []; // Dropdown can take a Trigger component as a child and then return it as the parent DOM element. React.Children.forEach(customContent, (child) => { if (child && child.type.displayName === LIST) { // eslint-disable-next-line fp/no-mutating-methods customContentWithListPropInjection.push( this.renderDefaultMenuContent(child.props) ); } else if (child) { const clonedCustomContent = React.cloneElement(child, { onClick: this.handleClickCustomContent, key: generateId(), }); // eslint-disable-next-line fp/no-mutating-methods customContentWithListPropInjection.push(clonedCustomContent); } }); if (customContentWithListPropInjection.length === 0) { customContentWithListPropInjection = null; } return ( customContentWithListPropInjection || this.renderDefaultMenuContent() ); }; renderDialog = (customContent, isOpen, outsideClickIgnoreClass) => { let align = 'bottom'; let hasNubbin = false; let positionClassName = ''; if (this.props.nubbinPosition) { hasNubbin = true; align = DropdownToDialogNubbinMapping[this.props.nubbinPosition]; } else if (this.props.align) { align = this.props.align === 'center' ? 'bottom' : `bottom ${this.props.align}`; } const positions = DropdownToDialogNubbinMapping[align].split(' '); positionClassName = classNames( positions.map((position) => `slds-dropdown_${position}`) ); // FOR BACKWARDS COMPATIBILITY const menuPosition = this.props.isInline ? 'relative' : this.props.menuPosition; // eslint-disable-line react/prop-types const menuStylesBase = {}; if (this.props.align === 'center' && !hasNubbin) { menuStylesBase.transform = 'none'; } return isOpen ? ( <Dialog align={align} className={classNames(this.props.containerClassName)} closeOnTabKey contentsClassName={classNames( 'slds-dropdown', `slds-dropdown_${this.props.width}`, 'slds-text-align_left', 'ignore-react-onclickoutside', this.props.className, positionClassName, { 'slds-dropdown_inverse': this.props.inverse, } )} context={this.context} hasNubbin={hasNubbin} hasStaticAlignment={this.props.hasStaticAlignment} inheritWidthOf={this.props.inheritTargetWidth ? 'target' : 'none'} offset={this.props.offset} onClose={this.handleClose} onKeyDown={this.handleKeyDown} outsideClickIgnoreClass={outsideClickIgnoreClass} position={menuPosition} style={{ ...menuStylesBase, ...this.props.menuStyle, }} onRequestTargetElement={() => this.trigger} > {this.renderMenuContent(customContent)} </Dialog> ) : null; }; renderOverlay = (isOpen) => { if (isFunction(overlay) && documentDefined) { overlay(isOpen, overlay); } else if ( this.props.overlay && isOpen && !this.overlay && documentDefined ) { this.overlay = overlay; document.querySelector('body').appendChild(this.overlay); } else if (!isOpen && this.overlay && this.overlay.parentNode) { this.overlay.parentNode.removeChild(this.overlay); this.overlay = undefined; } }; render() { // Dropdowns are used by other components. The default trigger is a button, but some other components use `li` elements. The following allows `MenuDropdown` to be extended by providing a child component with the displayName of `DropdownTrigger`. let CurrentTrigger = DefaultTrigger; let CustomTriggerChildProps = {}; // Child elements that do not have the display name of the value of `MENU_DROPDOWN_TRIGGER` in `components/constants.js` will be considered custom content and rendered in the popover. let customContent = []; // Dropdown can take a Trigger component as a child and then return it as the parent DOM element. React.Children.forEach(this.props.children, (child) => { if (child && child.type.displayName === MENU_DROPDOWN_TRIGGER) { // `CustomTriggerChildProps` is not used by the default button Trigger, but by other triggers CustomTriggerChildProps = child.props; CurrentTrigger = child.type; } else if (child) { // eslint-disable-next-line fp/no-mutating-methods customContent.push(child); } }); if (customContent.length === 0) { customContent = null; } const outsideClickIgnoreClass = `ignore-click-${this.getId()}`; const isOpen = !this.props.disabled && this.getIsOpen() && !!this.trigger; this.renderOverlay(isOpen); /* Below are three sections of props: - The first are the props that may be given by the dropdown component. These may get deprecated in the future. - The next set of props (`CustomTriggerChildProps`) are props that can be overwritten by the end developer. - The final set are props that should not be overwritten, since they are ones that tie the trigger to the dropdown menu. */ return ( <CurrentTrigger aria-haspopup assistiveText={this.props.assistiveText} className={classNames( outsideClickIgnoreClass, this.props.buttonClassName )} disabled={this.props.disabled} hint={this.props.hint} iconCategory={this.props.iconCategory} iconName={this.props.iconName} iconPosition={this.props.iconPosition} iconSize={this.props.iconSize} iconVariant={this.props.iconVariant} id={this.getId()} inverse={this.props.buttonInverse} isOpen={isOpen} label={this.props.label} menu={this.renderDialog(customContent, isOpen, outsideClickIgnoreClass)} onBlur={this.props.onBlur} onClick={ this.props.openOn === 'click' || this.props.openOn === 'hybrid' ? this.handleClick : this.props.onClick } onFocus={this.handleFocus} onKeyDown={this.handleKeyDown} onMouseDown={this.props.onMouseDown} onMouseEnter={ this.props.openOn === 'hover' || this.props.openOn === 'hybrid' ? this.handleMouseEnter : null } onMouseLeave={ this.props.openOn === 'hover' || this.props.openOn === 'hybrid' ? this.handleMouseLeave : null } openOn={this.props.openOn} ref={this.saveRefToTriggerContainer} style={this.props.style} tabIndex={this.props.tabIndex || (isOpen ? '-1' : '0')} tooltip={this.props.tooltip} triggerClassName={this.props.triggerClassName} triggerRef={this.saveRefToTrigger} variant={this.props.buttonVariant} {...CustomTriggerChildProps} /> ); } } MenuDropdown.contextType = IconSettingsContext; MenuDropdown.propTypes = propTypes; MenuDropdown.defaultProps = defaultProps; export default MenuDropdown; export { ListItem, ListItemLabel, DropdownNubbinPositions };