UNPKG

semantic-ui-react

Version:
1,415 lines (1,144 loc) 42.9 kB
import EventStack from '@semantic-ui-react/event-stack' import cx from 'classnames' import keyboardKey from 'keyboard-key' import _ from 'lodash' import PropTypes from 'prop-types' import React, { Children, cloneElement, createRef } from 'react' import shallowEqual from 'shallowequal' import { AutoControlledComponent as Component, childrenUtils, customPropTypes, doesNodeContainClick, getElementType, getUnhandledProps, makeDebugger, objectDiff, useKeyOnly, useKeyOrValueAndKey, } from '../../lib' import Ref from '../../addons/Ref' import Icon from '../../elements/Icon' import Label from '../../elements/Label' import DropdownDivider from './DropdownDivider' import DropdownItem from './DropdownItem' import DropdownHeader from './DropdownHeader' import DropdownMenu from './DropdownMenu' import DropdownSearchInput from './DropdownSearchInput' const debug = makeDebugger('dropdown') const getKeyOrValue = (key, value) => (_.isNil(key) ? value : key) /** * A dropdown allows a user to select a value from a series of options. * @see Form * @see Select * @see Menu */ export default class Dropdown extends Component { static propTypes = { /** An element type to render as (string or function). */ as: customPropTypes.as, /** Label prefixed to an option added by a user. */ additionLabel: PropTypes.oneOfType([PropTypes.element, PropTypes.string]), /** Position of the `Add: ...` option in the dropdown list ('top' or 'bottom'). */ additionPosition: PropTypes.oneOf(['top', 'bottom']), /** * Allow user additions to the list of options (boolean). * Requires the use of `selection`, `options` and `search`. */ allowAdditions: customPropTypes.every([ customPropTypes.demand(['options', 'selection', 'search']), PropTypes.bool, ]), /** A Dropdown can reduce its complexity. */ basic: PropTypes.bool, /** Format the Dropdown to appear as a button. */ button: PropTypes.bool, /** Primary content. */ children: customPropTypes.every([ customPropTypes.disallow(['options', 'selection']), customPropTypes.givenProps( { children: PropTypes.any.isRequired }, PropTypes.element.isRequired, ), ]), /** Additional classes. */ className: PropTypes.string, /** Using the clearable setting will let users remove their selection from a dropdown. */ clearable: PropTypes.bool, /** Whether or not the menu should close when the dropdown is blurred. */ closeOnBlur: PropTypes.bool, /** Whether or not the dropdown should close when the escape key is pressed. */ closeOnEscape: PropTypes.bool, /** * Whether or not the menu should close when a value is selected from the dropdown. * By default, multiple selection dropdowns will remain open on change, while single * selection dropdowns will close on change. */ closeOnChange: PropTypes.bool, /** A compact dropdown has no minimum width. */ compact: PropTypes.bool, /** Whether or not the dropdown should strip diacritics in options and input search */ deburr: PropTypes.bool, /** Initial value of open. */ defaultOpen: PropTypes.bool, /** Initial value of searchQuery. */ defaultSearchQuery: PropTypes.string, /** Currently selected label in multi-select. */ defaultSelectedLabel: customPropTypes.every([ customPropTypes.demand(['multiple']), PropTypes.oneOfType([PropTypes.number, PropTypes.string]), ]), /** Initial value of upward. */ defaultUpward: PropTypes.bool, /** Initial value or value array if multiple. */ defaultValue: PropTypes.oneOfType([ PropTypes.number, PropTypes.string, PropTypes.bool, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool])), ]), /** A dropdown menu can open to the left or to the right. */ direction: PropTypes.oneOf(['left', 'right']), /** A disabled dropdown menu or item does not allow user interaction. */ disabled: PropTypes.bool, /** An errored dropdown can alert a user to a problem. */ error: PropTypes.bool, /** A dropdown menu can contain floated content. */ floating: PropTypes.bool, /** A dropdown can take the full width of its parent */ fluid: PropTypes.bool, /** A dropdown menu can contain a header. */ header: PropTypes.node, /** Shorthand for Icon. */ icon: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), /** A dropdown can be formatted to appear inline in other content. */ inline: PropTypes.bool, /** A dropdown can be formatted as a Menu item. */ item: PropTypes.bool, /** A dropdown can be labeled. */ labeled: PropTypes.bool, /** A dropdown can defer rendering its options until it is open. */ lazyLoad: PropTypes.bool, /** A dropdown can show that it is currently loading data. */ loading: PropTypes.bool, /** The minimum characters for a search to begin showing results. */ minCharacters: PropTypes.number, /** A selection dropdown can allow multiple selections. */ multiple: PropTypes.bool, /** Message to display when there are no results. */ noResultsMessage: PropTypes.node, /** * Called when a user adds a new item. Use this to update the options list. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props and the new item's value. */ onAddItem: PropTypes.func, /** * Called on blur. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onBlur: PropTypes.func, /** * Called when the user attempts to change the value. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props and proposed value. */ onChange: PropTypes.func, /** * Called on click. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onClick: PropTypes.func, /** * Called when a close event happens. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onClose: PropTypes.func, /** * Called on focus. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onFocus: PropTypes.func, /** * Called when a multi-select label is clicked. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All label props. */ onLabelClick: PropTypes.func, /** * Called on mousedown. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onMouseDown: PropTypes.func, /** * Called when an open event happens. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props. */ onOpen: PropTypes.func, /** * Called on search input change. * * @param {SyntheticEvent} event - React's original SyntheticEvent. * @param {object} data - All props, includes current value of searchQuery. */ onSearchChange: PropTypes.func, /** Controls whether or not the dropdown menu is displayed. */ open: PropTypes.bool, /** Whether or not the menu should open when the dropdown is focused. */ openOnFocus: PropTypes.bool, /** Array of Dropdown.Item props e.g. `{ text: '', value: '' }` */ options: customPropTypes.every([ customPropTypes.disallow(['children']), PropTypes.arrayOf(PropTypes.shape(DropdownItem.propTypes)), ]), /** Placeholder text. */ placeholder: PropTypes.string, /** A dropdown can be formatted so that its menu is pointing. */ pointing: PropTypes.oneOfType([ PropTypes.bool, PropTypes.oneOf([ 'left', 'right', 'top', 'top left', 'top right', 'bottom', 'bottom left', 'bottom right', ]), ]), /** * Mapped over the active items and returns shorthand for the active item Labels. * Only applies to `multiple` Dropdowns. * * @param {object} item - A currently active dropdown item. * @param {number} index - The current index. * @param {object} defaultLabelProps - The default props for an active item Label. * @returns {*} Shorthand for a Label. */ renderLabel: PropTypes.func, /** A dropdown can have its menu scroll. */ scrolling: PropTypes.bool, /** * A selection dropdown can allow a user to search through a large list of choices. * Pass a function here to replace the default search. */ search: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), /** A shorthand for a search input. */ searchInput: PropTypes.oneOfType([PropTypes.array, PropTypes.node, PropTypes.object]), /** Current value of searchQuery. Creates a controlled component. */ searchQuery: PropTypes.string, // TODO 'searchInMenu' or 'search='in menu' or ??? How to handle this markup and functionality? /** Define whether the highlighted item should be selected on blur. */ selectOnBlur: PropTypes.bool, /** * Whether or not to change the value when navigating the menu using arrow keys. * Setting to false will require enter or left click to confirm a choice. */ selectOnNavigation: PropTypes.bool, /** Currently selected label in multi-select. */ selectedLabel: customPropTypes.every([ customPropTypes.demand(['multiple']), PropTypes.oneOfType([PropTypes.string, PropTypes.number]), ]), /** A dropdown can be used to select between choices in a form. */ selection: customPropTypes.every([ customPropTypes.disallow(['children']), customPropTypes.demand(['options']), PropTypes.bool, ]), /** A simple dropdown can open without Javascript. */ simple: PropTypes.bool, /** A dropdown can receive focus. */ tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), /** The text displayed in the dropdown, usually for the active item. */ text: PropTypes.string, /** Custom element to trigger the menu to become visible. Takes place of 'text'. */ trigger: customPropTypes.every([ customPropTypes.disallow(['selection', 'text']), PropTypes.node, ]), /** Current value or value array if multiple. Creates a controlled component. */ value: PropTypes.oneOfType([ PropTypes.bool, PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])), ]), /** Controls whether the dropdown will open upward. */ upward: PropTypes.bool, /** * A dropdown will go to the last element when ArrowUp is pressed on the first, * or go to the first when ArrowDown is pressed on the last( aka infinite selection ) */ wrapSelection: PropTypes.bool, } static defaultProps = { additionLabel: 'Add ', additionPosition: 'top', closeOnBlur: true, closeOnEscape: true, deburr: false, icon: 'dropdown', minCharacters: 1, noResultsMessage: 'No results found.', openOnFocus: true, renderLabel: ({ text }) => text, searchInput: 'text', selectOnBlur: true, selectOnNavigation: true, wrapSelection: true, } static autoControlledProps = ['open', 'searchQuery', 'selectedLabel', 'value', 'upward'] static Divider = DropdownDivider static Header = DropdownHeader static Item = DropdownItem static Menu = DropdownMenu static SearchInput = DropdownSearchInput searchRef = createRef() sizerRef = createRef() ref = createRef() getInitialAutoControlledState() { return { focus: false, searchQuery: '' } } componentWillMount() { debug('componentWillMount()') const { open, value } = this.state this.setValue(value) this.setSelectedIndex(value) if (open) { this.open() } } componentWillReceiveProps(nextProps) { super.componentWillReceiveProps(nextProps) debug('componentWillReceiveProps()') debug('to props:', objectDiff(this.props, nextProps)) /* eslint-disable no-console */ if (process.env.NODE_ENV !== 'production') { // in development, validate value type matches dropdown type const isNextValueArray = Array.isArray(nextProps.value) const hasValue = _.has(nextProps, 'value') if (hasValue && nextProps.multiple && !isNextValueArray) { console.error( 'Dropdown `value` must be an array when `multiple` is set.' + ` Received type: \`${Object.prototype.toString.call(nextProps.value)}\`.`, ) } else if (hasValue && !nextProps.multiple && isNextValueArray) { console.error( 'Dropdown `value` must not be an array when `multiple` is not set.' + ' Either set `multiple={true}` or use a string or number value.', ) } } /* eslint-enable no-console */ if (!shallowEqual(nextProps.value, this.props.value)) { debug('value changed, setting', nextProps.value) this.setValue(nextProps.value) this.setSelectedIndex(nextProps.value) } // The selected index is only dependent on option keys/values. // We only check those properties to avoid recursive performance impacts. // https://github.com/Semantic-Org/Semantic-UI-React/issues/3000 if ( !_.isEqual(this.getKeyAndValues(nextProps.options), this.getKeyAndValues(this.props.options)) ) { this.setSelectedIndex(undefined, nextProps.options) } } shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(nextProps, this.props) || !shallowEqual(nextState, this.state) } componentDidUpdate(prevProps, prevState) { // eslint-disable-line complexity debug('componentDidUpdate()') debug('to state:', objectDiff(prevState, this.state)) const { closeOnBlur, minCharacters, openOnFocus, search } = this.props // focused / blurred if (!prevState.focus && this.state.focus) { debug('dropdown focused') if (!this.isMouseDown) { const openable = !search || (search && minCharacters === 1 && !this.state.open) debug('mouse is not down, opening') if (openOnFocus && openable) this.open() } } else if (prevState.focus && !this.state.focus) { debug('dropdown blurred') if (!this.isMouseDown && closeOnBlur) { debug('mouse is not down and closeOnBlur=true, closing') this.close() } } // opened / closed if (!prevState.open && this.state.open) { debug('dropdown opened') this.setOpenDirection() this.scrollSelectedItemIntoView() } else if (prevState.open && !this.state.open) { debug('dropdown closed') this.handleClose() } } // ---------------------------------------- // Document Event Handlers // ---------------------------------------- // onChange needs to receive a value // can't rely on props.value if we are controlled handleChange = (e, value) => { debug('handleChange()', value) _.invoke(this.props, 'onChange', e, { ...this.props, value }) } closeOnChange = (e) => { const { closeOnChange, multiple } = this.props const shouldClose = _.isUndefined(closeOnChange) ? !multiple : closeOnChange if (shouldClose) this.close(e) } closeOnEscape = (e) => { if (!this.props.closeOnEscape) return if (keyboardKey.getCode(e) !== keyboardKey.Escape) return e.preventDefault() debug('closeOnEscape()') this.close(e) } moveSelectionOnKeyDown = (e) => { debug('moveSelectionOnKeyDown()', keyboardKey.getKey(e)) const { multiple, selectOnNavigation } = this.props const moves = { [keyboardKey.ArrowDown]: 1, [keyboardKey.ArrowUp]: -1, } const move = moves[keyboardKey.getCode(e)] if (move === undefined) return e.preventDefault() this.moveSelectionBy(move) if (!multiple && selectOnNavigation) this.makeSelectedItemActive(e) } openOnSpace = (e) => { debug('openOnSpace()') if (keyboardKey.getCode(e) !== keyboardKey.Spacebar) return e.preventDefault() this.open(e) } openOnArrow = (e) => { debug('openOnArrow()') const code = keyboardKey.getCode(e) if (!_.includes([keyboardKey.ArrowDown, keyboardKey.ArrowUp], code)) return if (this.state.open) return e.preventDefault() this.open(e) } makeSelectedItemActive = (e) => { const { open, value } = this.state const { multiple } = this.props const item = this.getSelectedItem() const selectedValue = _.get(item, 'value') // prevent selecting null if there was no selected item value // prevent selecting duplicate items when the dropdown is closed if (_.isNil(selectedValue) || !open) return // state value may be undefined const newValue = multiple ? _.union(this.state.value, [selectedValue]) : selectedValue const valueHasChanged = multiple ? !!_.difference(newValue, value).length : newValue !== value if (valueHasChanged) { // notify the onChange prop that the user is trying to change value this.setValue(newValue) this.setSelectedIndex(newValue) this.handleChange(e, newValue) // Heads up! This event handler should be called after `onChange` // Notify the onAddItem prop if this is a new value if (item['data-additional']) { _.invoke(this.props, 'onAddItem', e, { ...this.props, value: selectedValue }) } } } selectItemOnEnter = (e) => { debug('selectItemOnEnter()', keyboardKey.getKey(e)) const { search } = this.props if (keyboardKey.getCode(e) !== keyboardKey.Enter) return e.preventDefault() const optionSize = _.size(this.getMenuOptions()) if (search && optionSize === 0) return this.makeSelectedItemActive(e) this.closeOnChange(e) this.clearSearchQuery() if (search) _.invoke(this.searchRef.current, 'focus') } removeItemOnBackspace = (e) => { debug('removeItemOnBackspace()', keyboardKey.getKey(e)) const { multiple, search } = this.props const { searchQuery, value } = this.state if (keyboardKey.getCode(e) !== keyboardKey.Backspace) return if (searchQuery || !search || !multiple || _.isEmpty(value)) return e.preventDefault() // remove most recent value const newValue = _.dropRight(value) this.setValue(newValue) this.setSelectedIndex(newValue) this.handleChange(e, newValue) } closeOnDocumentClick = (e) => { debug('closeOnDocumentClick()') debug(e) if (!this.props.closeOnBlur) return // If event happened in the dropdown, ignore it if (this.ref.current && doesNodeContainClick(this.ref.current, e)) return this.close() } // ---------------------------------------- // Component Event Handlers // ---------------------------------------- handleMouseDown = (e) => { debug('handleMouseDown()') this.isMouseDown = true _.invoke(this.props, 'onMouseDown', e, this.props) document.addEventListener('mouseup', this.handleDocumentMouseUp) } handleDocumentMouseUp = () => { debug('handleDocumentMouseUp()') this.isMouseDown = false document.removeEventListener('mouseup', this.handleDocumentMouseUp) } handleClick = (e) => { debug('handleClick()', e) const { minCharacters, search } = this.props const { open, searchQuery } = this.state _.invoke(this.props, 'onClick', e, this.props) // prevent closeOnDocumentClick() e.stopPropagation() if (!search) return this.toggle(e) if (open) { _.invoke(this.searchRef.current, 'focus') return } if (searchQuery.length >= minCharacters || minCharacters === 1) { this.open(e) return } _.invoke(this.searchRef.current, 'focus') } handleIconClick = (e) => { const { clearable } = this.props const hasValue = this.hasValue() debug('handleIconClick()', { e, clearable, hasValue }) _.invoke(this.props, 'onClick', e, this.props) // prevent handleClick() e.stopPropagation() if (clearable && hasValue) { this.clearValue(e) } else { this.toggle(e) } } handleItemClick = (e, item) => { debug('handleItemClick()', item) const { multiple, search } = this.props const { value: currentValue } = this.state const { value } = item // prevent toggle() in handleClick() e.stopPropagation() // prevent closeOnDocumentClick() if multiple or item is disabled if (multiple || item.disabled) e.nativeEvent.stopImmediatePropagation() if (item.disabled) return const isAdditionItem = item['data-additional'] const newValue = multiple ? _.union(this.state.value, [value]) : value const valueHasChanged = multiple ? !!_.difference(newValue, currentValue).length : newValue !== currentValue // notify the onChange prop that the user is trying to change value if (valueHasChanged) { this.setValue(newValue) this.setSelectedIndex(value) this.handleChange(e, newValue) } this.clearSearchQuery(value) this.closeOnChange(e) // Heads up! This event handler should be called after `onChange` // Notify the onAddItem prop if this is a new value if (isAdditionItem) _.invoke(this.props, 'onAddItem', e, { ...this.props, value }) if (search) _.invoke(this.searchRef.current, 'focus') } handleFocus = (e) => { debug('handleFocus()') const { focus } = this.state if (focus) return _.invoke(this.props, 'onFocus', e, this.props) this.setState({ focus: true }) } handleBlur = (e) => { debug('handleBlur()') // Heads up! Don't remove this. // https://github.com/Semantic-Org/Semantic-UI-React/issues/1315 const currentTarget = _.get(e, 'currentTarget') if (currentTarget && currentTarget.contains(document.activeElement)) return const { closeOnBlur, multiple, selectOnBlur } = this.props // do not "blur" when the mouse is down inside of the Dropdown if (this.isMouseDown) return _.invoke(this.props, 'onBlur', e, this.props) if (selectOnBlur && !multiple) { this.makeSelectedItemActive(e) if (closeOnBlur) this.close() } this.setState({ focus: false }) this.clearSearchQuery() } handleSearchChange = (e, { value }) => { debug('handleSearchChange()') debug(value) // prevent propagating to this.props.onChange() e.stopPropagation() const { minCharacters } = this.props const { open } = this.state const newQuery = value _.invoke(this.props, 'onSearchChange', e, { ...this.props, searchQuery: newQuery }) this.trySetState({ searchQuery: newQuery }, { selectedIndex: 0 }) // open search dropdown on search query if (!open && newQuery.length >= minCharacters) { this.open() return } // close search dropdown if search query is too small if (open && minCharacters !== 1 && newQuery.length < minCharacters) this.close() } // ---------------------------------------- // Getters // ---------------------------------------- getKeyAndValues = (options) => options ? options.map((option) => _.pick(option, ['key', 'value'])) : options // There are times when we need to calculate the options based on a value // that hasn't yet been persisted to state. getMenuOptions = ( value = this.state.value, options = this.props.options, searchQuery = this.state.searchQuery, ) => { const { additionLabel, additionPosition, allowAdditions, deburr, multiple, search } = this.props let filteredOptions = options // filter out active options if (multiple) { filteredOptions = _.filter(filteredOptions, (opt) => !_.includes(value, opt.value)) } // filter by search query if (search && searchQuery) { if (_.isFunction(search)) { filteredOptions = search(filteredOptions, searchQuery) } else { // remove diacritics on search input and options, if deburr prop is set const strippedQuery = deburr ? _.deburr(searchQuery) : searchQuery const re = new RegExp(_.escapeRegExp(strippedQuery), 'i') filteredOptions = _.filter(filteredOptions, (opt) => re.test(deburr ? _.deburr(opt.text) : opt.text), ) } } // insert the "add" item if ( allowAdditions && search && searchQuery && !_.some(filteredOptions, { text: searchQuery }) ) { const additionLabelElement = React.isValidElement(additionLabel) ? React.cloneElement(additionLabel, { key: 'addition-label' }) : additionLabel || '' const addItem = { key: 'addition', // by using an array, we can pass multiple elements, but when doing so // we must specify a `key` for React to know which one is which text: [additionLabelElement, <b key='addition-query'>{searchQuery}</b>], value: searchQuery, className: 'addition', 'data-additional': true, } if (additionPosition === 'top') filteredOptions.unshift(addItem) else filteredOptions.push(addItem) } return filteredOptions } getSelectedItem = () => { const { selectedIndex } = this.state const options = this.getMenuOptions() return _.get(options, `[${selectedIndex}]`) } getEnabledIndices = (givenOptions) => { const options = givenOptions || this.getMenuOptions() return _.reduce( options, (memo, item, index) => { if (!item.disabled) memo.push(index) return memo }, [], ) } getItemByValue = (value) => { const { options } = this.props return _.find(options, { value }) } getMenuItemIndexByValue = (value, givenOptions) => { const options = givenOptions || this.getMenuOptions() return _.findIndex(options, ['value', value]) } getDropdownAriaOptions = () => { const { loading, disabled, search, multiple } = this.props const { open } = this.state const ariaOptions = { role: search ? 'combobox' : 'listbox', 'aria-busy': loading, 'aria-disabled': disabled, 'aria-expanded': !!open, } if (ariaOptions.role === 'listbox') { ariaOptions['aria-multiselectable'] = multiple } return ariaOptions } getDropdownMenuAriaOptions() { const { search, multiple } = this.props const ariaOptions = {} if (search) { ariaOptions['aria-multiselectable'] = multiple ariaOptions.role = 'listbox' } return ariaOptions } // ---------------------------------------- // Setters // ---------------------------------------- clearSearchQuery = (value) => { debug('clearSearchQuery()') const { searchQuery } = this.state if (searchQuery === undefined || searchQuery === '') return this.trySetState({ searchQuery: '' }) this.setSelectedIndex(value, undefined, '') } setValue = (value) => { debug('setValue()', value) this.trySetState({ value }) } setSelectedIndex = ( value = this.state.value, optionsProps = this.props.options, searchQuery = this.state.searchQuery, ) => { const { multiple } = this.props const { selectedIndex } = this.state const options = this.getMenuOptions(value, optionsProps, searchQuery) const enabledIndicies = this.getEnabledIndices(options) let newSelectedIndex // update the selected index if (!selectedIndex || selectedIndex < 0) { const firstIndex = enabledIndicies[0] // Select the currently active item, if none, use the first item. // Multiple selects remove active items from the list, // their initial selected index should be 0. newSelectedIndex = multiple ? firstIndex : this.getMenuItemIndexByValue(value, options) || enabledIndicies[0] } else if (multiple) { // multiple selects remove options from the menu as they are made active // keep the selected index within range of the remaining items if (selectedIndex >= options.length - 1) { newSelectedIndex = enabledIndicies[enabledIndicies.length - 1] } } else { const activeIndex = this.getMenuItemIndexByValue(value, options) // regular selects can only have one active item // set the selected index to the currently active item newSelectedIndex = _.includes(enabledIndicies, activeIndex) ? activeIndex : undefined } if (!newSelectedIndex || newSelectedIndex < 0) { newSelectedIndex = enabledIndicies[0] } this.setState({ selectedIndex: newSelectedIndex }) } handleLabelClick = (e, labelProps) => { debug('handleLabelClick()') // prevent focusing search input on click e.stopPropagation() this.setState({ selectedLabel: labelProps.value }) _.invoke(this.props, 'onLabelClick', e, labelProps) } handleLabelRemove = (e, labelProps) => { debug('handleLabelRemove()') // prevent focusing search input on click e.stopPropagation() const { value } = this.state const newValue = _.without(value, labelProps.value) debug('label props:', labelProps) debug('current value:', value) debug('remove value:', labelProps.value) debug('new value:', newValue) this.setValue(newValue) this.setSelectedIndex(newValue) this.handleChange(e, newValue) } moveSelectionBy = (offset, startIndex = this.state.selectedIndex) => { debug('moveSelectionBy()') debug(`offset: ${offset}`) const options = this.getMenuOptions() // Prevent infinite loop // TODO: remove left part of condition after children API will be removed if (options === undefined || _.every(options, 'disabled')) return const lastIndex = options.length - 1 const { wrapSelection } = this.props // next is after last, wrap to beginning // next is before first, wrap to end let nextIndex = startIndex + offset // if 'wrapSelection' is set to false and selection is after last or before first, it just does not change if (!wrapSelection && (nextIndex > lastIndex || nextIndex < 0)) { nextIndex = startIndex } else if (nextIndex > lastIndex) nextIndex = 0 else if (nextIndex < 0) nextIndex = lastIndex if (options[nextIndex].disabled) { this.moveSelectionBy(offset, nextIndex) return } this.setState({ selectedIndex: nextIndex }) this.scrollSelectedItemIntoView() } // ---------------------------------------- // Overrides // ---------------------------------------- handleIconOverrides = (predefinedProps) => { const { clearable } = this.props const classes = cx(clearable && this.hasValue() && 'clear', predefinedProps.className) return { className: classes, onClick: (e) => { _.invoke(predefinedProps, 'onClick', e, predefinedProps) this.handleIconClick(e) }, } } // ---------------------------------------- // Helpers // ---------------------------------------- clearValue = (e) => { const { multiple } = this.props const newValue = multiple ? [] : '' this.setValue(newValue) this.setSelectedIndex(newValue) this.handleChange(e, newValue) } computeSearchInputTabIndex = () => { const { disabled, tabIndex } = this.props if (!_.isNil(tabIndex)) return tabIndex return disabled ? -1 : 0 } computeSearchInputWidth = () => { const { searchQuery } = this.state if (this.sizerRef.current && searchQuery) { // resize the search input, temporarily show the sizer so we can measure it this.sizerRef.current.style.display = 'inline' this.sizerRef.current.textContent = searchQuery const searchWidth = Math.ceil(this.sizerRef.current.getBoundingClientRect().width) this.sizerRef.current.style.removeProperty('display') return searchWidth } } computeTabIndex = () => { const { disabled, search, tabIndex } = this.props // don't set a root node tabIndex as the search input has its own tabIndex if (search) return undefined if (disabled) return -1 return _.isNil(tabIndex) ? 0 : tabIndex } handleSearchInputOverrides = (predefinedProps) => ({ onChange: (e, inputProps) => { _.invoke(predefinedProps, 'onChange', e, inputProps) this.handleSearchChange(e, inputProps) }, }) hasValue = () => { const { multiple } = this.props const { value } = this.state return multiple ? !_.isEmpty(value) : !_.isNil(value) && value !== '' } // ---------------------------------------- // Behavior // ---------------------------------------- scrollSelectedItemIntoView = () => { debug('scrollSelectedItemIntoView()') if (!this.ref.current) return const menu = this.ref.current.querySelector('.menu.visible') if (!menu) return const item = menu.querySelector('.item.selected') if (!item) return debug(`menu: ${menu}`) debug(`item: ${item}`) const isOutOfUpperView = item.offsetTop < menu.scrollTop const isOutOfLowerView = item.offsetTop + item.clientHeight > menu.scrollTop + menu.clientHeight if (isOutOfUpperView) { menu.scrollTop = item.offsetTop } else if (isOutOfLowerView) { // eslint-disable-next-line no-mixed-operators menu.scrollTop = item.offsetTop + item.clientHeight - menu.clientHeight } } setOpenDirection = () => { if (!this.ref.current) return const menu = this.ref.current.querySelector('.menu.visible') if (!menu) return const dropdownRect = this.ref.current.getBoundingClientRect() const menuHeight = menu.clientHeight const spaceAtTheBottom = document.documentElement.clientHeight - dropdownRect.top - dropdownRect.height - menuHeight const spaceAtTheTop = dropdownRect.top - menuHeight const upward = spaceAtTheBottom < 0 && spaceAtTheTop > spaceAtTheBottom // set state only if there's a relevant difference if (!upward !== !this.state.upward) { this.trySetState({ upward }) } } open = (e) => { const { disabled, open, search } = this.props debug('open()', { disabled, open, search }) if (disabled) return if (search) _.invoke(this.searchRef.current, 'focus') _.invoke(this.props, 'onOpen', e, this.props) this.trySetState({ open: true }) this.scrollSelectedItemIntoView() } close = (e) => { const { open } = this.state debug('close()', { open }) if (open) { _.invoke(this.props, 'onClose', e, this.props) this.trySetState({ open: false }) } } handleClose = () => { debug('handleClose()') const hasSearchFocus = document.activeElement === this.searchRef.current // https://github.com/Semantic-Org/Semantic-UI-React/issues/627 // Blur the Dropdown on close so it is blurred after selecting an item. // This is to prevent it from re-opening when switching tabs after selecting an item. if (!hasSearchFocus) { this.ref.current.blur() } const hasDropdownFocus = document.activeElement === this.ref.current const hasFocus = hasSearchFocus || hasDropdownFocus // We need to keep the virtual model in sync with the browser focus change // https://github.com/Semantic-Org/Semantic-UI-React/issues/692 this.setState({ focus: hasFocus }) } toggle = (e) => (this.state.open ? this.close(e) : this.open(e)) // ---------------------------------------- // Render // ---------------------------------------- renderText = () => { const { multiple, placeholder, search, text } = this.props const { searchQuery, value, open } = this.state const hasValue = this.hasValue() const classes = cx( placeholder && !hasValue && 'default', 'text', search && searchQuery && 'filtered', ) let _text = placeholder if (text) { _text = text } else if (open && !multiple) { _text = _.get(this.getSelectedItem(), 'text') } else if (hasValue) { _text = _.get(this.getItemByValue(value), 'text') } return ( <div className={classes} role='alert' aria-live='polite' aria-atomic> {_text} </div> ) } renderSearchInput = () => { const { search, searchInput } = this.props const { searchQuery } = this.state return ( search && ( <Ref innerRef={this.searchRef}> {DropdownSearchInput.create(searchInput, { defaultProps: { style: { width: this.computeSearchInputWidth() }, tabIndex: this.computeSearchInputTabIndex(), value: searchQuery, }, overrideProps: this.handleSearchInputOverrides, })} </Ref> ) ) } renderSearchSizer = () => { const { search, multiple } = this.props return search && multiple && <span className='sizer' ref={this.sizerRef} /> } renderLabels = () => { debug('renderLabels()') const { multiple, renderLabel } = this.props const { selectedLabel, value } = this.state if (!multiple || _.isEmpty(value)) { return } const selectedItems = _.map(value, this.getItemByValue) debug('selectedItems', selectedItems) // if no item could be found for a given state value the selected item will be undefined // compact the selectedItems so we only have actual objects left return _.map(_.compact(selectedItems), (item, index) => { const defaultProps = { active: item.value === selectedLabel, as: 'a', key: getKeyOrValue(item.key, item.value), onClick: this.handleLabelClick, onRemove: this.handleLabelRemove, value: item.value, } return Label.create(renderLabel(item, index, defaultProps), { defaultProps }) }) } renderOptions = () => { const { lazyLoad, multiple, search, noResultsMessage } = this.props const { open, selectedIndex, value } = this.state // lazy load, only render options when open if (lazyLoad && !open) return null const options = this.getMenuOptions() if (noResultsMessage !== null && search && _.isEmpty(options)) { return <div className='message'>{noResultsMessage}</div> } const isActive = multiple ? (optValue) => _.includes(value, optValue) : (optValue) => optValue === value return _.map(options, (opt, i) => DropdownItem.create({ active: isActive(opt.value), onClick: this.handleItemClick, selected: selectedIndex === i, ...opt, key: getKeyOrValue(opt.key, opt.value), // Needed for handling click events on disabled items style: { ...opt.style, pointerEvents: 'all' }, }), ) } renderMenu = () => { const { children, direction, header } = this.props const { open } = this.state const ariaOptions = this.getDropdownMenuAriaOptions() // single menu child if (!childrenUtils.isNil(children)) { const menuChild = Children.only(children) const className = cx(direction, useKeyOnly(open, 'visible'), menuChild.props.className) return cloneElement(menuChild, { className, ...ariaOptions }) } return ( <DropdownMenu {...ariaOptions} direction={direction} open={open}> {DropdownHeader.create(header, { autoGenerateKey: false })} {this.renderOptions()} </DropdownMenu> ) } render() { debug('render()') debug('props', this.props) debug('state', this.state) const { basic, button, className, compact, disabled, error, fluid, floating, icon, inline, item, labeled, loading, multiple, pointing, search, selection, scrolling, simple, trigger, } = this.props const { focus, open, upward } = this.state // Classes const classes = cx( 'ui', useKeyOnly(open, 'active visible'), useKeyOnly(disabled, 'disabled'), useKeyOnly(error, 'error'), useKeyOnly(loading, 'loading'), useKeyOnly(basic, 'basic'), useKeyOnly(button, 'button'), useKeyOnly(compact, 'compact'), useKeyOnly(fluid, 'fluid'), useKeyOnly(floating, 'floating'), useKeyOnly(inline, 'inline'), // TODO: consider augmentation to render Dropdowns as Button/Menu, solves icon/link item issues // https://github.com/Semantic-Org/Semantic-UI-React/issues/401#issuecomment-240487229 // TODO: the icon class is only required when a dropdown is a button // useKeyOnly(icon, 'icon'), useKeyOnly(labeled, 'labeled'), useKeyOnly(item, 'item'), useKeyOnly(multiple, 'multiple'), useKeyOnly(search, 'search'), useKeyOnly(selection, 'selection'), useKeyOnly(simple, 'simple'), useKeyOnly(scrolling, 'scrolling'), useKeyOnly(upward, 'upward'), useKeyOrValueAndKey(pointing, 'pointing'), 'dropdown', className, ) const rest = getUnhandledProps(Dropdown, this.props) const ElementType = getElementType(Dropdown, this.props) const ariaOptions = this.getDropdownAriaOptions(ElementType, this.props) return ( <Ref innerRef={this.ref}> <ElementType {...rest} {...ariaOptions} className={classes} onBlur={this.handleBlur} onClick={this.handleClick} onMouseDown={this.handleMouseDown} onFocus={this.handleFocus} onChange={this.handleChange} tabIndex={this.computeTabIndex()} > {this.renderLabels()} {this.renderSearchInput()} {this.renderSearchSizer()} {trigger || this.renderText()} {Icon.create(icon, { overrideProps: this.handleIconOverrides, autoGenerateKey: false, })} {this.renderMenu()} {open && <EventStack name='keydown' on={this.closeOnEscape} />} {open && <EventStack name='keydown' on={this.moveSelectionOnKeyDown} />} {open && <EventStack name='click' on={this.closeOnDocumentClick} />} {open && <EventStack name='keydown' on={this.selectItemOnEnter} />} {focus && <EventStack name='keydown' on={this.removeItemOnBackspace} />} {focus && !open && <EventStack name='keydown' on={this.openOnArrow} />} {focus && !open && <EventStack name='keydown' on={this.openOnSpace} />} </ElementType> </Ref> ) } }