import { getElementType, useAutoControlled, useStyles, useUnhandledProps, ComponentWithAs, useFluentContext, useTelemetry, } from '@fluentui/react-bindings'; import { handleRef, Ref } from '@fluentui/react-component-ref'; import * as customPropTypes from '@fluentui/react-proptypes'; import { indicatorBehavior } from '@fluentui/accessibility'; import * as React from 'react'; import * as PropTypes from 'prop-types'; import * as _ from 'lodash'; import cx from 'classnames'; import { getCode, keyboardKey } from '@fluentui/keyboard-key'; import computeScrollIntoView from 'compute-scroll-into-view'; import { ShorthandRenderFunction, ShorthandValue, ShorthandCollection, FluentComponentStaticProps } from '../../types'; import Downshift, { DownshiftState, StateChangeOptions, A11yStatusMessageOptions, GetMenuPropsOptions, GetPropsCommonOptions, GetInputPropsOptions, GetToggleButtonPropsOptions, GetItemPropsOptions, } from 'downshift'; import { commonPropTypes, UIComponentProps, isFromKeyboard as detectIsFromKeyboard, createShorthand, } from '../../utils'; import { List, ListProps } from '../List/List'; import { DropdownItem, DropdownItemProps } from './DropdownItem'; import { DropdownSelectedItem, DropdownSelectedItemProps } from './DropdownSelectedItem'; import { DropdownSearchInput, DropdownSearchInputProps } from './DropdownSearchInput'; import { Button, ButtonProps } from '../Button/Button'; import { screenReaderContainerStyles } from '../../utils/accessibility/Styles/accessibilityStyles'; import { Box, BoxProps } from '../Box/Box'; import { Portal } from '../Portal/Portal'; import { ALIGNMENTS, POSITIONS, Popper, PositioningProps, PopperShorthandProps, partitionPopperPropsFromShorthand, } from '../../utils/positioner'; export interface DownshiftA11yStatusMessageOptions extends Required> {} export interface DropdownSlotClassNames { clearIndicator: string; container: string; toggleIndicator: string; item: string; itemsList: string; searchInput: string; selectedItem: string; selectedItems: string; triggerButton: string; } export interface DropdownProps extends UIComponentProps, PositioningProps { /** The index of the currently selected item, if the dropdown supports multiple selection. */ activeSelectedIndex?: number; /** A dropdown item can show a check indicator if it is selected. */ checkable?: boolean; /** A slot for a selected indicator in the dropdown list. */ checkableIndicator?: ShorthandValue; /** A dropdown can be clearable to let users remove their selection. */ clearable?: boolean; /** A slot for the clearing indicator. */ clearIndicator?: ShorthandValue; /** The initial value for the index of the currently selected item in a multiple selection. */ defaultActiveSelectedIndex?: number; /** The initial value for 'open' in uncontrolled mode. */ defaultOpen?: boolean; /** The initial list item index to highlight. */ defaultHighlightedIndex?: number; /** The initial value for the search query if the dropdown has `search` enabled. */ defaultSearchQuery?: string; /** The initial value (or value array if the array has multiple selection). */ defaultValue?: ShorthandValue | ShorthandCollection; /** A dropdown can show that it cannot be interacted with. */ disabled?: boolean; /** A dropdown can fill the width of its container. */ fluid?: boolean; /** Object with callbacks for generating announcements for item selection and removal. */ getA11ySelectionMessage?: { /** * Callback that creates custom accessibility message a screen reader narrates on item added to selection. * @param item - Dropdown added element. */ onAdd?: (item: ShorthandValue) => string; /** * Callback that creates custom accessibility message a screen reader narrates on item removed from selection. * @param item - Dropdown removed element. */ onRemove?: (item: ShorthandValue) => string; }; /** * Callback that provides status announcement message with number of items in the list, using Arrow Up/Down keys to navigate through them and, if multiple, using Arrow Left/Right to navigate through selected items. * @param messageGenerationProps - Object with properties to generate message from. See getA11yStatusMessage from Downshift repo. */ getA11yStatusMessage?: (options: DownshiftA11yStatusMessageOptions>) => string; /** A dropdown can highlight the first option when it opens. */ highlightFirstItemOnOpen?: boolean; /** The index of the list item to highlight. */ highlightedIndex?: number; /** A dropdown can be formatted to appear inline next to other elements. */ inline?: boolean; /** A dropdown can have inverted colors. */ inverted?: boolean; /** Array of props for generating list options (Dropdown.Item[]) and selected item labels (Dropdown.SelectedItem[]), if it's a multiple selection. */ items?: ShorthandCollection; /** * A function that converts an item to string. Used when dropdown has `search` enabled. * By default, it: * - returns the `header` property if it exists on an item * - stringifies the item if it is a primitive type */ itemToString?: (item: ShorthandValue) => string; /** Used when comparing two items in multiple selection. Default comparison is by the header prop. */ itemToValue?: (item: ShorthandValue) => any; /** A message to be displayed in the list header. */ headerMessage?: ShorthandValue; /** A slot for dropdown list. */ list?: ShorthandValue; /** A dropdown can show that it is currently loading data. */ loading?: boolean; /** A message to be displayed in the list when the dropdown is loading. */ loadingMessage?: ShorthandValue; /** When selecting an element with Tab, focus stays on the dropdown by default. If true, the focus will jump to next/previous element in DOM. Only available to multiple selection dropdowns. */ moveFocusOnTab?: boolean; /** A dropdown can allow a user to select multiple items. */ multiple?: boolean; /** A message to be displayed in the list when the dropdown has no items. */ noResultsMessage?: ShorthandValue; /** * Called when the dropdown's selected items index change. * @param event - React's original SyntheticEvent. * @param data - All props and the new selected value(s). */ onActiveSelectedIndexChange?: (event: React.MouseEvent | React.KeyboardEvent | null, data: DropdownProps) => void; /** * Called when the dropdown's highlighted index change. * @param event - React's original SyntheticEvent. * @param data - All props and the new selected value(s). */ onHighlightedIndexChange?: (event: React.MouseEvent | React.KeyboardEvent | null, data: DropdownProps) => void; /** * Called when the dropdown opens or closes. * @param event - React's original SyntheticEvent. * @param data - All props, with `open` reflecting the new open state. */ onOpenChange?: (event: React.MouseEvent | React.KeyboardEvent | null, data: DropdownProps) => void; /** * Called when the dropdown's search query changes. * @param event - React's original SyntheticEvent. * @param data - All props, with `searchQuery` reflecting its new value. */ onSearchQueryChange?: (event: React.MouseEvent | React.KeyboardEvent | null, data: DropdownProps) => void; /** * Called when the dropdown's selected item(s) change. * @param event - React's original SyntheticEvent. * @param data - All props and the new selected value(s). */ onChange?: (event: React.MouseEvent | React.KeyboardEvent | null, data: DropdownProps) => void; /** * Called when the focus moves out from dropdown. * @param event - React's original SyntheticEvent. */ onBlur?: (event: React.MouseEvent | React.KeyboardEvent | null) => void; /** A dropdown's open state can be controlled. */ open?: boolean; /** A placeholder message for the input field. */ placeholder?: string; /** * A render function to customize how items are rendered in the dropdown. * * @param Component - The computed component for this slot. * @param props - The computed props for this slot. * @param children - The computed children for this slot. */ renderItem?: ShorthandRenderFunction; /** * A custom render function for the selected item. Only applicable with the `multiple` prop. * * @param Component - The computed component for this slot. * @param props - The computed props for this slot. * @param children - The computed children for this slot. */ renderSelectedItem?: ShorthandRenderFunction; /** A dropdown can have a search field instead of trigger button. Can receive a custom search function that will replace the default equivalent. */ search?: | boolean | ((items: ShorthandCollection, searchQuery: string) => ShorthandCollection); /** A search dropdown's input can be customized. */ searchInput?: ShorthandValue; /** Sets search query value (controlled mode). */ searchQuery?: string; /** Controls the appearance of the indicator that shows/hides the list of items. */ toggleIndicator?: ShorthandValue; /** Controls the appearance of the trigger button if it's a selection dropdown (not a search). */ triggerButton?: ShorthandValue; /** Sets the dropdown's currently selected value(s) in controlled mode. */ value?: ShorthandValue | ShorthandCollection; /** Dropdown can have errors status */ error?: boolean; } export type DropdownStylesProps = Required< Pick > & { focused: boolean; isEmptyClearIndicator: boolean; hasToggleIndicator: boolean; isFromKeyboard: boolean; search: boolean; }; type DropdownStateForInvoke = { activeSelectedIndex?: number | null; highlightedIndex?: number | null; itemIsFromKeyboard?: boolean; open?: boolean; searchQuery?: string; value?: ShorthandCollection; }; export const dropdownClassName = 'ui-dropdown'; export const dropdownSlotClassNames: DropdownSlotClassNames = { clearIndicator: `${dropdownClassName}__clear-indicator`, container: `${dropdownClassName}__container`, toggleIndicator: `${dropdownClassName}__toggle-indicator`, item: `${dropdownClassName}__item`, itemsList: `${dropdownClassName}__items-list`, searchInput: `${dropdownClassName}__searchinput`, selectedItem: `${dropdownClassName}__selecteditem`, selectedItems: `${dropdownClassName}__selected-items`, triggerButton: `${dropdownClassName}__trigger-button`, }; const a11yStatusCleanupTime = 500; const charKeyPressedCleanupTime = 500; /** `normalizedValue` should be normalized always as it can be received from props */ function normalizeValue(multiple: boolean, rawValue: DropdownProps['value']): ShorthandCollection { const normalizedValue = Array.isArray(rawValue) ? rawValue : [rawValue]; return multiple ? normalizedValue : normalizedValue.slice(0, 1); } /** * Used to compute the filtered items (by value and search query) and, if needed, * their string equivalents, in order to be used throughout the component. */ function getFilteredValues( options: Required< Pick > & { value: ShorthandCollection; }, ) { const { items, itemToString, itemToValue, multiple, search, searchQuery, value } = options; const filteredItemsByValue = multiple ? _.differenceBy(items, value, itemToValue) : items; const filteredItemStrings = _.map(filteredItemsByValue, filteredItem => itemToString(filteredItem).toLowerCase()); if (search) { if (_.isFunction(search)) { return { filteredItems: search(filteredItemsByValue, searchQuery), filteredItemStrings, }; } return { filteredItems: filteredItemsByValue.filter( item => itemToString(item) .toLowerCase() .indexOf(searchQuery.toLowerCase()) !== -1, ), filteredItemStrings, }; } return { filteredItems: filteredItemsByValue, filteredItemStrings, }; } const isEmpty = prop => { return typeof prop === 'object' && !prop.props && !_.get(prop, 'children') && !_.get(prop, 'content'); }; /** * A Dropdown allows user to select one or more values from a list of options. * Can be created with search and multi-selection capabilities. * * @accessibility * Implements [ARIA Combo Box](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox) design pattern, uses aria-live to announce state changes. * @accessibilityIssues * [Issue 991203: VoiceOver doesn't narrate properly elements in the input/combobox](https://bugs.chromium.org/p/chromium/issues/detail?id=991203) */ export const Dropdown: ComponentWithAs<'div', DropdownProps> & FluentComponentStaticProps & { Item: typeof DropdownItem; SearchInput: typeof DropdownSearchInput; SelectedItem: typeof DropdownSelectedItem; } = props => { const context = useFluentContext(); const { setStart, setEnd } = useTelemetry(Dropdown.displayName, context.telemetry); setStart(); const { align, 'aria-labelledby': ariaLabelledby, clearable, clearIndicator, checkable, checkableIndicator, className, design, disabled, error, fluid, getA11ySelectionMessage, getA11yStatusMessage, inline, inverted, itemToString, itemToValue, items, highlightFirstItemOnOpen, multiple, headerMessage, moveFocusOnTab, noResultsMessage, loading, loadingMessage, placeholder, position, offset, renderItem, renderSelectedItem, search, searchInput, styles, toggleIndicator, triggerButton, unstable_pinned, variables, } = props; const [list, positioningProps] = partitionPopperPropsFromShorthand(props.list); const buttonRef = React.useRef(); const inputRef = React.useRef() as React.MutableRefObject; const listRef = React.useRef(); const selectedItemsRef = React.useRef(); const containerRef = React.useRef(); const defaultTriggerButtonId = React.useMemo(() => _.uniqueId('dropdown-trigger-button-'), []); const ElementType = getElementType(props); const unhandledProps = useUnhandledProps(Dropdown.handledProps, props); const [activeSelectedIndex, setActiveSelectedIndex] = useAutoControlled({ defaultValue: props.defaultActiveSelectedIndex, initialValue: multiple ? null : undefined, value: props.activeSelectedIndex, }); const [highlightedIndex, setHighlightedIndex] = useAutoControlled({ defaultValue: props.defaultHighlightedIndex, initialValue: highlightFirstItemOnOpen ? 0 : null, value: props.highlightedIndex, }); const [open, setOpen] = useAutoControlled({ defaultValue: props.defaultOpen, initialValue: false, value: props.open, }); const [searchQuery, setSearchQuery] = useAutoControlled({ defaultValue: props.defaultSearchQuery, initialValue: search ? '' : undefined, value: props.searchQuery, }); const [rawValue, setValue] = useAutoControlled({ defaultValue: props.defaultValue, initialValue: [], value: props.value, }); const value = normalizeValue(multiple, rawValue); const [a11ySelectionStatus, setA11ySelectionStatus] = React.useState(''); const [focused, setFocused] = React.useState(false); const [isFromKeyboard, setIsFromKeyboard] = React.useState(false); const [itemIsFromKeyboard, setItemIsFromKeyboard] = React.useState(false); const [startingString, setStartingString] = React.useState(search ? undefined : ''); const { filteredItems, filteredItemStrings } = getFilteredValues({ itemToString, itemToValue, items, multiple, search, searchQuery, value, }); const { classes, styles: resolvedStyles } = useStyles(Dropdown.displayName, { className: dropdownClassName, mapPropsToStyles: () => ({ disabled, error, fluid, focused, isEmptyClearIndicator: isEmpty(clearIndicator), hasToggleIndicator: !!toggleIndicator, inline, inverted, isFromKeyboard, multiple, open, position, search: !!search, }), mapPropsToInlineStyles: () => ({ className, design, styles, variables, }), rtl: context.rtl, }); const clearA11ySelectionMessage = React.useMemo( () => _.debounce(() => { setA11ySelectionStatus(''); }, a11yStatusCleanupTime), [], ); const clearStartingString = React.useMemo( () => _.debounce(() => { setStartingString(''); }, charKeyPressedCleanupTime), [], ); const handleChange = (e: React.SyntheticEvent) => { // Dropdown component doesn't present any `input` component in markup, however all of our // components should handle events transparently. _.invoke(props, 'onChange', e, { ...props, value }); }; const handleOnBlur = (e: React.SyntheticEvent) => { // Dropdown component doesn't present any `input` component in markup, however all of our // components should handle events transparently. if (e.target !== buttonRef.current) { _.invoke(props, 'onBlur', e, props); } }; const renderTriggerButton = (getToggleButtonProps: (options?: GetToggleButtonPropsOptions) => any): JSX.Element => { const content = getSelectedItemAsString(value[0]); const triggerButtonId = triggerButton['id'] || defaultTriggerButtonId; const triggerButtonProps = getToggleButtonProps({ disabled, onFocus: handleTriggerButtonOrListFocus, onBlur: handleTriggerButtonBlur, onKeyDown: e => { handleTriggerButtonKeyDown(e); }, 'aria-label': undefined, 'aria-labelledby': [ariaLabelledby, triggerButtonId].filter(l => !!l).join(' '), }); const { onClick, onFocus, onBlur, onKeyDown, ...restTriggerButtonProps } = triggerButtonProps; return ( {createShorthand(Button, triggerButton, { defaultProps: () => ({ className: dropdownSlotClassNames.triggerButton, content, disabled, id: triggerButtonId, fluid: true, styles: resolvedStyles.triggerButton, ...restTriggerButtonProps, }), overrideProps: (predefinedProps: ButtonProps) => ({ onClick: e => { onClick(e); _.invoke(predefinedProps, 'onClick', e, predefinedProps); }, onFocus: e => { onFocus(e); _.invoke(predefinedProps, 'onFocus', e, predefinedProps); }, onBlur: e => { if (!disabled) { onBlur(e); } _.invoke(predefinedProps, 'onBlur', e, predefinedProps); }, onKeyDown: e => { if (!disabled) { onKeyDown(e); } _.invoke(predefinedProps, 'onKeyDown', e, predefinedProps); }, }), })} ); }; const renderSearchInput = ( accessibilityComboboxProps: Object, highlightedIndex: number, getInputProps: (options?: GetInputPropsOptions) => any, selectItemAtIndex: (index: number, otherStateToSet?: Partial>, cb?: () => void) => void, toggleMenu: () => void, variables, ): JSX.Element => { const noPlaceholder = searchQuery.length > 0 || (multiple && value.length > 0); return DropdownSearchInput.create(searchInput || {}, { defaultProps: () => ({ className: dropdownSlotClassNames.searchInput, placeholder: noPlaceholder ? '' : placeholder, inline, variables, disabled, }), overrideProps: handleSearchInputOverrides( highlightedIndex, selectItemAtIndex, toggleMenu, accessibilityComboboxProps, getInputProps, ), }); }; const renderItemsList = ( highlightedIndex: number, toggleMenu: () => void, selectItemAtIndex: (index: number) => void, getMenuProps: (options?: GetMenuPropsOptions, otherOptions?: GetPropsCommonOptions) => any, getItemProps: (options: GetItemPropsOptions>) => any, getInputProps: (options?: GetInputPropsOptions) => any, ) => { const items = open ? renderItems(getItemProps) : []; const { innerRef, ...accessibilityMenuProps } = getMenuProps({ refKey: 'innerRef' }, { suppressRefError: true }); // If it's just a selection, some attributes and listeners from Downshift input need to go on the menu list. if (!search) { const accessibilityInputProps = getInputProps(); accessibilityMenuProps['aria-activedescendant'] = accessibilityInputProps['aria-activedescendant']; accessibilityMenuProps['onKeyDown'] = e => { handleListKeyDown(e, highlightedIndex, accessibilityInputProps['onKeyDown'], toggleMenu, selectItemAtIndex); }; } return ( { handleRef(listRef, listElement); handleRef(innerRef, listElement); }} > {List.create(list, { defaultProps: () => ({ className: dropdownSlotClassNames.itemsList, ...accessibilityMenuProps, styles: resolvedStyles.list, items, tabIndex: search ? undefined : -1, // needs to be focused when trigger button is activated. 'aria-hidden': !open, }), overrideProps: (predefinedProps: ListProps) => ({ onFocus: (e: React.SyntheticEvent, listProps: ListProps) => { handleTriggerButtonOrListFocus(); _.invoke(predefinedProps, 'onClick', e, listProps); }, onBlur: (e: React.SyntheticEvent, listProps: ListProps) => { handleListBlur(e); _.invoke(predefinedProps, 'onBlur', e, listProps); }, }), })} ); }; const renderItems = (getItemProps: (options: GetItemPropsOptions>) => any) => { const footerItem = renderItemsListFooter(); const headerItem = renderItemsListHeader(); const items = _.map(filteredItems, (item, index) => ({ children: () => { const selected = value.indexOf(item) !== -1; return DropdownItem.create(item, { defaultProps: () => ({ className: dropdownSlotClassNames.item, active: highlightedIndex === index, selected, checkable, checkableIndicator, isFromKeyboard: itemIsFromKeyboard, variables, ...(typeof item === 'object' && !item.hasOwnProperty('key') && { key: (item as any).header, }), }), overrideProps: handleItemOverrides(item, index, getItemProps, selected), render: renderItem, }); }, })); if (footerItem) { items.push(footerItem); } return headerItem ? [headerItem, ...items] : items; }; const renderItemsListHeader = () => { if (headerMessage) { return { children: () => DropdownItem.create(headerMessage, { defaultProps: () => ({ key: 'items-list-footer-message', styles: resolvedStyles.headerMessage, }), }), }; } return null; }; const renderItemsListFooter = () => { if (loading) { return { children: () => DropdownItem.create(loadingMessage, { defaultProps: () => ({ key: 'loading-message', styles: resolvedStyles.loadingMessage, }), }), }; } if (filteredItems && filteredItems.length === 0) { return { children: () => DropdownItem.create(noResultsMessage, { defaultProps: () => ({ key: 'no-results-message', styles: resolvedStyles.noResultsMessage, }), }), }; } return null; }; const renderSelectedItems = () => { if (value.length === 0) { return null; } return value.map((item: DropdownItemProps, index) => // (!) an item matches DropdownItemProps DropdownSelectedItem.create(item, { defaultProps: () => ({ className: dropdownSlotClassNames.selectedItem, active: isSelectedItemActive(index), variables, ...(typeof item === 'object' && !item.hasOwnProperty('key') && { key: (item as any).header, }), }), overrideProps: handleSelectedItemOverrides(item), render: renderSelectedItem, }), ); }; const downshiftStateReducer = ( state: DownshiftState>, changes: StateChangeOptions>, ) => { const activeElement: Element = context.target.activeElement; switch (changes.type) { case Downshift.stateChangeTypes.blurButton: // Downshift closes the list by default on trigger blur. It does not support the case when dropdown is // single selection and focuses list on trigger click/up/down/space/enter. Treating that here. if (state.isOpen && activeElement === listRef.current) { return {}; // won't change state in this case. } _.invoke(props, 'onBlur', null); default: return changes; } }; const handleStateChange = (changes: StateChangeOptions>) => { const { type } = changes; const newState = {} as DropdownStateForInvoke; switch (type) { case Downshift.stateChangeTypes.changeInput: { const shouldValueChange = changes.inputValue === '' && !multiple && value.length > 0; newState.searchQuery = changes.inputValue; newState.highlightedIndex = highlightFirstItemOnOpen ? 0 : null; if (shouldValueChange) { newState.value = []; } if (open) { // we clear value when in single selection user cleared the query. const shouldMenuClose = changes.inputValue === '' || changes.selectedItem !== undefined; if (shouldMenuClose) { newState.open = false; } } else { newState.open = true; } break; } case Downshift.stateChangeTypes.keyDownEnter: case Downshift.stateChangeTypes.clickItem: const shouldAddHighlightedIndex = !multiple && items && items.length > 0; const isSameItemSelected = changes.selectedItem === undefined; const newValue = isSameItemSelected ? value[0] : changes.selectedItem; newState.searchQuery = getSelectedItemAsString(newValue); newState.open = false; newState.highlightedIndex = shouldAddHighlightedIndex ? items.indexOf(newValue) : null; if (!isSameItemSelected) { newState.value = multiple ? [...value, changes.selectedItem] : [changes.selectedItem]; if (getA11ySelectionMessage && getA11ySelectionMessage.onAdd) { setA11ySelectionMessage(getA11ySelectionMessage.onAdd(newValue)); } } if (multiple) { setTimeout(() => (selectedItemsRef.current.scrollTop = selectedItemsRef.current.scrollHeight), 0); } tryFocusTriggerButton(); break; case Downshift.stateChangeTypes.keyDownEscape: if (search) { newState.searchQuery = ''; if (!multiple) { newState.value = []; } } newState.open = false; newState.highlightedIndex = highlightFirstItemOnOpen ? 0 : null; break; case Downshift.stateChangeTypes.keyDownArrowDown: case Downshift.stateChangeTypes.keyDownArrowUp: if (changes.isOpen !== undefined) { newState.open = changes.isOpen; newState.highlightedIndex = changes.highlightedIndex; if (changes.isOpen) { const highlightedIndexOnArrowKeyOpen = getHighlightedIndexOnArrowKeyOpen(changes); if (_.isNumber(highlightedIndexOnArrowKeyOpen)) { newState.highlightedIndex = highlightedIndexOnArrowKeyOpen; } if (!search) { listRef.current.focus(); } } else { newState.highlightedIndex = null; } } case Downshift.stateChangeTypes['keyDownHome']: case Downshift.stateChangeTypes['keyDownEnd']: if (open && _.isNumber(changes.highlightedIndex)) { newState.highlightedIndex = changes.highlightedIndex; newState.itemIsFromKeyboard = true; } break; case Downshift.stateChangeTypes.mouseUp: if (open) { newState.open = false; newState.highlightedIndex = null; } break; case Downshift.stateChangeTypes.clickButton: case Downshift.stateChangeTypes.keyDownSpaceButton: newState.open = changes.isOpen; if (changes.isOpen) { const highlightedIndexOnArrowKeyOpen = getHighlightedIndexOnArrowKeyOpen(changes); if (_.isNumber(highlightedIndexOnArrowKeyOpen)) { newState.highlightedIndex = highlightedIndexOnArrowKeyOpen; } if (!search) { listRef.current.focus(); } } else { newState.highlightedIndex = null; } break; case Downshift.stateChangeTypes.itemMouseEnter: newState.highlightedIndex = changes.highlightedIndex; newState.itemIsFromKeyboard = false; break; case Downshift.stateChangeTypes.unknown: if (changes.selectedItem) { newState.value = multiple ? [...value, changes.selectedItem] : [changes.selectedItem]; newState.searchQuery = multiple ? '' : changes.inputValue; newState.open = false; newState.highlightedIndex = changes.highlightedIndex; tryFocusTriggerButton(); } else { newState.open = changes.isOpen; } default: break; } if (_.isEmpty(newState)) { return; } const handlers: (keyof DropdownProps)[] = [ newState.highlightedIndex !== undefined && 'onHighlightedIndexChange', newState.open !== undefined && 'onOpenChange', newState.searchQuery !== undefined && 'onSearchQueryChange', newState.value !== undefined && 'onChange', ].filter(Boolean) as (keyof DropdownProps)[]; setStateAndInvokeHandler(handlers, null, newState); }; const isSelectedItemActive = (index: number): boolean => { return index === activeSelectedIndex; }; const handleItemOverrides = ( item: ShorthandValue, index: number, getItemProps: (options: GetItemPropsOptions>) => any, selected: boolean, ) => (predefinedProps: DropdownItemProps) => ({ accessibilityItemProps: { ...getItemProps({ item, index, disabled: item['disabled'], onClick: e => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); _.invoke(predefinedProps, 'onClick', e, predefinedProps); }, }), // for single selection the selected item should have aria-selected, instead of the highlighted ...(!multiple && { 'aria-selected': selected, }), }, }); const handleSelectedItemOverrides = (item: ShorthandValue) => ( predefinedProps: DropdownSelectedItemProps, ) => ({ onRemove: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => { handleSelectedItemRemove(e, item, predefinedProps, dropdownSelectedItemProps); }, onClick: (e: React.SyntheticEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: value.indexOf(item), }); e.stopPropagation(); _.invoke(predefinedProps, 'onClick', e, dropdownSelectedItemProps); }, onKeyDown: (e: React.KeyboardEvent, dropdownSelectedItemProps: DropdownSelectedItemProps) => { handleSelectedItemKeyDown(e, item, predefinedProps, dropdownSelectedItemProps); }, }); const handleSearchInputOverrides = ( highlightedIndex: number, selectItemAtIndex: (index: number, otherStateToSet?: Partial>, cb?: () => void) => void, toggleMenu: () => void, accessibilityComboboxProps: Object, getInputProps: (options?: GetInputPropsOptions) => any, ) => (predefinedProps: DropdownSearchInputProps) => { const handleInputBlur = (e: React.SyntheticEvent, searchInputProps: DropdownSearchInputProps) => { if (!disabled) { setFocused(false); setIsFromKeyboard(detectIsFromKeyboard()); e.nativeEvent['preventDownshiftDefault'] = true; } _.invoke(predefinedProps, 'onInputBlur', e, searchInputProps); }; const handleInputKeyDown = ( e: React.KeyboardEvent, searchInputProps: DropdownSearchInputProps, ) => { if (!disabled) { switch (getCode(e)) { // https://github.com/downshift-js/downshift/issues/1097 // Downshift skips Home/End if Deopdown is opened case keyboardKey.Home: e.nativeEvent['preventDownshiftDefault'] = filteredItems.length === 0; break; case keyboardKey.End: e.nativeEvent['preventDownshiftDefault'] = filteredItems.length === 0; break; case keyboardKey.Tab: e.stopPropagation(); handleTabSelection(e, highlightedIndex, selectItemAtIndex, toggleMenu); break; case keyboardKey.ArrowLeft: e.stopPropagation(); if (!context.rtl) { trySetLastSelectedItemAsActive(); } break; case keyboardKey.ArrowRight: e.stopPropagation(); if (context.rtl) { trySetLastSelectedItemAsActive(); } break; case keyboardKey.Backspace: e.stopPropagation(); tryRemoveItemFromValue(); break; case keyboardKey.Escape: // If dropdown list is open ESC should close it and not propagate to the parent // otherwise event should propagate if (open) { e.stopPropagation(); } default: break; } } _.invoke(predefinedProps, 'onInputKeyDown', e, { ...searchInputProps, highlightedIndex, selectItemAtIndex, }); }; return { // getInputProps adds Downshift handlers. We also add our own by passing them as params to that function. // user handlers were also added to our handlers previously, at the beginning of this function. accessibilityInputProps: { ...getInputProps({ disabled, onBlur: e => { handleInputBlur(e, predefinedProps); }, onKeyDown: e => { handleInputKeyDown(e, predefinedProps); }, onChange: (e: React.ChangeEvent) => { // we prevent the onChange input event to bubble up to our Dropdown handler, // since in Dropdown it gets handled as onSearchQueryChange. e.stopPropagation(); // A state modification should be triggered there otherwise it will go to an another frame and will break // cursor position: // https://github.com/facebook/react/issues/955#issuecomment-469352730 setSearchQuery(e.target.value); }, }), }, // same story as above for getRootProps. accessibilityComboboxProps, inputRef: (node: HTMLInputElement) => { handleRef(predefinedProps.inputRef, node); inputRef.current = node; }, onFocus: (e: React.FocusEvent, searchInputProps: DropdownSearchInputProps) => { if (!disabled) { setFocused(true); setIsFromKeyboard(detectIsFromKeyboard()); } _.invoke(predefinedProps, 'onFocus', e, searchInputProps); }, onInputBlur: (e: React.FocusEvent, searchInputProps: DropdownSearchInputProps) => { handleInputBlur(e, searchInputProps); }, onInputKeyDown: (e: React.KeyboardEvent, searchInputProps: DropdownSearchInputProps) => { handleInputKeyDown(e, searchInputProps); }, }; }; /** * Custom Tab selection logic, at least until Downshift will implement selection on blur. * Also keeps focus on multiple selection dropdown when selecting by Tab. */ const handleTabSelection = ( e: React.SyntheticEvent, highlightedIndex: number, selectItemAtIndex: (highlightedIndex: number) => void, toggleMenu: () => void, ): void => { if (open) { if (!_.isNil(highlightedIndex) && filteredItems.length && !items[highlightedIndex]['disabled']) { selectItemAtIndex(highlightedIndex); if (multiple && !moveFocusOnTab) { e.preventDefault(); } } else { toggleMenu(); } } }; const trySetLastSelectedItemAsActive = () => { if (!multiple || (inputRef.current && inputRef.current.selectionStart !== 0)) { return; } if (value.length > 0) { // If last element was already active, perform a 'reset' of activeSelectedIndex. if (activeSelectedIndex === value.length - 1) { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: value.length - 1, }); } else { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: value.length - 1, }); } } }; const tryRemoveItemFromValue = () => { if ( multiple && (searchQuery === '' || (inputRef.current.selectionStart === 0 && inputRef.current.selectionEnd === 0)) && value.length > 0 ) { removeItemFromValue(); } }; const handleClear = (e: React.SyntheticEvent) => { setStateAndInvokeHandler(['onChange', 'onActiveSelectedIndexChange', 'onHighlightedIndexChange'], e, { activeSelectedIndex: multiple ? null : undefined, highlightedIndex: highlightFirstItemOnOpen ? 0 : null, open: false, searchQuery: search ? '' : undefined, value: [], }); tryFocusSearchInput(); tryFocusTriggerButton(); }; const handleContainerClick = () => { tryFocusSearchInput(); }; const handleTriggerButtonKeyDown = (e: React.KeyboardEvent) => { switch (getCode(e)) { case keyboardKey.ArrowLeft: if (!context.rtl) { trySetLastSelectedItemAsActive(); } return; case keyboardKey.ArrowRight: if (context.rtl) { trySetLastSelectedItemAsActive(); } return; default: return; } }; const handleListKeyDown = ( e: React.KeyboardEvent, highlightedIndex: number, accessibilityInputPropsKeyDown: (e) => any, toggleMenu: () => void, selectItemAtIndex: (index: number) => void, ) => { const keyCode = getCode(e); switch (keyCode) { case keyboardKey.Tab: handleTabSelection(e, highlightedIndex, selectItemAtIndex, toggleMenu); return; case keyboardKey.Escape: accessibilityInputPropsKeyDown(e); tryFocusTriggerButton(); e.stopPropagation(); return; default: const keyString = String.fromCharCode(keyCode); if (/[a-zA-Z0-9]/.test(keyString)) { setHighlightedIndexOnCharKeyDown(keyString); } accessibilityInputPropsKeyDown(e); return; } }; const handleSelectedItemKeyDown = ( e: React.KeyboardEvent, item: ShorthandValue, predefinedProps: DropdownSelectedItemProps, dropdownSelectedItemProps: DropdownSelectedItemProps, ) => { const previousKey = context.rtl ? keyboardKey.ArrowRight : keyboardKey.ArrowLeft; const nextKey = context.rtl ? keyboardKey.ArrowLeft : keyboardKey.ArrowRight; switch (getCode(e)) { case keyboardKey.Delete: case keyboardKey.Backspace: handleSelectedItemRemove(e, item, predefinedProps, dropdownSelectedItemProps); break; case previousKey: if (value.length > 0 && !_.isNil(activeSelectedIndex) && activeSelectedIndex > 0) { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: activeSelectedIndex - 1, }); } break; case nextKey: if (value.length > 0 && !_.isNil(activeSelectedIndex)) { if (activeSelectedIndex < value.length - 1) { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: activeSelectedIndex + 1, }); } else { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: null, }); if (search) { e.preventDefault(); // prevents caret to forward one position in input. inputRef.current.focus(); } else { buttonRef.current.focus(); } } } break; default: break; } _.invoke(predefinedProps, 'onKeyDown', e, dropdownSelectedItemProps); }; const handleTriggerButtonOrListFocus = () => { setFocused(true); setIsFromKeyboard(detectIsFromKeyboard()); }; const handleTriggerButtonBlur = e => { if (listRef.current !== e.relatedTarget) { setFocused(false); setIsFromKeyboard(detectIsFromKeyboard()); } }; const handleListBlur = e => { if (buttonRef.current !== e.relatedTarget) { setFocused(false); setIsFromKeyboard(detectIsFromKeyboard()); } }; /** * Sets highlightedIndex to be the item that starts with the character keys the * user has typed. Only used in non-search dropdowns. * * @param keystring - The string the item needs to start with. It is composed by typing keys in fast succession. */ const setHighlightedIndexOnCharKeyDown = (keyString: string): void => { const newStartingString = `${startingString}${keyString.toLowerCase()}`; let newHighlightedIndex = -1; setStartingString(newStartingString); clearStartingString(); if (_.isNumber(highlightedIndex)) { newHighlightedIndex = _.findIndex( filteredItemStrings, item => item.startsWith(newStartingString), highlightedIndex + (startingString.length > 0 ? 0 : 1), ); } if (newHighlightedIndex < 0) { newHighlightedIndex = _.findIndex(filteredItemStrings, item => item.startsWith(newStartingString)); } if (newHighlightedIndex >= 0) { setStateAndInvokeHandler(['onHighlightedIndexChange'], null, { highlightedIndex: newHighlightedIndex, }); } }; const handleSelectedItemRemove = ( e: React.SyntheticEvent, item: ShorthandValue, predefinedProps: DropdownSelectedItemProps, dropdownSelectedItemProps: DropdownSelectedItemProps, ) => { setStateAndInvokeHandler(['onActiveSelectedIndexChange'], null, { activeSelectedIndex: null, }); removeItemFromValue(item); tryFocusSearchInput(); tryFocusTriggerButton(); _.invoke(predefinedProps, 'onRemove', e, dropdownSelectedItemProps); }; const removeItemFromValue = (item?: ShorthandValue) => { let poppedItem = item; let newValue = [...value]; if (poppedItem) { newValue = newValue.filter(currentElement => currentElement !== item); } else { poppedItem = newValue.pop(); } if (getA11ySelectionMessage && getA11ySelectionMessage.onRemove) { setA11ySelectionMessage(getA11ySelectionMessage.onRemove(poppedItem)); } setStateAndInvokeHandler(['onChange'], null, { value: newValue }); }; /** * Calls setState and invokes event handler exposed to user. * We don't have the event object for most events coming from Downshift se we send an empty event * because we want to keep the event handling interface */ const setStateAndInvokeHandler = ( handlerNames: (keyof DropdownProps)[], event: React.SyntheticEvent, newState: DropdownStateForInvoke, ) => { const proposedValue = _.isNil(newState.value) ? value : newState.value; // `proposedValue` should be normalized for single/multiple variations, `null` condition is // required as first item can be undefined const newValue = multiple ? proposedValue : proposedValue[0] || null; if (newState.hasOwnProperty('activeSelectedIndex')) { setActiveSelectedIndex(newState.activeSelectedIndex); } if (newState.hasOwnProperty('highlightedIndex')) { setHighlightedIndex(newState.highlightedIndex); } if (newState.hasOwnProperty('itemIsFromKeyboard')) { setItemIsFromKeyboard(newState.itemIsFromKeyboard); } if (newState.hasOwnProperty('open')) { setOpen(newState.open); } if (newState.hasOwnProperty('searchQuery')) { setSearchQuery(newState.searchQuery); } if (newState.hasOwnProperty('value')) { setValue(newState.value); } handlerNames.forEach(handlerName => { _.invoke(props, handlerName, event, { ...props, ...newState, value: newValue }); }); }; const tryFocusTriggerButton = () => { if (!search && buttonRef.current) { buttonRef.current.focus(); } }; const tryFocusSearchInput = () => { if (search && inputRef.current) { inputRef.current.focus(); } }; /** * If there is no value we use the placeholder value * otherwise, for single selection we convert the value with itemToString * and for multiple selection we return empty string, the values are rendered by renderSelectedItems */ const getSelectedItemAsString = (value: ShorthandValue): string => { if (!value) { return search ? '' : placeholder; } if (multiple) { return ''; } return itemToString(value); }; const getHighlightedIndexOnArrowKeyOpen = ( changes: StateChangeOptions>, ): number => { const isArrowUp = changes.type === Downshift.stateChangeTypes.keyDownArrowUp; const isArrowDown = changes.type === Downshift.stateChangeTypes.keyDownArrowDown; const itemsLength = filteredItems.length; if (highlightedIndex) { return highlightedIndex; } if (highlightFirstItemOnOpen) { // otherwise, if highlightFirstItemOnOpen prop is provied, highlight first item. return 0; } if (!multiple && !search && value.length > 0) { // in single selection, if there is a selected item, highlight it. const offset = isArrowUp ? -1 : isArrowDown ? 1 : 0; const newHighlightedIndex = items.indexOf(value[0]) + offset; if (newHighlightedIndex >= itemsLength) { return 0; } if (newHighlightedIndex < 0) { return itemsLength - 1; } return newHighlightedIndex; } if (isArrowDown) { return 0; } if (isArrowUp) { return itemsLength - 1; } return null; }; /** * Function that sets and cleans the selection message after it has been set, * so it is not read anymore via virtual cursor. */ const setA11ySelectionMessage = (a11ySelectionStatus: string): void => { setA11ySelectionStatus(a11ySelectionStatus); clearA11ySelectionMessage(); }; React.useEffect(() => { return () => { clearStartingString.cancel(); clearA11ySelectionMessage.cancel(); }; }, [clearA11ySelectionMessage, clearStartingString]); const element = ( { if (node) { const { children } = menu; let nodeToScroll = node; /** * If it's loading downshift doesn't take the last node with loadingMessage * in consideration to scrolld so we need to check if the current is the * antepenultimate and is so scroll the loading into view, same for headerMessage */ if (loading && children[children.length - 2] === node) { nodeToScroll = children[children.length - 1] as HTMLElement; } else if (headerMessage && children[1] === node) { nodeToScroll = children[0] as HTMLElement; } // Replicating same config that Downshift uses const actions = computeScrollIntoView(nodeToScroll, { scrollMode: 'if-needed', block: 'nearest', inline: 'nearest', }); actions.forEach(({ el, top, left }) => { el.scrollTop = top; el.scrollLeft = left; }); } }} getA11yStatusMessage={getA11yStatusMessage} highlightedIndex={highlightedIndex} onStateChange={handleStateChange} labelId={ariaLabelledby} environment={context.target?.defaultView} inputId={searchInput && searchInput['id'] ? searchInput['id'] : undefined} > {({ getInputProps, getItemProps, getMenuProps, getRootProps, getToggleButtonProps, toggleMenu, highlightedIndex, selectItemAtIndex, }) => { const { innerRef, ...accessibilityRootPropsRest } = getRootProps( { refKey: 'innerRef' }, { suppressRefError: true }, ); const showClearIndicator = clearable && value.length > 0; return (
{multiple && renderSelectedItems()} {search ? renderSearchInput( accessibilityRootPropsRest, highlightedIndex, getInputProps, selectItemAtIndex, toggleMenu, variables, ) : renderTriggerButton(getToggleButtonProps)}
{showClearIndicator ? Box.create(clearIndicator, { defaultProps: () => ({ className: dropdownSlotClassNames.clearIndicator, styles: resolvedStyles.clearIndicator, accessibility: indicatorBehavior, ...(!search && { tabIndex: 0, role: 'button' }), }), overrideProps: (predefinedProps: BoxProps) => ({ onClick: (e: React.SyntheticEvent) => { _.invoke(predefinedProps, 'onClick', e); handleClear(e); }, }), }) : Box.create(toggleIndicator, { defaultProps: () => ({ className: dropdownSlotClassNames.toggleIndicator, styles: resolvedStyles.toggleIndicator, accessibility: indicatorBehavior, }), overrideProps: (predefinedProps: BoxProps) => ({ onClick: e => { if (!disabled) { getToggleButtonProps({ disabled }).onClick(e); } _.invoke(predefinedProps, 'onClick', e); }, }), })} {renderItemsList( highlightedIndex, toggleMenu, selectItemAtIndex, getMenuProps, getItemProps, getInputProps, )}
); }}
{a11ySelectionStatus}
); setEnd(); return element; }; Dropdown.displayName = 'Dropdown'; Dropdown.propTypes = { ...commonPropTypes.createCommon({ accessibility: false, children: false, content: false, }), activeSelectedIndex: PropTypes.number, align: PropTypes.oneOf(ALIGNMENTS), checkable: PropTypes.bool, checkableIndicator: customPropTypes.shorthandAllowingChildren, clearable: PropTypes.bool, clearIndicator: customPropTypes.shorthandAllowingChildren, defaultActiveSelectedIndex: PropTypes.number, defaultOpen: PropTypes.bool, defaultHighlightedIndex: PropTypes.number, defaultSearchQuery: PropTypes.string, defaultValue: PropTypes.oneOfType([customPropTypes.itemShorthand, customPropTypes.collectionShorthand]), disabled: PropTypes.bool, error: PropTypes.bool, fluid: PropTypes.bool, getA11ySelectionMessage: PropTypes.object, getA11yStatusMessage: PropTypes.func, highlightFirstItemOnOpen: PropTypes.bool, highlightedIndex: PropTypes.number, inline: PropTypes.bool, inverted: PropTypes.bool, items: customPropTypes.collectionShorthand, itemToString: PropTypes.func, itemToValue: PropTypes.func, headerMessage: customPropTypes.itemShorthand, list: customPropTypes.itemShorthand, loading: PropTypes.bool, loadingMessage: customPropTypes.itemShorthand, moveFocusOnTab: PropTypes.bool, multiple: PropTypes.bool, noResultsMessage: customPropTypes.itemShorthand, offset: PropTypes.oneOfType([ PropTypes.func, PropTypes.arrayOf(PropTypes.number) as PropTypes.Requireable<[number, number]>, ]), onOpenChange: PropTypes.func, onSearchQueryChange: PropTypes.func, onBlur: PropTypes.func, onChange: PropTypes.func, onActiveSelectedIndexChange: PropTypes.func, onHighlightedIndexChange: PropTypes.func, open: PropTypes.bool, placeholder: PropTypes.string, position: PropTypes.oneOf(POSITIONS), renderItem: PropTypes.func, renderSelectedItem: PropTypes.func, search: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), searchQuery: PropTypes.string, searchInput: customPropTypes.itemShorthand, toggleIndicator: customPropTypes.shorthandAllowingChildren, triggerButton: customPropTypes.itemShorthand, unstable_pinned: PropTypes.bool, value: PropTypes.oneOfType([customPropTypes.itemShorthand, customPropTypes.collectionShorthand]), }; Dropdown.handledProps = Object.keys(Dropdown.propTypes) as any; Dropdown.defaultProps = { align: 'start', clearIndicator: {}, itemToString: item => { if (!item || React.isValidElement(item)) { return ''; } // targets DropdownItem shorthand objects return (item as any).header || String(item); }, itemToValue: item => { if (!item || React.isValidElement(item)) { return ''; } // targets DropdownItem shorthand objects return (item as any).header || String(item); }, list: {}, position: 'below', toggleIndicator: {}, triggerButton: {}, }; Dropdown.Item = DropdownItem; Dropdown.SearchInput = DropdownSearchInput; Dropdown.SelectedItem = DropdownSelectedItem;