UNPKG

react-select

Version:

A Select control built with and for ReactJS

1,671 lines (1,544 loc) 55.9 kB
// @flow import React, { Component, type ElementRef, type Node } from 'react'; import memoizeOne from 'memoize-one'; import { MenuPlacer } from './components/Menu'; import isEqual from './internal/react-fast-compare'; import { createFilter } from './filters'; import { A11yText, DummyInput, ScrollBlock, ScrollCaptor, } from './internal/index'; import { valueFocusAriaMessage, optionFocusAriaMessage, resultsAriaMessage, valueEventAriaMessage, instructionsAriaMessage, type InstructionsContext, type ValueEventContext, } from './accessibility/index'; import { classNames, cleanValue, isTouchCapable, isMobileDevice, noop, scrollIntoView, isDocumentElement, } from './utils'; import { formatGroupLabel, getOptionLabel, getOptionValue, isOptionDisabled, } from './builtins'; import { defaultComponents, type PlaceholderOrValue, type SelectComponents, type SelectComponentsConfig, } from './components/index'; import { defaultStyles, type StylesConfig } from './styles'; import { defaultTheme, type ThemeConfig } from './theme'; import type { ActionMeta, ActionTypes, FocusDirection, FocusEventHandler, GroupType, InputActionMeta, KeyboardEventHandler, MenuPlacement, MenuPosition, OptionsType, OptionType, ValueType, } from './types'; type MouseOrTouchEvent = | SyntheticMouseEvent<HTMLElement> | SyntheticTouchEvent<HTMLElement>; type FormatOptionLabelContext = 'menu' | 'value'; type FormatOptionLabelMeta = { context: FormatOptionLabelContext, inputValue: string, selectValue: ValueType, }; export type Props = { /* Aria label (for assistive tech) */ 'aria-label'?: string, /* HTML ID of an element that should be used as the label (for assistive tech) */ 'aria-labelledby'?: string, /* Focus the control when it is mounted */ autoFocus?: boolean, /* Remove the currently focused option when the user presses backspace */ backspaceRemovesValue: boolean, /* Remove focus from the input when the user selects an option (handy for dismissing the keyboard on touch devices) */ blurInputOnSelect: boolean, /* When the user reaches the top/bottom of the menu, prevent scroll on the scroll-parent */ captureMenuScroll: boolean, /* Sets a className attribute on the outer component */ className?: string, /* If provided, all inner components will be given a prefixed className attribute. This is useful when styling via CSS classes instead of the Styles API approach. */ classNamePrefix?: string | null, /* Close the select menu when the user selects an option */ closeMenuOnSelect: boolean, /* If `true`, close the select menu when the user scrolls the document/body. If a function, takes a standard javascript `ScrollEvent` you return a boolean: `true` => The menu closes `false` => The menu stays open This is useful when you have a scrollable modal and want to portal the menu out, but want to avoid graphical issues. */ closeMenuOnScroll: boolean | EventListener, /* This complex object includes all the compositional components that are used in `react-select`. If you wish to overwrite a component, pass in an object with the appropriate namespace. If you only wish to restyle a component, we recommend using the `styles` prop instead. For a list of the components that can be passed in, and the shape that will be passed to them, see [the components docs](/components) */ components: SelectComponentsConfig, /* Whether the value of the select, e.g. SingleValue, should be displayed in the control. */ controlShouldRenderValue: boolean, /* Delimiter used to join multiple values into a single HTML Input value */ delimiter?: string, /* Clear all values when the user presses escape AND the menu is closed */ escapeClearsValue: boolean, /* Custom method to filter whether an option should be displayed in the menu */ filterOption: | (({ label: string, value: string, data: OptionType }, string) => boolean) | null, /* Formats group labels in the menu as React components An example can be found in the [Replacing builtins](/advanced#replacing-builtins) documentation. */ formatGroupLabel: typeof formatGroupLabel, /* Formats option labels in the menu and control as React components */ formatOptionLabel?: (OptionType, FormatOptionLabelMeta) => Node, /* Resolves option data to a string to be displayed as the label by components */ getOptionLabel: typeof getOptionLabel, /* Resolves option data to a string to compare options and specify value attributes */ getOptionValue: typeof getOptionValue, /* Hide the selected option from the menu */ hideSelectedOptions?: boolean, /* The id to set on the SelectContainer component. */ id?: string, /* The value of the search input */ inputValue: string, /* The id of the search input */ inputId?: string, /* Define an id prefix for the select components e.g. {your-id}-value */ instanceId?: number | string, /* Is the select value clearable */ isClearable?: boolean, /* Is the select disabled */ isDisabled: boolean, /* Is the select in a state of loading (async) */ isLoading: boolean, /* Override the built-in logic to detect whether an option is disabled An example can be found in the [Replacing builtins](/advanced#replacing-builtins) documentation. */ isOptionDisabled: (OptionType, OptionsType) => boolean | false, /* Override the built-in logic to detect whether an option is selected */ isOptionSelected?: (OptionType, OptionsType) => boolean, /* Support multiple selected options */ isMulti: boolean, /* Is the select direction right-to-left */ isRtl: boolean, /* Whether to enable search functionality */ isSearchable: boolean, /* Async: Text to display when loading options */ loadingMessage: ({ inputValue: string }) => string | null, /* Minimum height of the menu before flipping */ minMenuHeight: number, /* Maximum height of the menu before scrolling */ maxMenuHeight: number, /* Whether the menu is open */ menuIsOpen: boolean, /* Default placement of the menu in relation to the control. 'auto' will flip when there isn't enough space below the control. */ menuPlacement: MenuPlacement, /* The CSS position value of the menu, when "fixed" extra layout management is required */ menuPosition: MenuPosition, /* Whether the menu should use a portal, and where it should attach An example can be found in the [Portaling](/advanced#portaling) documentation */ menuPortalTarget?: HTMLElement, /* Whether to block scroll events when the menu is open */ menuShouldBlockScroll: boolean, /* Whether the menu should be scrolled into view when it opens */ menuShouldScrollIntoView: boolean, /* Name of the HTML Input (optional - without this, no input will be rendered) */ name?: string, /* Text to display when there are no options */ noOptionsMessage: ({ inputValue: string }) => Node | null, /* Handle blur events on the control */ onBlur?: FocusEventHandler, /* Handle change events on the select */ onChange: (ValueType, ActionMeta) => void, /* Handle focus events on the control */ onFocus?: FocusEventHandler, /* Handle change events on the input */ onInputChange: (string, InputActionMeta) => void, /* Handle key down events on the select */ onKeyDown?: KeyboardEventHandler, /* Handle the menu opening */ onMenuOpen: () => void, /* Handle the menu closing */ onMenuClose: () => void, /* Fired when the user scrolls to the top of the menu */ onMenuScrollToTop?: (SyntheticEvent<HTMLElement>) => void, /* Fired when the user scrolls to the bottom of the menu */ onMenuScrollToBottom?: (SyntheticEvent<HTMLElement>) => void, /* Allows control of whether the menu is opened when the Select is focused */ openMenuOnFocus: boolean, /* Allows control of whether the menu is opened when the Select is clicked */ openMenuOnClick: boolean, /* Array of options that populate the select menu */ options: OptionsType, /* Number of options to jump in menu when page{up|down} keys are used */ pageSize: number, /* Placeholder for the select value */ placeholder: Node, /* Status to relay to screen readers */ screenReaderStatus: ({ count: number }) => string, /* Style modifier methods A basic example can be found at the bottom of the [Replacing builtins](/advanced#replacing-builtins) documentation. */ styles: StylesConfig, /* Theme modifier method */ theme?: ThemeConfig, /* Sets the tabIndex attribute on the input */ tabIndex: string, /* Select the currently focused option when the user presses tab */ tabSelectsValue: boolean, /* The value of the select; reflected by the selected option */ value: ValueType, }; export const defaultProps = { backspaceRemovesValue: true, blurInputOnSelect: isTouchCapable(), captureMenuScroll: !isTouchCapable(), closeMenuOnSelect: true, closeMenuOnScroll: false, components: {}, controlShouldRenderValue: true, escapeClearsValue: false, filterOption: createFilter(), formatGroupLabel: formatGroupLabel, getOptionLabel: getOptionLabel, getOptionValue: getOptionValue, isDisabled: false, isLoading: false, isMulti: false, isRtl: false, isSearchable: true, isOptionDisabled: isOptionDisabled, loadingMessage: () => 'Loading...', maxMenuHeight: 300, minMenuHeight: 140, menuIsOpen: false, menuPlacement: 'bottom', menuPosition: 'absolute', menuShouldBlockScroll: false, menuShouldScrollIntoView: !isMobileDevice(), noOptionsMessage: () => 'No options', openMenuOnFocus: false, openMenuOnClick: true, options: [], pageSize: 5, placeholder: 'Select...', screenReaderStatus: ({ count }: { count: number }) => `${count} result${count !== 1 ? 's' : ''} available`, styles: {}, tabIndex: '0', tabSelectsValue: true, }; type MenuOptions = { render: Array<OptionType>, focusable: Array<OptionType>, }; type State = { ariaLiveSelection: string, ariaLiveContext: string, inputIsHidden: boolean, isFocused: boolean, focusedOption: OptionType | null, focusedValue: OptionType | null, menuOptions: MenuOptions, selectValue: OptionsType, }; type ElRef = ElementRef<*>; let instanceId = 1; export default class Select extends Component<Props, State> { static defaultProps = defaultProps; state = { ariaLiveSelection: '', ariaLiveContext: '', focusedOption: null, focusedValue: null, inputIsHidden: false, isFocused: false, menuOptions: { render: [], focusable: [] }, selectValue: [], }; // Misc. Instance Properties // ------------------------------ blockOptionHover: boolean = false; isComposing: boolean = false; clearFocusValueOnUpdate: boolean = false; commonProps: any; // TODO components: SelectComponents; hasGroups: boolean = false; initialTouchX: number = 0; initialTouchY: number = 0; inputIsHiddenAfterUpdate: ?boolean; instancePrefix: string = ''; openAfterFocus: boolean = false; scrollToFocusedOptionOnUpdate: boolean = false; userIsDragging: ?boolean; // Refs // ------------------------------ controlRef: ElRef = null; getControlRef = (ref: HTMLElement) => { this.controlRef = ref; }; focusedOptionRef: ElRef = null; getFocusedOptionRef = (ref: HTMLElement) => { this.focusedOptionRef = ref; }; menuListRef: ElRef = null; getMenuListRef = (ref: HTMLElement) => { this.menuListRef = ref; }; inputRef: ElRef = null; getInputRef = (ref: HTMLElement) => { this.inputRef = ref; }; // Lifecycle // ------------------------------ constructor(props: Props) { super(props); const { value } = props; this.cacheComponents = memoizeOne(this.cacheComponents, isEqual).bind(this); this.cacheComponents(props.components); this.instancePrefix = 'react-select-' + (this.props.instanceId || ++instanceId); const selectValue = cleanValue(value); this.buildMenuOptions = memoizeOne( this.buildMenuOptions, (newArgs: any, lastArgs: any) => { const [newProps, newSelectValue] = (newArgs: [Props, OptionsType]); const [lastProps, lastSelectValue] = (lastArgs: [Props, OptionsType]); return isEqual(newSelectValue, lastSelectValue) && isEqual(newProps.inputValue, lastProps.inputValue) && isEqual(newProps.options, lastProps.options); }).bind(this); const menuOptions = props.menuIsOpen ? this.buildMenuOptions(props, selectValue) : { render: [], focusable: [] }; this.state.menuOptions = menuOptions; this.state.selectValue = selectValue; } componentDidMount() { this.startListeningComposition(); this.startListeningToTouch(); if (this.props.closeMenuOnScroll && document && document.addEventListener) { // Listen to all scroll events, and filter them out inside of 'onScroll' document.addEventListener('scroll', this.onScroll, true); } if (this.props.autoFocus) { this.focusInput(); } } UNSAFE_componentWillReceiveProps(nextProps: Props) { const { options, value, menuIsOpen, inputValue } = this.props; // re-cache custom components this.cacheComponents(nextProps.components); // rebuild the menu options if ( nextProps.value !== value || nextProps.options !== options || nextProps.menuIsOpen !== menuIsOpen || nextProps.inputValue !== inputValue ) { const selectValue = cleanValue(nextProps.value); const menuOptions = nextProps.menuIsOpen ? this.buildMenuOptions(nextProps, selectValue) : { render: [], focusable: [] }; const focusedValue = this.getNextFocusedValue(selectValue); const focusedOption = this.getNextFocusedOption(menuOptions.focusable); this.setState({ menuOptions, selectValue, focusedOption, focusedValue }); } // some updates should toggle the state of the input visibility if (this.inputIsHiddenAfterUpdate != null) { this.setState({ inputIsHidden: this.inputIsHiddenAfterUpdate, }); delete this.inputIsHiddenAfterUpdate; } } componentDidUpdate(prevProps: Props) { const { isDisabled, menuIsOpen } = this.props; const { isFocused } = this.state; if ( // ensure focus is restored correctly when the control becomes enabled (isFocused && !isDisabled && prevProps.isDisabled) || // ensure focus is on the Input when the menu opens (isFocused && menuIsOpen && !prevProps.menuIsOpen) ) { this.focusInput(); } // scroll the focused option into view if necessary if ( this.menuListRef && this.focusedOptionRef && this.scrollToFocusedOptionOnUpdate ) { scrollIntoView(this.menuListRef, this.focusedOptionRef); this.scrollToFocusedOptionOnUpdate = false; } } componentWillUnmount() { this.stopListeningComposition(); this.stopListeningToTouch(); document.removeEventListener('scroll', this.onScroll, true); } cacheComponents = (components: SelectComponents) => { this.components = defaultComponents({ components }); }; // ============================== // Consumer Handlers // ============================== onMenuOpen() { this.props.onMenuOpen(); } onMenuClose() { const { isSearchable, isMulti } = this.props; this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti }, }); this.onInputChange('', { action: 'menu-close' }); this.props.onMenuClose(); } onInputChange(newValue: string, actionMeta: InputActionMeta) { this.props.onInputChange(newValue, actionMeta); } // ============================== // Methods // ============================== focusInput() { if (!this.inputRef) return; this.inputRef.focus(); } blurInput() { if (!this.inputRef) return; this.inputRef.blur(); } // aliased for consumers focus = this.focusInput; blur = this.blurInput; openMenu(focusOption: 'first' | 'last') { const { selectValue, isFocused } = this.state; const menuOptions = this.buildMenuOptions(this.props, selectValue); const { isMulti } = this.props; let openAtIndex = focusOption === 'first' ? 0 : menuOptions.focusable.length - 1; if (!isMulti) { const selectedIndex = menuOptions.focusable.indexOf(selectValue[0]); if (selectedIndex > -1) { openAtIndex = selectedIndex; } } // only scroll if the menu isn't already open this.scrollToFocusedOptionOnUpdate = !(isFocused && this.menuListRef); this.inputIsHiddenAfterUpdate = false; this.setState({ menuOptions, focusedValue: null, focusedOption: menuOptions.focusable[openAtIndex], }, () => { this.onMenuOpen(); this.announceAriaLiveContext({ event: 'menu' }); }); } focusValue(direction: 'previous' | 'next') { const { isMulti, isSearchable } = this.props; const { selectValue, focusedValue } = this.state; // Only multiselects support value focusing if (!isMulti) return; this.setState({ focusedOption: null, }); let focusedIndex = selectValue.indexOf(focusedValue); if (!focusedValue) { focusedIndex = -1; this.announceAriaLiveContext({ event: 'value' }); } const lastIndex = selectValue.length - 1; let nextFocus = -1; if (!selectValue.length) return; switch (direction) { case 'previous': if (focusedIndex === 0) { // don't cycle from the start to the end nextFocus = 0; } else if (focusedIndex === -1) { // if nothing is focused, focus the last value first nextFocus = lastIndex; } else { nextFocus = focusedIndex - 1; } break; case 'next': if (focusedIndex > -1 && focusedIndex < lastIndex) { nextFocus = focusedIndex + 1; } break; } if (nextFocus === -1) { this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti }, }); } this.setState({ inputIsHidden: nextFocus !== -1, focusedValue: selectValue[nextFocus], }); } focusOption(direction: FocusDirection = 'first') { const { pageSize } = this.props; const { focusedOption, menuOptions } = this.state; const options = menuOptions.focusable; if (!options.length) return; let nextFocus = 0; // handles 'first' let focusedIndex = options.indexOf(focusedOption); if (!focusedOption) { focusedIndex = -1; this.announceAriaLiveContext({ event: 'menu' }); } if (direction === 'up') { nextFocus = focusedIndex > 0 ? focusedIndex - 1 : options.length - 1; } else if (direction === 'down') { nextFocus = (focusedIndex + 1) % options.length; } else if (direction === 'pageup') { nextFocus = focusedIndex - pageSize; if (nextFocus < 0) nextFocus = 0; } else if (direction === 'pagedown') { nextFocus = focusedIndex + pageSize; if (nextFocus > options.length - 1) nextFocus = options.length - 1; } else if (direction === 'last') { nextFocus = options.length - 1; } this.scrollToFocusedOptionOnUpdate = true; this.setState({ focusedOption: options[nextFocus], focusedValue: null, }); this.announceAriaLiveContext({ event: 'menu', context: { isDisabled: isOptionDisabled(options[nextFocus]) }, }); } onChange = (newValue: ValueType, actionMeta: ActionMeta) => { const { onChange, name } = this.props; onChange(newValue, { ...actionMeta, name }); }; setValue = ( newValue: ValueType, action: ActionTypes = 'set-value', option?: OptionType ) => { const { closeMenuOnSelect, isMulti } = this.props; this.onInputChange('', { action: 'set-value' }); if (closeMenuOnSelect) { this.inputIsHiddenAfterUpdate = !isMulti; this.onMenuClose(); } // when the select value should change, we should reset focusedValue this.clearFocusValueOnUpdate = true; this.onChange(newValue, { action, option }); }; selectOption = (newValue: OptionType) => { const { blurInputOnSelect, isMulti } = this.props; const { selectValue } = this.state; if (isMulti) { if (this.isOptionSelected(newValue, selectValue)) { const candidate = this.getOptionValue(newValue); this.setValue( selectValue.filter(i => this.getOptionValue(i) !== candidate), 'deselect-option', newValue ); this.announceAriaLiveSelection({ event: 'deselect-option', context: { value: this.getOptionLabel(newValue) }, }); } else { if (!this.isOptionDisabled(newValue, selectValue)) { this.setValue([...selectValue, newValue], 'select-option', newValue); this.announceAriaLiveSelection({ event: 'select-option', context: { value: this.getOptionLabel(newValue) }, }); } else { // announce that option is disabled this.announceAriaLiveSelection({ event: 'select-option', context: { value: this.getOptionLabel(newValue), isDisabled: true }, }); } } } else { if (!this.isOptionDisabled(newValue, selectValue)) { this.setValue(newValue, 'select-option'); this.announceAriaLiveSelection({ event: 'select-option', context: { value: this.getOptionLabel(newValue) }, }); } else { // announce that option is disabled this.announceAriaLiveSelection({ event: 'select-option', context: { value: this.getOptionLabel(newValue), isDisabled: true }, }); } } if (blurInputOnSelect) { this.blurInput(); } }; removeValue = (removedValue: OptionType) => { const { selectValue } = this.state; const candidate = this.getOptionValue(removedValue); const newValue = selectValue.filter( i => this.getOptionValue(i) !== candidate ); this.onChange(newValue.length ? newValue : null, { action: 'remove-value', removedValue, }); this.announceAriaLiveSelection({ event: 'remove-value', context: { value: removedValue ? this.getOptionLabel(removedValue) : '', }, }); this.focusInput(); }; clearValue = () => { const { isMulti } = this.props; this.onChange(isMulti ? [] : null, { action: 'clear' }); }; popValue = () => { const { selectValue } = this.state; const lastSelectedValue = selectValue[selectValue.length - 1]; const newValue = selectValue.slice(0, selectValue.length - 1); this.announceAriaLiveSelection({ event: 'pop-value', context: { value: lastSelectedValue ? this.getOptionLabel(lastSelectedValue) : '', }, }); this.onChange(newValue.length ? newValue : null, { action: 'pop-value', removedValue: lastSelectedValue, }); }; // ============================== // Getters // ============================== getTheme() { // Use the default theme if there are no customizations. if (!this.props.theme) { return defaultTheme; } // If the theme prop is a function, assume the function // knows how to merge the passed-in default theme with // its own modifications. if (typeof this.props.theme === 'function') { return this.props.theme(defaultTheme); } // Otherwise, if a plain theme object was passed in, // overlay it with the default theme. return { ...defaultTheme, ...this.props.theme, }; } getCommonProps() { const { clearValue, getStyles, setValue, selectOption, props } = this; const { classNamePrefix, isMulti, isRtl, options } = props; const { selectValue } = this.state; const hasValue = this.hasValue(); const getValue = () => selectValue; const cx = classNames.bind(null, classNamePrefix); return { cx, clearValue, getStyles, getValue, hasValue, isMulti, isRtl, options, selectOption, setValue, selectProps: props, theme: this.getTheme(), }; } getNextFocusedValue(nextSelectValue: OptionsType) { if (this.clearFocusValueOnUpdate) { this.clearFocusValueOnUpdate = false; return null; } const { focusedValue, selectValue: lastSelectValue } = this.state; const lastFocusedIndex = lastSelectValue.indexOf(focusedValue); if (lastFocusedIndex > -1) { const nextFocusedIndex = nextSelectValue.indexOf(focusedValue); if (nextFocusedIndex > -1) { // the focused value is still in the selectValue, return it return focusedValue; } else if (lastFocusedIndex < nextSelectValue.length) { // the focusedValue is not present in the next selectValue array by // reference, so return the new value at the same index return nextSelectValue[lastFocusedIndex]; } } return null; } getNextFocusedOption(options: OptionsType) { const { focusedOption: lastFocusedOption } = this.state; return lastFocusedOption && options.indexOf(lastFocusedOption) > -1 ? lastFocusedOption : options[0]; } getOptionLabel = (data: OptionType): string => { return this.props.getOptionLabel(data); }; getOptionValue = (data: OptionType): string => { return this.props.getOptionValue(data); }; getStyles = (key: string, props: {}): {} => { const base = defaultStyles[key](props); base.boxSizing = 'border-box'; const custom = this.props.styles[key]; return custom ? custom(base, props) : base; }; getElementId = (element: 'group' | 'input' | 'listbox' | 'option') => { return `${this.instancePrefix}-${element}`; }; getActiveDescendentId = () => { const { menuIsOpen } = this.props; const { menuOptions, focusedOption } = this.state; if (!focusedOption || !menuIsOpen) return undefined; const index = menuOptions.focusable.indexOf(focusedOption); const option = menuOptions.render[index]; return option && option.key; }; // ============================== // Helpers // ============================== announceAriaLiveSelection = ({ event, context, }: { event: string, context: ValueEventContext, }) => { this.setState({ ariaLiveSelection: valueEventAriaMessage(event, context), }); }; announceAriaLiveContext = ({ event, context, }: { event: string, context?: InstructionsContext, }) => { this.setState({ ariaLiveContext: instructionsAriaMessage(event, { ...context, label: this.props['aria-label'], }), }); }; hasValue() { const { selectValue } = this.state; return selectValue.length > 0; } hasOptions() { return !!this.state.menuOptions.render.length; } countOptions() { return this.state.menuOptions.focusable.length; } isClearable(): boolean { const { isClearable, isMulti } = this.props; // single select, by default, IS NOT clearable // multi select, by default, IS clearable if (isClearable === undefined) return isMulti; return isClearable; } isOptionDisabled(option: OptionType, selectValue: OptionsType): boolean { return typeof this.props.isOptionDisabled === 'function' ? this.props.isOptionDisabled(option, selectValue) : false; } isOptionSelected(option: OptionType, selectValue: OptionsType): boolean { if (selectValue.indexOf(option) > -1) return true; if (typeof this.props.isOptionSelected === 'function') { return this.props.isOptionSelected(option, selectValue); } const candidate = this.getOptionValue(option); return selectValue.some(i => this.getOptionValue(i) === candidate); } filterOption( option: { label: string, value: string, data: OptionType }, inputValue: string ) { return this.props.filterOption ? this.props.filterOption(option, inputValue) : true; } formatOptionLabel(data: OptionType, context: FormatOptionLabelContext): Node { if (typeof this.props.formatOptionLabel === 'function') { const { inputValue } = this.props; const { selectValue } = this.state; return this.props.formatOptionLabel(data, { context, inputValue, selectValue, }); } else { return this.getOptionLabel(data); } } formatGroupLabel(data: GroupType) { return this.props.formatGroupLabel(data); } // ============================== // Mouse Handlers // ============================== onMenuMouseDown = (event: SyntheticMouseEvent<HTMLElement>) => { if (event.button !== 0) { return; } event.stopPropagation(); event.preventDefault(); this.focusInput(); }; onMenuMouseMove = (event: SyntheticMouseEvent<HTMLElement>) => { this.blockOptionHover = false; }; onControlMouseDown = (event: MouseOrTouchEvent) => { const { openMenuOnClick } = this.props; if (!this.state.isFocused) { if (openMenuOnClick) { this.openAfterFocus = true; } this.focusInput(); } else if (!this.props.menuIsOpen) { if (openMenuOnClick) { this.openMenu('first'); } } else { if ( // $FlowFixMe event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA' ) { this.onMenuClose(); } } if ( // $FlowFixMe event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA' ) { event.preventDefault(); } }; onDropdownIndicatorMouseDown = (event: MouseOrTouchEvent) => { // ignore mouse events that weren't triggered by the primary button if (event && event.type === 'mousedown' && event.button !== 0) { return; } if (this.props.isDisabled) return; const { isMulti, menuIsOpen } = this.props; this.focusInput(); if (menuIsOpen) { this.inputIsHiddenAfterUpdate = !isMulti; this.onMenuClose(); } else { this.openMenu('first'); } event.preventDefault(); event.stopPropagation(); }; onClearIndicatorMouseDown = (event: MouseOrTouchEvent) => { // ignore mouse events that weren't triggered by the primary button if (event && event.type === 'mousedown' && event.button !== 0) { return; } this.clearValue(); event.stopPropagation(); this.openAfterFocus = false; if (event.type === 'touchend') { this.focusInput(); } else { setTimeout(() => this.focusInput()); } }; onScroll = (event: Event) => { if (typeof this.props.closeMenuOnScroll === 'boolean') { if ( event.target instanceof HTMLElement && isDocumentElement(event.target) ) { this.props.onMenuClose(); } } else if (typeof this.props.closeMenuOnScroll === 'function') { if (this.props.closeMenuOnScroll(event)) { this.props.onMenuClose(); } } }; // ============================== // Composition Handlers // ============================== startListeningComposition() { if (document && document.addEventListener) { document.addEventListener( 'compositionstart', this.onCompositionStart, false ); document.addEventListener('compositionend', this.onCompositionEnd, false); } } stopListeningComposition() { if (document && document.removeEventListener) { document.removeEventListener('compositionstart', this.onCompositionStart); document.removeEventListener('compositionend', this.onCompositionEnd); } } onCompositionStart = () => { this.isComposing = true; }; onCompositionEnd = () => { this.isComposing = false; }; // ============================== // Touch Handlers // ============================== startListeningToTouch() { if (document && document.addEventListener) { document.addEventListener('touchstart', this.onTouchStart, false); document.addEventListener('touchmove', this.onTouchMove, false); document.addEventListener('touchend', this.onTouchEnd, false); } } stopListeningToTouch() { if (document && document.removeEventListener) { document.removeEventListener('touchstart', this.onTouchStart); document.removeEventListener('touchmove', this.onTouchMove); document.removeEventListener('touchend', this.onTouchEnd); } } onTouchStart = ({ touches }: TouchEvent) => { const touch = touches.item(0); if (!touch) { return; } this.initialTouchX = touch.clientX; this.initialTouchY = touch.clientY; this.userIsDragging = false; }; onTouchMove = ({ touches }: TouchEvent) => { const touch = touches.item(0); if (!touch) { return; } const deltaX = Math.abs(touch.clientX - this.initialTouchX); const deltaY = Math.abs(touch.clientY - this.initialTouchY); const moveThreshold = 5; this.userIsDragging = deltaX > moveThreshold || deltaY > moveThreshold; }; onTouchEnd = (event: TouchEvent) => { if (this.userIsDragging) return; // close the menu if the user taps outside // we're checking on event.target here instead of event.currentTarget, because we want to assert information // on events on child elements, not the document (which we've attached this handler to). if ( this.controlRef && !this.controlRef.contains(event.target) && this.menuListRef && !this.menuListRef.contains(event.target) ) { this.blurInput(); } // reset move vars this.initialTouchX = 0; this.initialTouchY = 0; }; onControlTouchEnd = (event: SyntheticTouchEvent<HTMLElement>) => { if (this.userIsDragging) return; this.onControlMouseDown(event); }; onClearIndicatorTouchEnd = (event: SyntheticTouchEvent<HTMLElement>) => { if (this.userIsDragging) return; this.onClearIndicatorMouseDown(event); }; onDropdownIndicatorTouchEnd = (event: SyntheticTouchEvent<HTMLElement>) => { if (this.userIsDragging) return; this.onDropdownIndicatorMouseDown(event); }; // ============================== // Focus Handlers // ============================== handleInputChange = (event: SyntheticKeyboardEvent<HTMLInputElement>) => { const inputValue = event.currentTarget.value; this.inputIsHiddenAfterUpdate = false; this.onInputChange(inputValue, { action: 'input-change' }); this.onMenuOpen(); }; onInputFocus = (event: SyntheticFocusEvent<HTMLInputElement>) => { const { isSearchable, isMulti } = this.props; if (this.props.onFocus) { this.props.onFocus(event); } this.inputIsHiddenAfterUpdate = false; this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti }, }); this.setState({ isFocused: true, }); if (this.openAfterFocus || this.props.openMenuOnFocus) { this.openMenu('first'); } this.openAfterFocus = false; }; onInputBlur = (event: SyntheticFocusEvent<HTMLInputElement>) => { if (this.menuListRef && this.menuListRef.contains(document.activeElement)) { this.inputRef.focus(); return; } if (this.props.onBlur) { this.props.onBlur(event); } this.onInputChange('', { action: 'input-blur' }); this.onMenuClose(); this.setState({ focusedValue: null, isFocused: false, }); }; onOptionHover = (focusedOption: OptionType) => { if (this.blockOptionHover || this.state.focusedOption === focusedOption) { return; } this.setState({ focusedOption }); }; shouldHideSelectedOptions = () => { const { hideSelectedOptions, isMulti } = this.props; if (hideSelectedOptions === undefined) return isMulti; return hideSelectedOptions; }; // ============================== // Keyboard Handlers // ============================== onKeyDown = (event: SyntheticKeyboardEvent<HTMLElement>) => { const { isMulti, backspaceRemovesValue, escapeClearsValue, inputValue, isClearable, isDisabled, menuIsOpen, onKeyDown, tabSelectsValue, openMenuOnFocus, } = this.props; const { focusedOption, focusedValue, selectValue } = this.state; if (isDisabled) return; if (typeof onKeyDown === 'function') { onKeyDown(event); if (event.defaultPrevented) { return; } } // Block option hover events when the user has just pressed a key this.blockOptionHover = true; switch (event.key) { case 'ArrowLeft': if (!isMulti || inputValue) return; this.focusValue('previous'); break; case 'ArrowRight': if (!isMulti || inputValue) return; this.focusValue('next'); break; case 'Delete': case 'Backspace': if (inputValue) return; if (focusedValue) { this.removeValue(focusedValue); } else { if (!backspaceRemovesValue) return; if (isMulti) { this.popValue(); } else if (isClearable) { this.clearValue(); } } break; case 'Tab': if (this.isComposing) return; if ( event.shiftKey || !menuIsOpen || !tabSelectsValue || !focusedOption || // don't capture the event if the menu opens on focus and the focused // option is already selected; it breaks the flow of navigation (openMenuOnFocus && this.isOptionSelected(focusedOption, selectValue)) ) { return; } this.selectOption(focusedOption); break; case 'Enter': if (event.keyCode === 229) { // ignore the keydown event from an Input Method Editor(IME) // ref. https://www.w3.org/TR/uievents/#determine-keydown-keyup-keyCode break; } if (menuIsOpen) { if (!focusedOption) return; if (this.isComposing) return; this.selectOption(focusedOption); break; } return; case 'Escape': if (menuIsOpen) { this.inputIsHiddenAfterUpdate = false; this.onInputChange('', { action: 'menu-close' }); this.onMenuClose(); } else if (isClearable && escapeClearsValue) { this.clearValue(); } break; case ' ': // space if (inputValue) { return; } if (!menuIsOpen) { this.openMenu('first'); break; } if (!focusedOption) return; this.selectOption(focusedOption); break; case 'ArrowUp': if (menuIsOpen) { this.focusOption('up'); } else { this.openMenu('last'); } break; case 'ArrowDown': if (menuIsOpen) { this.focusOption('down'); } else { this.openMenu('first'); } break; case 'PageUp': if (!menuIsOpen) return; this.focusOption('pageup'); break; case 'PageDown': if (!menuIsOpen) return; this.focusOption('pagedown'); break; case 'Home': if (!menuIsOpen) return; this.focusOption('first'); break; case 'End': if (!menuIsOpen) return; this.focusOption('last'); break; default: return; } event.preventDefault(); }; // ============================== // Menu Options // ============================== buildMenuOptions = (props: Props, selectValue: OptionsType): MenuOptions => { const { inputValue = '', options } = props; const toOption = (option, id) => { const isDisabled = this.isOptionDisabled(option, selectValue); const isSelected = this.isOptionSelected(option, selectValue); const label = this.getOptionLabel(option); const value = this.getOptionValue(option); if ( (this.shouldHideSelectedOptions() && isSelected) || !this.filterOption({ label, value, data: option }, inputValue) ) { return; } const onHover = isDisabled ? undefined : () => this.onOptionHover(option); const onSelect = isDisabled ? undefined : () => this.selectOption(option); const optionId = `${this.getElementId('option')}-${id}`; return { innerProps: { id: optionId, onClick: onSelect, onMouseMove: onHover, onMouseOver: onHover, tabIndex: -1, }, data: option, isDisabled, isSelected, key: optionId, label, type: 'option', value, }; }; return options.reduce( (acc, item, itemIndex) => { if (item.options) { // TODO needs a tidier implementation if (!this.hasGroups) this.hasGroups = true; const { options: items } = item; const children = items .map((child, i) => { const option = toOption(child, `${itemIndex}-${i}`); if (option) acc.focusable.push(child); return option; }) .filter(Boolean); if (children.length) { const groupId = `${this.getElementId('group')}-${itemIndex}`; acc.render.push({ type: 'group', key: groupId, data: item, options: children, }); } } else { const option = toOption(item, `${itemIndex}`); if (option) { acc.render.push(option); acc.focusable.push(item); } } return acc; }, { render: [], focusable: [] } ); } // ============================== // Renderers // ============================== constructAriaLiveMessage() { const { ariaLiveContext, selectValue, focusedValue, focusedOption, } = this.state; const { options, menuIsOpen, inputValue, screenReaderStatus } = this.props; // An aria live message representing the currently focused value in the select. const focusedValueMsg = focusedValue ? valueFocusAriaMessage({ focusedValue, getOptionLabel: this.getOptionLabel, selectValue, }) : ''; // An aria live message representing the currently focused option in the select. const focusedOptionMsg = focusedOption && menuIsOpen ? optionFocusAriaMessage({ focusedOption, getOptionLabel: this.getOptionLabel, options, }) : ''; // An aria live message representing the set of focusable results and current searchterm/inputvalue. const resultsMsg = resultsAriaMessage({ inputValue, screenReaderMessage: screenReaderStatus({ count: this.countOptions() }), }); return `${focusedValueMsg} ${focusedOptionMsg} ${resultsMsg} ${ariaLiveContext}`; } renderInput() { const { isDisabled, isSearchable, inputId, inputValue, tabIndex, } = this.props; const { Input } = this.components; const { inputIsHidden } = this.state; const id = inputId || this.getElementId('input'); // aria attributes makes the JSX "noisy", separated for clarity const ariaAttributes = { 'aria-autocomplete': 'list', 'aria-label': this.props['aria-label'], 'aria-labelledby': this.props['aria-labelledby'], }; if (!isSearchable) { // use a dummy input to maintain focus/blur functionality return ( <DummyInput id={id} innerRef={this.getInputRef} onBlur={this.onInputBlur} onChange={noop} onFocus={this.onInputFocus} readOnly disabled={isDisabled} tabIndex={tabIndex} value="" {...ariaAttributes} /> ); } const { cx, theme, selectProps } = this.commonProps; return ( <Input autoCapitalize="none" autoComplete="off" autoCorrect="off" cx={cx} getStyles={this.getStyles} id={id} innerRef={this.getInputRef} isDisabled={isDisabled} isHidden={inputIsHidden} onBlur={this.onInputBlur} onChange={this.handleInputChange} onFocus={this.onInputFocus} selectProps={selectProps} spellCheck="false" tabIndex={tabIndex} theme={theme} type="text" value={inputValue} {...ariaAttributes} /> ); } renderPlaceholderOrValue(): ?PlaceholderOrValue { const { MultiValue, MultiValueContainer, MultiValueLabel, MultiValueRemove, SingleValue, Placeholder, } = this.components; const { commonProps } = this; const { controlShouldRenderValue, isDisabled, isMulti, inputValue, placeholder, } = this.props; const { selectValue, focusedValue, isFocused } = this.state; if (!this.hasValue() || !controlShouldRenderValue) { return inputValue ? null : ( <Placeholder {...commonProps} key="placeholder" isDisabled={isDisabled} isFocused={isFocused} > {placeholder} </Placeholder> ); } if (isMulti) { const selectValues: Array<any> = selectValue.map((opt, index) => { const isOptionFocused = opt === focusedValue; return ( <MultiValue {...commonProps} components={{ Container: MultiValueContainer, Label: MultiValueLabel, Remove: MultiValueRemove, }} isFocused={isOptionFocused} isDisabled={isDisabled} key={this.getOptionValue(opt)} index={index} removeProps={{ onClick: () => this.removeValue(opt), onTouchEnd: () => this.removeValue(opt), onMouseDown: e => { e.preventDefault(); e.stopPropagation(); }, }} data={opt} > {this.formatOptionLabel(opt, 'value')} </MultiValue> ); }); return selectValues; } if (inputValue) { return null; } const singleValue = selectValue[0]; return ( <SingleValue {...commonProps} data={singleValue} isDisabled={isDisabled}> {this.formatOptionLabel(singleValue, 'value')} </SingleValue> ); } renderClearIndicator() { const { ClearIndicator } = this.components; const { commonProps } = this; const { isDisabled, isLoading } = this.props; const { isFocused } = this.state; if ( !this.isClearable() || !ClearIndicator || isDisabled || !this.hasValue() || isLoading ) { return null; } const innerProps = { onMouseDown: this.onClearIndicatorMouseDown, onTouchEnd: this.onClearIndicatorTouchEnd, 'aria-hidden': 'true', }; return ( <ClearIndicator {...commonProps} innerProps={innerProps} isFocused={isFocused} /> ); } renderLoadingIndicator() { const { LoadingIndicator } = this.components; const { commonProps } = this; const { isDisabled, isLoading } = this.props; const { isFocused } = this.state; if (!LoadingIndicator || !isLoading) return null; const innerProps = { 'aria-hidden': 'true' }; return ( <LoadingIndicator {...commonProps} innerProps={innerProps} isDisabled={isDisabled} isFocused={isFocused} /> ); } renderIndicatorSeparator() { const { DropdownIndicator, IndicatorSeparator } = this.components; // separator doesn't make sense without the dropdown indicator if (!DropdownIndicator || !IndicatorSeparator) return null; const { commonProps } = this; const { isDisabled } = this.props; const { isFocused } = this.state; return ( <IndicatorSeparator {...commonProps} isDisabled={isDisabled} isFocused={isFocused} /> ); } renderDropdownIndicator() { const { DropdownIndicator } = this.components; if (!DropdownIndicator) return null; const { commonProps } = this; const { isDisabled } = this.props; const { isFocused } = this.state; const innerProps = { onMouseDown: this.onDropdownIndicatorMouseDown, onTouchEnd: this.onDropdownIndicatorTouchEnd, 'aria-hidden': 'true', }; return ( <DropdownIndicator {...commonProps} innerProps={innerProps} isDisabled={isDisabled} isFocused={isFocused} /> ); } renderMenu() { const { Group, GroupHeading, Menu, MenuList, MenuPortal, LoadingMessage, NoOptionsMessage, Option, } = this.components; const { commonProps } = this; const { focusedOption, menuOptions } = this.state; const { captureMenuScroll, inputValue, isLoading, loadingMessage, minMenuHeight, maxMenuHeight, menuIsOpen, menuPlacement, menuPosition, menuPortalTarget, menuShouldBlockScroll, menuShouldScrollIntoView, noOptionsMessage, onMenuScrollToTop, onMenuScrollToBottom, } = this.props; if (!menuIsOpen) return null; // TODO: Internal Option Type here const render = (props: OptionType) => { // for performance, the menu options in state aren't changed when the // focused option changes so we calculate additional props based on that const isFocus