@salesforce/design-system-react
Version:
Salesforce Lightning Design System for React
720 lines (644 loc) • 19.6 kB
JSX
/* eslint-disable max-lines */
/* eslint-disable react/no-access-state-in-setstate */
/* eslint-disable no-param-reassign */
/* eslint-disable prefer-destructuring */
/* 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 react/prefer-es6-class */
// # Picklist Component [DEPRECATED]
// Implements the [Picklist design pattern](https://www.lightningdesignsystem.com/components/menus/#flavor-picklist) in React.
// Based on SLDS v2.1.0-rc.2
import React from 'react';
import ReactDOM from 'react-dom';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import isFunction from 'lodash.isfunction';
// ### classNames
// [github.com/JedWatson/classnames](https://github.com/JedWatson/classnames)
// This project uses `classnames`, "a simple javascript utility for conditionally
// joining classNames together."
import classNames from 'classnames';
// 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';
// ### Children
import Dialog from '../utilities/dialog';
import Icon from '../icon';
import List from '../utilities/menu-list';
import ListItemLabel from '../utilities/menu-list/item-label';
import Pill from '../utilities/pill';
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_PICKLIST } from '../../utilities/constants';
import { IconSettingsContext } from '../icon-settings';
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 getMenuItem(menuItemId, context = document) {
let menuItem;
if (menuItemId) {
menuItem = context.getElementById(menuItemId);
}
return menuItem;
}
function getMenu(componentRef) {
return ReactDOM.findDOMNode(componentRef).querySelector('ul.dropdown__list'); // eslint-disable-line react/no-find-dom-node
}
/**
* ** MenuPicklist is deprecated. Please use a read-only Combobox instead.**
*
* The MenuPicklist component is a variant of the Lightning Design System Menu component.
*/
const MenuPicklist = createReactClass({
// ### Display Name
// Always use the canonical component name as the React display name.
displayName: MENU_PICKLIST,
// ### Prop Types
propTypes: {
/**
* Callback that passes in the DOM reference of the `<button>` DOM node within this component. Primary use is to allow `focus` to be called. You should still test if the node exists, since rendering is asynchronous. `buttonRef={(component) => { if(component) console.log(component); }}`
*/
buttonRef: PropTypes.func,
className: PropTypes.string,
/**
* If true, renders checkmark icon on the selected Menu Item.
*/
checkmark: PropTypes.bool,
disabled: PropTypes.bool,
/**
* Message to display when the input is in an error state. When this is present, also visually highlights the component as in error.
*/
errorText: PropTypes.string,
/**
* A unique ID is needed in order to support keyboard navigation, ARIA support, and connect the dropdown to the triggering button.
*/
id: PropTypes.string,
/**
* Renders menu within the wrapping trigger as a sibling of the button. By default, you will have an absolutely positioned container at an elevated z-index.
*/
isInline: PropTypes.bool,
/**
* Form element label
*/
label: PropTypes.string,
/**
* **Text labels for internationalization**
* This object is merged with the default props object on every render.
* * `multipleOptionsSelected`: Text to be used when multiple items are selected. "2 Options Selected" is a good pattern to use.
*/
labels: PropTypes.shape({
multipleOptionsSelected: PropTypes.string,
}),
/**
* Custom element that overrides the default Menu Item component.
*/
listItemRenderer: PropTypes.func,
/**
* Triggered when the trigger button is clicked to open.
*/
onClick: PropTypes.func,
/**
* Triggered when an item is selected. Passes in the option object that has been selected and a data object in the format: `{ option, optionIndex }`. The first parameter may be deprecated in the future and changed to an event for consistency. Please use the data object.
*/
onSelect: PropTypes.func,
/**
* Triggered when a pill is removed. Passes in the option object that has been removed and a data object in the format: `{ option, optionIndex }`. The first parameter may be deprecated in the future and changed to an event for consistency. Please use the data object.
*/
onPillRemove: PropTypes.func,
/**
* Menu item data.
*/
options: PropTypes.array.isRequired,
/**
* Text present in trigger button if no items are selected.
*/
placeholder: PropTypes.string,
/**
* Add styling of a required form element.
*/
required: PropTypes.bool,
/**
* Current selected item.
*/
value: PropTypes.node,
/**
* Initial selected item index.
*/
initValueIndex: PropTypes.number,
},
getDefaultProps() {
return {
inheritTargetWidth: true,
placeholder: 'Select an Option',
checkmark: true,
labels: {
multipleOptionsSelected: 'Multiple Options Selected',
},
menuPosition: 'absolute',
};
},
getInitialState() {
return {
focusedIndex: this.props.initValueIndex ? this.props.initValueIndex : -1,
selectedIndex: this.props.initValueIndex ? this.props.initValueIndex : -1,
selectedIndices: [],
currentPillLabel: '',
};
},
// eslint-disable-next-line camelcase, react/sort-comp
UNSAFE_componentWillMount() {
// `checkProps` issues warnings to developers about properties (similar to React's built in development tools)
checkProps(MENU_PICKLIST, this.props);
this.generatedId = generateId();
if (this.props.errorText) {
this.generatedErrorId = generateId();
}
if (typeof window !== 'undefined') {
window.addEventListener('click', this.closeOnClick, false);
}
if (!this.props.multiple) {
this.setState({
selectedIndex: this.getIndexByValue(this.props),
});
} else {
const currentSelectedIndex = this.getIndexByValue(this.props);
const currentIndices = this.state.selectedIndices;
if (currentSelectedIndex !== -1) {
// eslint-disable-next-line fp/no-mutating-methods
currentIndices.push(currentSelectedIndex);
}
this.setState({
selectedIndices: currentIndices,
});
}
this.navigableItems = getNavigableItems(this.props.options);
},
// eslint-disable-next-line camelcase, react/sort-comp
UNSAFE_componentWillReceiveProps(nextProps) {
if (
this.props.value !== nextProps.value ||
this.props.options.length !== nextProps.length
) {
if (this.props.multiple !== true) {
this.setState({
selectedIndex: this.getIndexByValue(nextProps),
});
} else {
const currentSelectedIndex = this.getIndexByValue(nextProps);
if (currentSelectedIndex !== -1) {
const currentIndices = this.state.selectedIndices.concat(
currentSelectedIndex
);
this.setState({
selectedIndices: currentIndices,
});
}
}
}
if (nextProps.options) {
this.navigableItems = getNavigableItems(nextProps.options);
}
},
componentWillUnmount() {
this.isUnmounting = true;
window.removeEventListener('click', this.closeOnClick, false);
},
getListItemId(index) {
let menuItemId;
if (index !== undefined) {
const menuId = isFunction(this.getId) ? this.getId() : this.props.id;
menuItemId = `${menuId}-item-${index}`;
}
return menuItemId;
},
getId() {
return this.props.id || this.generatedId;
},
getErrorId() {
return this.props['aria-describedby'] || this.generatedErrorId;
},
getClickEventName() {
return `SLDS${this.getId()}ClickEvent`;
},
getIndexByValue({ value, options } = this.props) {
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) {
return this.props.options[index];
},
getListItemRenderer() {
return this.props.listItemRenderer
? this.props.listItemRenderer
: ListItemLabel;
},
setFocus() {
if (!this.isUnmounting && this.button) {
this.button.focus();
}
},
handleSelect(index) {
if (!this.props.multiple) {
this.setState({ selectedIndex: index });
this.handleClose();
this.setFocus();
} else {
let currentIndices;
if (this.state.selectedIndices.indexOf(index) === -1) {
currentIndices = this.state.selectedIndices.concat(index);
} else {
const deselectIndex = this.state.selectedIndices.indexOf(index);
currentIndices = this.state.selectedIndices;
// eslint-disable-next-line fp/no-mutating-methods
currentIndices.splice(deselectIndex, 1);
}
this.setState({
selectedIndices: currentIndices,
});
}
if (this.props.onSelect) {
const option = this.getValueByIndex(index);
this.props.onSelect(option, { option, optionIndex: index });
}
},
handleClose() {
this.setState({ isOpen: false });
},
handleClick(event) {
if (event) {
event.nativeEvent[this.getClickEventName()] = true;
}
if (!this.state.isOpen) {
this.setState({ isOpen: true });
this.setFocus();
if (this.props.onClick) {
this.props.onClick(event);
}
} else {
this.handleClose();
}
},
handleMouseDown(event) {
if (event) {
EventUtil.trapImmediate(event);
event.nativeEvent[this.getClickEventName()] = true;
}
},
handleKeyDown(event) {
if (event.keyCode) {
if (
event.keyCode === KEYS.ENTER ||
event.keyCode === KEYS.SPACE ||
event.keyCode === KEYS.DOWN ||
event.keyCode === KEYS.UP
) {
EventUtil.trap(event);
}
if (event.keyCode !== KEYS.TAB) {
// The outer div with onKeyDown is overriding button onClick so we need to add it here.
const openMenuKeys =
event.keyCode === KEYS.ENTER ||
event.keyCode === KEYS.DOWN ||
event.keyCode === KEYS.UP;
const isTrigger = event.target.tagName === 'BUTTON';
if (openMenuKeys && isTrigger && this.props.onClick) {
this.props.onClick(event);
}
this.handleKeyboardNavigate({
isOpen: this.state.isOpen || false,
keyCode: event.keyCode,
onSelect: this.handleSelect,
toggleOpen: this.toggleOpen,
});
} else {
this.handleCancel();
}
}
},
handleCancel() {
this.setFocus();
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) {
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) {
menu.scrollTop = menuItemTop;
} else {
const menuBottom = menuTop + menuHeight + menu.offsetTop;
const menuItemBottom =
menuItemTop + menuItem.offsetHeight + menu.offsetTop;
if (menuItemBottom > menuBottom) {
menu.scrollTop = menuItemBottom - menuHeight - menu.offsetTop;
}
}
}
},
closeOnClick(event) {
if (!event[this.getClickEventName()] && this.state.isOpen) {
this.handleClose();
}
},
toggleOpen() {
this.setState({ isOpen: !this.state.isOpen });
},
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);
}
},
// Trigger opens, closes, and recieves focus on close
saveRefToTrigger(trigger) {
this.button = trigger;
if (this.props.buttonRef) {
this.props.buttonRef(this.button);
}
if (!this.state.triggerRendered) {
this.setState({ triggerRendered: true });
}
},
renderMenuContent() {
return (
<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
}
triggerId={this.getId()}
/>
);
},
renderInlineMenu() {
return !this.props.disabled && this.state.isOpen ? (
<div
className="slds-dropdown slds-dropdown_left"
// inline style override
style={{
maxHeight: '20em',
overflowX: 'hidden',
minWidth: '100%',
}}
>
{this.renderMenuContent()}
</div>
) : null;
},
renderDialog() {
return !this.props.disabled && this.state.isOpen ? (
<Dialog
closeOnTabKey
constrainToScrollParent={this.props.constrainToScrollParent}
contentsClassName="slds-dropdown slds-dropdown_left"
context={this.context}
flippable
onClose={this.handleCancel}
onKeyDown={this.handleKeyDown}
onRequestTargetElement={() => this.button}
inheritWidthOf={this.props.inheritTargetWidth ? 'target' : 'none'}
position={this.props.menuPosition}
>
{this.renderMenuContent()}
</Dialog>
) : null;
},
renderTrigger() {
let isInline;
/* eslint-disable react/prop-types */
if (this.props.isInline) {
isInline = true;
} else if (this.props.modal !== undefined) {
isInline = !this.props.modal;
}
/* eslint-enable react/prop-types */
let inputValue;
if (this.props.multiple && this.state.selectedIndices.length === 0) {
inputValue = this.props.placeholder;
} else if (this.props.multiple && this.state.selectedIndices.length === 1) {
const option = this.props.options[this.state.selectedIndices];
inputValue = option.label;
} else if (this.props.multiple && this.state.selectedIndices.length > 1) {
inputValue = this.props.labels.multipleOptionsSelected;
} else {
const option = this.props.options[this.state.selectedIndex];
inputValue =
option && option.label ? option.label : this.props.placeholder;
}
// TODO: make use of <Button>
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={classNames(
'slds-picklist slds-dropdown-trigger slds-dropdown-trigger_click',
{ 'slds-is-open': this.state.isOpen },
this.props.className
)}
onKeyDown={this.handleKeyDown}
onMouseDown={this.handleMouseDown}
>
<button
aria-describedby={this.getErrorId()}
aria-expanded={this.state.isOpen}
aria-haspopup="true"
className="slds-button slds-button_neutral slds-picklist__label"
disabled={this.props.disabled}
id={this.getId()}
onClick={!this.props.disabled ? this.handleClick : undefined}
ref={this.saveRefToTrigger}
tabIndex={this.state.isOpen ? -1 : 0}
type="button"
>
<span className="slds-truncate">{inputValue}</span>
<Icon name="down" category="utility" />
</button>
{isInline ? this.renderInlineMenu() : this.renderDialog()}
</div>
);
},
renderPills() {
const selectedPills = this.state.selectedIndices.map((selectedPill) => {
const pillLabel = this.getValueByIndex(selectedPill).label;
return (
<li
className="slds-listbox__item"
key={`pill-${selectedPill}`}
role="presentation"
>
<Pill
eventData={{
item: this.props.options[selectedPill],
index: selectedPill,
}}
events={{
onRequestFocus: () => {},
onRequestFocusOnNextPill: () => {},
onRequestFocusOnPreviousPill: () => {},
onRequestRemove: (event, data) => {
const newData = this.state.selectedIndices;
const index = data.index;
// eslint-disable-next-line fp/no-mutating-methods
newData.splice(this.state.selectedIndices.indexOf(index), 1);
this.setState({ selectedIndices: newData });
if (this.props.onPillRemove) {
const option = this.getValueByIndex(index);
this.props.onPillRemove(option, {
option,
optionIndex: index,
});
}
},
}}
labels={{
label: pillLabel,
}}
/>
</li>
);
});
return (
<div
id="listbox-selections-unique-id"
orientation="horizontal"
role="listbox"
>
<ul
className="slds-listbox slds-listbox_inline slds-p-top_xxx-small"
role="group"
aria-label="Selected Options:"
>
{selectedPills}
</ul>
</div>
);
},
render() {
const { className, errorText, label, required } = this.props;
const requiredElem = required ? (
// eslint-disable-next-line react/jsx-curly-brace-presence
<span style={{ color: 'red' }}>{'* '}</span>
) : null;
return (
<div
className={classNames(
'slds-form-element',
{
'slds-has-error': errorText,
},
className
)}
>
{this.props.label ? (
<label
className="slds-form-element__label"
htmlFor={this.getId()}
// inline style override
style={{ width: '100%' }}
>
{requiredElem}
{label}
</label>
) : null}
{this.renderTrigger()}
{this.renderPills()}
{errorText && (
<div id={this.getErrorId()} className="slds-form-element__help">
{errorText}
</div>
)}
</div>
);
},
});
MenuPicklist.contextType = IconSettingsContext;
export default MenuPicklist;
export { ListItemLabel };