Dataset/JS/ReactSelect/index.tsx (1,878 lines of code) (raw):
import * as React from 'react';
import {
AriaAttributes,
Component,
FocusEventHandler,
FormEventHandler,
KeyboardEventHandler,
MouseEventHandler,
ReactNode,
RefCallback,
TouchEventHandler,
} from 'react';
import { MenuPlacer } from './components/Menu';
import LiveRegion from './components/LiveRegion';
import { createFilter, FilterOptionOption } from './filters';
import { DummyInput, ScrollManager, RequiredInput } from './internal/index';
import { AriaLiveMessages, AriaSelection } from './accessibility/index';
import { isAppleDevice } from './accessibility/helpers';
import {
classNames,
cleanValue,
isTouchCapable,
isMobileDevice,
noop,
scrollIntoView,
isDocumentElement,
notNullish,
valueTernary,
multiValueAsValue,
singleValueAsValue,
} from './utils';
import {
formatGroupLabel as formatGroupLabelBuiltin,
getOptionLabel as getOptionLabelBuiltin,
getOptionValue as getOptionValueBuiltin,
isOptionDisabled as isOptionDisabledBuiltin,
} from './builtins';
import { defaultComponents, SelectComponentsConfig } from './components/index';
import {
ClassNamesConfig,
defaultStyles,
StylesConfig,
StylesProps,
} from './styles';
import { defaultTheme, ThemeConfig } from './theme';
import {
ActionMeta,
FocusDirection,
GetOptionLabel,
GetOptionValue,
GroupBase,
InputActionMeta,
MenuPlacement,
MenuPosition,
OnChangeValue,
Options,
OptionsOrGroups,
PropsValue,
SetValueAction,
} from './types';
export type FormatOptionLabelContext = 'menu' | 'value';
export interface FormatOptionLabelMeta<Option> {
context: FormatOptionLabelContext;
inputValue: string;
selectValue: Options<Option>;
}
export interface Props<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> {
/** HTML ID of an element containing an error message related to the input**/
'aria-errormessage'?: AriaAttributes['aria-errormessage'];
/** Indicate if the value entered in the field is invalid **/
'aria-invalid'?: AriaAttributes['aria-invalid'];
/** Aria label (for assistive tech) */
'aria-label'?: AriaAttributes['aria-label'];
/** HTML ID of an element that should be used as the label (for assistive tech) */
'aria-labelledby'?: AriaAttributes['aria-labelledby'];
/** Used to set the priority with which screen reader should treat updates to live regions. The possible settings are: off, polite (default) or assertive */
'aria-live'?: AriaAttributes['aria-live'];
/** Customise the messages used by the aria-live component */
ariaLiveMessages?: AriaLiveMessages<Option, IsMulti, Group>;
/** Focus the control when it is mounted */
autoFocus?: boolean;
/** Remove the currently focused option when the user presses backspace when Select isClearable or isMulti */
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;
/**
* Provide classNames based on state for each inner component
*/
classNames: ClassNamesConfig<Option, IsMulti, Group>;
/** 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 | ((event: Event) => boolean);
/**
* 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<Option, IsMulti, Group>;
/** 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:
| ((option: FilterOptionOption<Option>, inputValue: 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: (group: Group) => ReactNode;
/** Formats option labels in the menu and control as React components */
formatOptionLabel?: (
data: Option,
formatOptionLabelMeta: FormatOptionLabelMeta<Option>
) => ReactNode;
/**
* Resolves option data to a string to be displayed as the label by components
*
* Note: Failure to resolve to a string type can interfere with filtering and
* screen reader support.
*/
getOptionLabel: GetOptionLabel<Option>;
/** Resolves option data to a string to compare options and specify value attributes */
getOptionValue: GetOptionValue<Option>;
/** 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: (option: Option, selectValue: Options<Option>) => boolean;
/** Override the built-in logic to detect whether an option is selected */
isOptionSelected?: (option: Option, selectValue: Options<Option>) => boolean;
/** Support multiple selected options */
isMulti: IsMulti;
/** Is the select direction right-to-left */
isRtl: boolean;
/** Whether to enable search functionality */
isSearchable: boolean;
/** Async: Text to display when loading options */
loadingMessage: (obj: { inputValue: string }) => ReactNode;
/** 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 | null;
/** 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: (obj: { inputValue: string }) => ReactNode;
/** Handle blur events on the control */
onBlur?: FocusEventHandler<HTMLInputElement>;
/** Handle change events on the select */
onChange: (
newValue: OnChangeValue<Option, IsMulti>,
actionMeta: ActionMeta<Option>
) => void;
/** Handle focus events on the control */
onFocus?: FocusEventHandler<HTMLInputElement>;
/** Handle change events on the input */
onInputChange: (newValue: string, actionMeta: InputActionMeta) => void;
/** Handle key down events on the select */
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
/** Handle the menu opening */
onMenuOpen: () => void;
/** Handle the menu closing */
onMenuClose: () => void;
/** Fired when the user scrolls to the top of the menu */
onMenuScrollToTop?: (event: WheelEvent | TouchEvent) => void;
/** Fired when the user scrolls to the bottom of the menu */
onMenuScrollToBottom?: (event: WheelEvent | TouchEvent) => 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: OptionsOrGroups<Option, Group>;
/** Number of options to jump in menu when page{up|down} keys are used */
pageSize: number;
/** Placeholder for the select value */
placeholder: ReactNode;
/** Status to relay to screen readers */
screenReaderStatus: (obj: { 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<Option, IsMulti, Group>;
/** Theme modifier method */
theme?: ThemeConfig;
/** Sets the tabIndex attribute on the input */
tabIndex: number;
/** Select the currently focused option when the user presses tab */
tabSelectsValue: boolean;
/** Remove all non-essential styles */
unstyled: boolean;
/** The value of the select; reflected by the selected option */
value: PropsValue<Option>;
/** Sets the form attribute on the input */
form?: string;
/** Marks the value-holding input as required for form validation */
required?: boolean;
}
export const defaultProps = {
'aria-live': 'polite',
backspaceRemovesValue: true,
blurInputOnSelect: isTouchCapable(),
captureMenuScroll: !isTouchCapable(),
classNames: {},
closeMenuOnSelect: true,
closeMenuOnScroll: false,
components: {},
controlShouldRenderValue: true,
escapeClearsValue: false,
filterOption: createFilter(),
formatGroupLabel: formatGroupLabelBuiltin,
getOptionLabel: getOptionLabelBuiltin,
getOptionValue: getOptionValueBuiltin,
isDisabled: false,
isLoading: false,
isMulti: false,
isRtl: false,
isSearchable: true,
isOptionDisabled: isOptionDisabledBuiltin,
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,
unstyled: false,
};
interface State<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> {
ariaSelection: AriaSelection<Option, IsMulti> | null;
inputIsHidden: boolean;
isFocused: boolean;
focusedOption: Option | null;
focusedOptionId: string | null;
focusableOptionsWithIds: FocusableOptionWithId<Option>[];
focusedValue: Option | null;
selectValue: Options<Option>;
clearFocusValueOnUpdate: boolean;
prevWasFocused: boolean;
inputIsHiddenAfterUpdate: boolean | null | undefined;
prevProps: Props<Option, IsMulti, Group> | void;
instancePrefix: string;
}
interface CategorizedOption<Option> {
type: 'option';
data: Option;
isDisabled: boolean;
isSelected: boolean;
label: string;
value: string;
index: number;
}
interface FocusableOptionWithId<Option> {
data: Option;
id: string;
}
interface CategorizedGroup<Option, Group extends GroupBase<Option>> {
type: 'group';
data: Group;
options: readonly CategorizedOption<Option>[];
index: number;
}
type CategorizedGroupOrOption<Option, Group extends GroupBase<Option>> =
| CategorizedGroup<Option, Group>
| CategorizedOption<Option>;
function toCategorizedOption<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
option: Option,
selectValue: Options<Option>,
index: number
): CategorizedOption<Option> {
const isDisabled = isOptionDisabled(props, option, selectValue);
const isSelected = isOptionSelected(props, option, selectValue);
const label = getOptionLabel(props, option);
const value = getOptionValue(props, option);
return {
type: 'option',
data: option,
isDisabled,
isSelected,
label,
value,
index,
};
}
function buildCategorizedOptions<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
selectValue: Options<Option>
): CategorizedGroupOrOption<Option, Group>[] {
return props.options
.map((groupOrOption, groupOrOptionIndex) => {
if ('options' in groupOrOption) {
const categorizedOptions = groupOrOption.options
.map((option, optionIndex) =>
toCategorizedOption(props, option, selectValue, optionIndex)
)
.filter((categorizedOption) => isFocusable(props, categorizedOption));
return categorizedOptions.length > 0
? {
type: 'group' as const,
data: groupOrOption,
options: categorizedOptions,
index: groupOrOptionIndex,
}
: undefined;
}
const categorizedOption = toCategorizedOption(
props,
groupOrOption,
selectValue,
groupOrOptionIndex
);
return isFocusable(props, categorizedOption)
? categorizedOption
: undefined;
})
.filter(notNullish);
}
function buildFocusableOptionsFromCategorizedOptions<
Option,
Group extends GroupBase<Option>
>(categorizedOptions: readonly CategorizedGroupOrOption<Option, Group>[]) {
return categorizedOptions.reduce<Option[]>(
(optionsAccumulator, categorizedOption) => {
if (categorizedOption.type === 'group') {
optionsAccumulator.push(
...categorizedOption.options.map((option) => option.data)
);
} else {
optionsAccumulator.push(categorizedOption.data);
}
return optionsAccumulator;
},
[]
);
}
function buildFocusableOptionsWithIds<Option, Group extends GroupBase<Option>>(
categorizedOptions: readonly CategorizedGroupOrOption<Option, Group>[],
optionId: string
) {
return categorizedOptions.reduce<FocusableOptionWithId<Option>[]>(
(optionsAccumulator, categorizedOption) => {
if (categorizedOption.type === 'group') {
optionsAccumulator.push(
...categorizedOption.options.map((option) => ({
data: option.data,
id: `${optionId}-${categorizedOption.index}-${option.index}`,
}))
);
} else {
optionsAccumulator.push({
data: categorizedOption.data,
id: `${optionId}-${categorizedOption.index}`,
});
}
return optionsAccumulator;
},
[]
);
}
function buildFocusableOptions<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(props: Props<Option, IsMulti, Group>, selectValue: Options<Option>) {
return buildFocusableOptionsFromCategorizedOptions(
buildCategorizedOptions(props, selectValue)
);
}
function isFocusable<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
categorizedOption: CategorizedOption<Option>
) {
const { inputValue = '' } = props;
const { data, isSelected, label, value } = categorizedOption;
return (
(!shouldHideSelectedOptions(props) || !isSelected) &&
filterOption(props, { label, value, data }, inputValue)
);
}
function getNextFocusedValue<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(state: State<Option, IsMulti, Group>, nextSelectValue: Options<Option>) {
const { focusedValue, selectValue: lastSelectValue } = 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;
}
function getNextFocusedOption<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(state: State<Option, IsMulti, Group>, options: Options<Option>) {
const { focusedOption: lastFocusedOption } = state;
return lastFocusedOption && options.indexOf(lastFocusedOption) > -1
? lastFocusedOption
: options[0];
}
const getFocusedOptionId = <Option,>(
focusableOptionsWithIds: FocusableOptionWithId<Option>[],
focusedOption: Option
) => {
const focusedOptionId = focusableOptionsWithIds.find(
(option) => option.data === focusedOption
)?.id;
return focusedOptionId || null;
};
const getOptionLabel = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
data: Option
): string => {
return props.getOptionLabel(data);
};
const getOptionValue = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
data: Option
): string => {
return props.getOptionValue(data);
};
function isOptionDisabled<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
option: Option,
selectValue: Options<Option>
): boolean {
return typeof props.isOptionDisabled === 'function'
? props.isOptionDisabled(option, selectValue)
: false;
}
function isOptionSelected<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
option: Option,
selectValue: Options<Option>
): boolean {
if (selectValue.indexOf(option) > -1) return true;
if (typeof props.isOptionSelected === 'function') {
return props.isOptionSelected(option, selectValue);
}
const candidate = getOptionValue(props, option);
return selectValue.some((i) => getOptionValue(props, i) === candidate);
}
function filterOption<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>,
option: FilterOptionOption<Option>,
inputValue: string
) {
return props.filterOption ? props.filterOption(option, inputValue) : true;
}
const shouldHideSelectedOptions = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: Props<Option, IsMulti, Group>
) => {
const { hideSelectedOptions, isMulti } = props;
if (hideSelectedOptions === undefined) return isMulti;
return hideSelectedOptions;
};
let instanceId = 1;
export default class Select<
Option = unknown,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>
> extends Component<
Props<Option, IsMulti, Group>,
State<Option, IsMulti, Group>
> {
static defaultProps = defaultProps;
state: State<Option, IsMulti, Group> = {
ariaSelection: null,
focusedOption: null,
focusedOptionId: null,
focusableOptionsWithIds: [],
focusedValue: null,
inputIsHidden: false,
isFocused: false,
selectValue: [],
clearFocusValueOnUpdate: false,
prevWasFocused: false,
inputIsHiddenAfterUpdate: undefined,
prevProps: undefined,
instancePrefix: '',
};
// Misc. Instance Properties
// ------------------------------
blockOptionHover = false;
isComposing = false;
commonProps: any; // TODO
initialTouchX = 0;
initialTouchY = 0;
openAfterFocus = false;
scrollToFocusedOptionOnUpdate = false;
userIsDragging?: boolean;
isAppleDevice = isAppleDevice();
// Refs
// ------------------------------
controlRef: HTMLDivElement | null = null;
getControlRef: RefCallback<HTMLDivElement> = (ref) => {
this.controlRef = ref;
};
focusedOptionRef: HTMLDivElement | null = null;
getFocusedOptionRef: RefCallback<HTMLDivElement> = (ref) => {
this.focusedOptionRef = ref;
};
menuListRef: HTMLDivElement | null = null;
getMenuListRef: RefCallback<HTMLDivElement> = (ref) => {
this.menuListRef = ref;
};
inputRef: HTMLInputElement | null = null;
getInputRef: RefCallback<HTMLInputElement> = (ref) => {
this.inputRef = ref;
};
// Lifecycle
// ------------------------------
constructor(props: Props<Option, IsMulti, Group>) {
super(props);
this.state.instancePrefix =
'react-select-' + (this.props.instanceId || ++instanceId);
this.state.selectValue = cleanValue(props.value);
// Set focusedOption if menuIsOpen is set on init (e.g. defaultMenuIsOpen)
if (props.menuIsOpen && this.state.selectValue.length) {
const focusableOptionsWithIds: FocusableOptionWithId<Option>[] =
this.getFocusableOptionsWithIds();
const focusableOptions = this.buildFocusableOptions();
const optionIndex = focusableOptions.indexOf(this.state.selectValue[0]);
this.state.focusableOptionsWithIds = focusableOptionsWithIds;
this.state.focusedOption = focusableOptions[optionIndex];
this.state.focusedOptionId = getFocusedOptionId(
focusableOptionsWithIds,
focusableOptions[optionIndex]
);
}
}
static getDerivedStateFromProps(
props: Props<unknown, boolean, GroupBase<unknown>>,
state: State<unknown, boolean, GroupBase<unknown>>
) {
const {
prevProps,
clearFocusValueOnUpdate,
inputIsHiddenAfterUpdate,
ariaSelection,
isFocused,
prevWasFocused,
instancePrefix,
} = state;
const { options, value, menuIsOpen, inputValue, isMulti } = props;
const selectValue = cleanValue(value);
let newMenuOptionsState = {};
if (
prevProps &&
(value !== prevProps.value ||
options !== prevProps.options ||
menuIsOpen !== prevProps.menuIsOpen ||
inputValue !== prevProps.inputValue)
) {
const focusableOptions = menuIsOpen
? buildFocusableOptions(props, selectValue)
: [];
const focusableOptionsWithIds = menuIsOpen
? buildFocusableOptionsWithIds(
buildCategorizedOptions(props, selectValue),
`${instancePrefix}-option`
)
: [];
const focusedValue = clearFocusValueOnUpdate
? getNextFocusedValue(state, selectValue)
: null;
const focusedOption = getNextFocusedOption(state, focusableOptions);
const focusedOptionId = getFocusedOptionId(
focusableOptionsWithIds,
focusedOption
);
newMenuOptionsState = {
selectValue,
focusedOption,
focusedOptionId,
focusableOptionsWithIds,
focusedValue,
clearFocusValueOnUpdate: false,
};
}
// some updates should toggle the state of the input visibility
const newInputIsHiddenState =
inputIsHiddenAfterUpdate != null && props !== prevProps
? {
inputIsHidden: inputIsHiddenAfterUpdate,
inputIsHiddenAfterUpdate: undefined,
}
: {};
let newAriaSelection = ariaSelection;
let hasKeptFocus = isFocused && prevWasFocused;
if (isFocused && !hasKeptFocus) {
// If `value` or `defaultValue` props are not empty then announce them
// when the Select is initially focused
newAriaSelection = {
value: valueTernary(isMulti, selectValue, selectValue[0] || null),
options: selectValue,
action: 'initial-input-focus',
};
hasKeptFocus = !prevWasFocused;
}
// If the 'initial-input-focus' action has been set already
// then reset the ariaSelection to null
if (ariaSelection?.action === 'initial-input-focus') {
newAriaSelection = null;
}
return {
...newMenuOptionsState,
...newInputIsHiddenState,
prevProps: props,
ariaSelection: newAriaSelection,
prevWasFocused: hasKeptFocus,
};
}
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();
}
// Scroll focusedOption into view if menuIsOpen is set on mount (e.g. defaultMenuIsOpen)
if (
this.props.menuIsOpen &&
this.state.focusedOption &&
this.menuListRef &&
this.focusedOptionRef
) {
scrollIntoView(this.menuListRef, this.focusedOptionRef);
}
}
componentDidUpdate(prevProps: Props<Option, IsMulti, Group>) {
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();
}
if (isFocused && isDisabled && !prevProps.isDisabled) {
// ensure select state gets blurred in case Select is programmatically disabled while focused
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ isFocused: false }, this.onMenuClose);
} else if (
!isFocused &&
!isDisabled &&
prevProps.isDisabled &&
this.inputRef === document.activeElement
) {
// ensure select state gets focused in case Select is programatically re-enabled while focused (Firefox)
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ isFocused: true });
}
// 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);
}
// ==============================
// Consumer Handlers
// ==============================
onMenuOpen() {
this.props.onMenuOpen();
}
onMenuClose() {
this.onInputChange('', {
action: 'menu-close',
prevInputValue: this.props.inputValue,
});
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 focusableOptions = this.buildFocusableOptions();
let openAtIndex = focusOption === 'first' ? 0 : focusableOptions.length - 1;
if (!this.props.isMulti) {
const selectedIndex = focusableOptions.indexOf(selectValue[0]);
if (selectedIndex > -1) {
openAtIndex = selectedIndex;
}
}
// only scroll if the menu isn't already open
this.scrollToFocusedOptionOnUpdate = !(isFocused && this.menuListRef);
this.setState(
{
inputIsHiddenAfterUpdate: false,
focusedValue: null,
focusedOption: focusableOptions[openAtIndex],
focusedOptionId: this.getFocusedOptionId(focusableOptions[openAtIndex]),
},
() => this.onMenuOpen()
);
}
focusValue(direction: 'previous' | 'next') {
const { selectValue, focusedValue } = this.state;
// Only multiselects support value focusing
if (!this.props.isMulti) return;
this.setState({
focusedOption: null,
});
let focusedIndex = selectValue.indexOf(focusedValue!);
if (!focusedValue) {
focusedIndex = -1;
}
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;
}
this.setState({
inputIsHidden: nextFocus !== -1,
focusedValue: selectValue[nextFocus],
});
}
focusOption(direction: FocusDirection = 'first') {
const { pageSize } = this.props;
const { focusedOption } = this.state;
const options = this.getFocusableOptions();
if (!options.length) return;
let nextFocus = 0; // handles 'first'
let focusedIndex = options.indexOf(focusedOption!);
if (!focusedOption) {
focusedIndex = -1;
}
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,
focusedOptionId: this.getFocusedOptionId(options[nextFocus]),
});
}
onChange = (
newValue: OnChangeValue<Option, IsMulti>,
actionMeta: ActionMeta<Option>
) => {
const { onChange, name } = this.props;
actionMeta.name = name;
this.ariaOnChange(newValue, actionMeta);
onChange(newValue, actionMeta);
};
setValue = (
newValue: OnChangeValue<Option, IsMulti>,
action: SetValueAction,
option?: Option
) => {
const { closeMenuOnSelect, isMulti, inputValue } = this.props;
this.onInputChange('', { action: 'set-value', prevInputValue: inputValue });
if (closeMenuOnSelect) {
this.setState({
inputIsHiddenAfterUpdate: !isMulti,
});
this.onMenuClose();
}
// when the select value should change, we should reset focusedValue
this.setState({ clearFocusValueOnUpdate: true });
this.onChange(newValue, { action, option });
};
selectOption = (newValue: Option) => {
const { blurInputOnSelect, isMulti, name } = this.props;
const { selectValue } = this.state;
const deselected = isMulti && this.isOptionSelected(newValue, selectValue);
const isDisabled = this.isOptionDisabled(newValue, selectValue);
if (deselected) {
const candidate = this.getOptionValue(newValue);
this.setValue(
multiValueAsValue(
selectValue.filter((i) => this.getOptionValue(i) !== candidate)
),
'deselect-option',
newValue
);
} else if (!isDisabled) {
// Select option if option is not disabled
if (isMulti) {
this.setValue(
multiValueAsValue([...selectValue, newValue]),
'select-option',
newValue
);
} else {
this.setValue(singleValueAsValue(newValue), 'select-option');
}
} else {
this.ariaOnChange(singleValueAsValue(newValue), {
action: 'select-option',
option: newValue,
name,
});
return;
}
if (blurInputOnSelect) {
this.blurInput();
}
};
removeValue = (removedValue: Option) => {
const { isMulti } = this.props;
const { selectValue } = this.state;
const candidate = this.getOptionValue(removedValue);
const newValueArray = selectValue.filter(
(i) => this.getOptionValue(i) !== candidate
);
const newValue = valueTernary(
isMulti,
newValueArray,
newValueArray[0] || null
);
this.onChange(newValue, { action: 'remove-value', removedValue });
this.focusInput();
};
clearValue = () => {
const { selectValue } = this.state;
this.onChange(valueTernary(this.props.isMulti, [], null), {
action: 'clear',
removedValues: selectValue,
});
};
popValue = () => {
const { isMulti } = this.props;
const { selectValue } = this.state;
const lastSelectedValue = selectValue[selectValue.length - 1];
const newValueArray = selectValue.slice(0, selectValue.length - 1);
const newValue = valueTernary(
isMulti,
newValueArray,
newValueArray[0] || null
);
this.onChange(newValue, {
action: 'pop-value',
removedValue: lastSelectedValue,
});
};
// ==============================
// Getters
// ==============================
getTheme() {
// Use the default theme if there are no customisations.
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,
};
}
getFocusedOptionId = (focusedOption: Option) => {
return getFocusedOptionId(
this.state.focusableOptionsWithIds,
focusedOption
);
};
getFocusableOptionsWithIds = () => {
return buildFocusableOptionsWithIds(
buildCategorizedOptions(this.props, this.state.selectValue),
this.getElementId('option')
);
};
getValue = () => this.state.selectValue;
cx = (...args: any) => classNames(this.props.classNamePrefix, ...args);
getCommonProps() {
const {
clearValue,
cx,
getStyles,
getClassNames,
getValue,
selectOption,
setValue,
props,
} = this;
const { isMulti, isRtl, options } = props;
const hasValue = this.hasValue();
return {
clearValue,
cx,
getStyles,
getClassNames,
getValue,
hasValue,
isMulti,
isRtl,
options,
selectOption,
selectProps: props,
setValue,
theme: this.getTheme(),
};
}
getOptionLabel = (data: Option): string => {
return getOptionLabel(this.props, data);
};
getOptionValue = (data: Option): string => {
return getOptionValue(this.props, data);
};
getStyles = <Key extends keyof StylesProps<Option, IsMulti, Group>>(
key: Key,
props: StylesProps<Option, IsMulti, Group>[Key]
) => {
const { unstyled } = this.props;
const base = defaultStyles[key](props as any, unstyled);
base.boxSizing = 'border-box';
const custom = this.props.styles[key];
return custom ? custom(base, props as any) : base;
};
getClassNames = <Key extends keyof StylesProps<Option, IsMulti, Group>>(
key: Key,
props: StylesProps<Option, IsMulti, Group>[Key]
) => this.props.classNames[key]?.(props as any);
getElementId = (
element:
| 'group'
| 'input'
| 'listbox'
| 'option'
| 'placeholder'
| 'live-region'
) => {
return `${this.state.instancePrefix}-${element}`;
};
getComponents = () => {
return defaultComponents(this.props);
};
buildCategorizedOptions = () =>
buildCategorizedOptions(this.props, this.state.selectValue);
getCategorizedOptions = () =>
this.props.menuIsOpen ? this.buildCategorizedOptions() : [];
buildFocusableOptions = () =>
buildFocusableOptionsFromCategorizedOptions(this.buildCategorizedOptions());
getFocusableOptions = () =>
this.props.menuIsOpen ? this.buildFocusableOptions() : [];
// ==============================
// Helpers
// ==============================
ariaOnChange = (
value: OnChangeValue<Option, IsMulti>,
actionMeta: ActionMeta<Option>
) => {
this.setState({ ariaSelection: { value, ...actionMeta } });
};
hasValue() {
const { selectValue } = this.state;
return selectValue.length > 0;
}
hasOptions() {
return !!this.getFocusableOptions().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: Option, selectValue: Options<Option>): boolean {
return isOptionDisabled(this.props, option, selectValue);
}
isOptionSelected(option: Option, selectValue: Options<Option>): boolean {
return isOptionSelected(this.props, option, selectValue);
}
filterOption(option: FilterOptionOption<Option>, inputValue: string) {
return filterOption(this.props, option, inputValue);
}
formatOptionLabel(
data: Option,
context: FormatOptionLabelContext
): ReactNode {
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: Group) {
return this.props.formatGroupLabel(data);
}
// ==============================
// Mouse Handlers
// ==============================
onMenuMouseDown: MouseEventHandler<HTMLDivElement> = (event) => {
if (event.button !== 0) {
return;
}
event.stopPropagation();
event.preventDefault();
this.focusInput();
};
onMenuMouseMove: MouseEventHandler<HTMLDivElement> = (event) => {
this.blockOptionHover = false;
};
onControlMouseDown = (
event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
// Event captured by dropdown indicator
if (event.defaultPrevented) {
return;
}
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 (
(event.target as HTMLElement).tagName !== 'INPUT' &&
(event.target as HTMLElement).tagName !== 'TEXTAREA'
) {
this.onMenuClose();
}
}
if (
(event.target as HTMLElement).tagName !== 'INPUT' &&
(event.target as HTMLElement).tagName !== 'TEXTAREA'
) {
event.preventDefault();
}
};
onDropdownIndicatorMouseDown = (
event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
// ignore mouse events that weren't triggered by the primary button
if (
event &&
event.type === 'mousedown' &&
(event as React.MouseEvent<HTMLDivElement>).button !== 0
) {
return;
}
if (this.props.isDisabled) return;
const { isMulti, menuIsOpen } = this.props;
this.focusInput();
if (menuIsOpen) {
this.setState({ inputIsHiddenAfterUpdate: !isMulti });
this.onMenuClose();
} else {
this.openMenu('first');
}
event.preventDefault();
};
onClearIndicatorMouseDown = (
event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
// ignore mouse events that weren't triggered by the primary button
if (
event &&
event.type === 'mousedown' &&
(event as React.MouseEvent<HTMLDivElement>).button !== 0
) {
return;
}
this.clearValue();
event.preventDefault();
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 && touches.item(0);
if (!touch) {
return;
}
this.initialTouchX = touch.clientX;
this.initialTouchY = touch.clientY;
this.userIsDragging = false;
};
onTouchMove = ({ touches }: TouchEvent) => {
const touch = touches && 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 as Node) &&
this.menuListRef &&
!this.menuListRef.contains(event.target as Node)
) {
this.blurInput();
}
// reset move vars
this.initialTouchX = 0;
this.initialTouchY = 0;
};
onControlTouchEnd: TouchEventHandler<HTMLDivElement> = (event) => {
if (this.userIsDragging) return;
this.onControlMouseDown(event);
};
onClearIndicatorTouchEnd: TouchEventHandler<HTMLDivElement> = (event) => {
if (this.userIsDragging) return;
this.onClearIndicatorMouseDown(event);
};
onDropdownIndicatorTouchEnd: TouchEventHandler<HTMLDivElement> = (event) => {
if (this.userIsDragging) return;
this.onDropdownIndicatorMouseDown(event);
};
// ==============================
// Focus Handlers
// ==============================
handleInputChange: FormEventHandler<HTMLInputElement> = (event) => {
const { inputValue: prevInputValue } = this.props;
const inputValue = event.currentTarget.value;
this.setState({ inputIsHiddenAfterUpdate: false });
this.onInputChange(inputValue, { action: 'input-change', prevInputValue });
if (!this.props.menuIsOpen) {
this.onMenuOpen();
}
};
onInputFocus: FocusEventHandler<HTMLInputElement> = (event) => {
if (this.props.onFocus) {
this.props.onFocus(event);
}
this.setState({
inputIsHiddenAfterUpdate: false,
isFocused: true,
});
if (this.openAfterFocus || this.props.openMenuOnFocus) {
this.openMenu('first');
}
this.openAfterFocus = false;
};
onInputBlur: FocusEventHandler<HTMLInputElement> = (event) => {
const { inputValue: prevInputValue } = this.props;
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', prevInputValue });
this.onMenuClose();
this.setState({
focusedValue: null,
isFocused: false,
});
};
onOptionHover = (focusedOption: Option) => {
if (this.blockOptionHover || this.state.focusedOption === focusedOption) {
return;
}
const options = this.getFocusableOptions();
const focusedOptionIndex = options.indexOf(focusedOption!);
this.setState({
focusedOption,
focusedOptionId:
focusedOptionIndex > -1 ? this.getFocusedOptionId(focusedOption) : null,
});
};
shouldHideSelectedOptions = () => {
return shouldHideSelectedOptions(this.props);
};
// If the hidden input gets focus through form submit,
// redirect focus to focusable input.
onValueInputFocus: FocusEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
this.focus();
};
// ==============================
// Keyboard Handlers
// ==============================
onKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
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.setState({
inputIsHiddenAfterUpdate: false,
});
this.onInputChange('', {
action: 'menu-close',
prevInputValue: inputValue,
});
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();
};
// ==============================
// Renderers
// ==============================
renderInput() {
const {
isDisabled,
isSearchable,
inputId,
inputValue,
tabIndex,
form,
menuIsOpen,
required,
} = this.props;
const { Input } = this.getComponents();
const { inputIsHidden, ariaSelection } = this.state;
const { commonProps } = this;
const id = inputId || this.getElementId('input');
// aria attributes makes the JSX "noisy", separated for clarity
const ariaAttributes = {
'aria-autocomplete': 'list' as const,
'aria-expanded': menuIsOpen,
'aria-haspopup': true,
'aria-errormessage': this.props['aria-errormessage'],
'aria-invalid': this.props['aria-invalid'],
'aria-label': this.props['aria-label'],
'aria-labelledby': this.props['aria-labelledby'],
'aria-required': required,
role: 'combobox',
'aria-activedescendant': this.isAppleDevice
? undefined
: this.state.focusedOptionId || '',
...(menuIsOpen && {
'aria-controls': this.getElementId('listbox'),
}),
...(!isSearchable && {
'aria-readonly': true,
}),
...(this.hasValue()
? ariaSelection?.action === 'initial-input-focus' && {
'aria-describedby': this.getElementId('live-region'),
}
: {
'aria-describedby': this.getElementId('placeholder'),
}),
};
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}
disabled={isDisabled}
tabIndex={tabIndex}
inputMode="none"
form={form}
value=""
{...ariaAttributes}
/>
);
}
return (
<Input
{...commonProps}
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
id={id}
innerRef={this.getInputRef}
isDisabled={isDisabled}
isHidden={inputIsHidden}
onBlur={this.onInputBlur}
onChange={this.handleInputChange}
onFocus={this.onInputFocus}
spellCheck="false"
tabIndex={tabIndex}
form={form}
type="text"
value={inputValue}
{...ariaAttributes}
/>
);
}
renderPlaceholderOrValue() {
const {
MultiValue,
MultiValueContainer,
MultiValueLabel,
MultiValueRemove,
SingleValue,
Placeholder,
} = this.getComponents();
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}
innerProps={{ id: this.getElementId('placeholder') }}
>
{placeholder}
</Placeholder>
);
}
if (isMulti) {
return selectValue.map((opt, index) => {
const isOptionFocused = opt === focusedValue;
const key = `${this.getOptionLabel(opt)}-${this.getOptionValue(opt)}`;
return (
<MultiValue
{...commonProps}
components={{
Container: MultiValueContainer,
Label: MultiValueLabel,
Remove: MultiValueRemove,
}}
isFocused={isOptionFocused}
isDisabled={isDisabled}
key={key}
index={index}
removeProps={{
onClick: () => this.removeValue(opt),
onTouchEnd: () => this.removeValue(opt),
onMouseDown: (e) => {
e.preventDefault();
},
}}
data={opt}
>
{this.formatOptionLabel(opt, 'value')}
</MultiValue>
);
});
}
if (inputValue) {
return null;
}
const singleValue = selectValue[0];
return (
<SingleValue {...commonProps} data={singleValue} isDisabled={isDisabled}>
{this.formatOptionLabel(singleValue, 'value')}
</SingleValue>
);
}
renderClearIndicator() {
const { ClearIndicator } = this.getComponents();
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.getComponents();
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.getComponents();
// 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.getComponents();
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.getComponents();
const { commonProps } = this;
const { focusedOption } = 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: CategorizedOption<Option>, id: string) => {
const { type, data, isDisabled, isSelected, label, value } = props;
const isFocused = focusedOption === data;
const onHover = isDisabled ? undefined : () => this.onOptionHover(data);
const onSelect = isDisabled ? undefined : () => this.selectOption(data);
const optionId = `${this.getElementId('option')}-${id}`;
const innerProps = {
id: optionId,
onClick: onSelect,
onMouseMove: onHover,
onMouseOver: onHover,
tabIndex: -1,
role: 'option',
'aria-selected': this.isAppleDevice ? undefined : isSelected, // is not supported on Apple devices
};
return (
<Option
{...commonProps}
innerProps={innerProps}
data={data}
isDisabled={isDisabled}
isSelected={isSelected}
key={optionId}
label={label}
type={type}
value={value}
isFocused={isFocused}
innerRef={isFocused ? this.getFocusedOptionRef : undefined}
>
{this.formatOptionLabel(props.data, 'menu')}
</Option>
);
};
let menuUI: ReactNode;
if (this.hasOptions()) {
menuUI = this.getCategorizedOptions().map((item) => {
if (item.type === 'group') {
const { data, options, index: groupIndex } = item;
const groupId = `${this.getElementId('group')}-${groupIndex}`;
const headingId = `${groupId}-heading`;
return (
<Group
{...commonProps}
key={groupId}
data={data}
options={options}
Heading={GroupHeading}
headingProps={{
id: headingId,
data: item.data,
}}
label={this.formatGroupLabel(item.data)}
>
{item.options.map((option) =>
render(option, `${groupIndex}-${option.index}`)
)}
</Group>
);
} else if (item.type === 'option') {
return render(item, `${item.index}`);
}
});
} else if (isLoading) {
const message = loadingMessage({ inputValue });
if (message === null) return null;
menuUI = <LoadingMessage {...commonProps}>{message}</LoadingMessage>;
} else {
const message = noOptionsMessage({ inputValue });
if (message === null) return null;
menuUI = <NoOptionsMessage {...commonProps}>{message}</NoOptionsMessage>;
}
const menuPlacementProps = {
minMenuHeight,
maxMenuHeight,
menuPlacement,
menuPosition,
menuShouldScrollIntoView,
};
const menuElement = (
<MenuPlacer {...commonProps} {...menuPlacementProps}>
{({ ref, placerProps: { placement, maxHeight } }) => (
<Menu
{...commonProps}
{...menuPlacementProps}
innerRef={ref}
innerProps={{
onMouseDown: this.onMenuMouseDown,
onMouseMove: this.onMenuMouseMove,
}}
isLoading={isLoading}
placement={placement}
>
<ScrollManager
captureEnabled={captureMenuScroll}
onTopArrive={onMenuScrollToTop}
onBottomArrive={onMenuScrollToBottom}
lockEnabled={menuShouldBlockScroll}
>
{(scrollTargetRef) => (
<MenuList
{...commonProps}
innerRef={(instance) => {
this.getMenuListRef(instance);
scrollTargetRef(instance);
}}
innerProps={{
role: 'listbox',
'aria-multiselectable': commonProps.isMulti,
id: this.getElementId('listbox'),
}}
isLoading={isLoading}
maxHeight={maxHeight}
focusedOption={focusedOption}
>
{menuUI}
</MenuList>
)}
</ScrollManager>
</Menu>
)}
</MenuPlacer>
);
// positioning behaviour is almost identical for portalled and fixed,
// so we use the same component. the actual portalling logic is forked
// within the component based on `menuPosition`
return menuPortalTarget || menuPosition === 'fixed' ? (
<MenuPortal
{...commonProps}
appendTo={menuPortalTarget}
controlElement={this.controlRef}
menuPlacement={menuPlacement}
menuPosition={menuPosition}
>
{menuElement}
</MenuPortal>
) : (
menuElement
);
}
renderFormField() {
const { delimiter, isDisabled, isMulti, name, required } = this.props;
const { selectValue } = this.state;
if (required && !this.hasValue() && !isDisabled) {
return <RequiredInput name={name} onFocus={this.onValueInputFocus} />;
}
if (!name || isDisabled) return;
if (isMulti) {
if (delimiter) {
const value = selectValue
.map((opt) => this.getOptionValue(opt))
.join(delimiter);
return <input name={name} type="hidden" value={value} />;
} else {
const input =
selectValue.length > 0 ? (
selectValue.map((opt, i) => (
<input
key={`i-${i}`}
name={name}
type="hidden"
value={this.getOptionValue(opt)}
/>
))
) : (
<input name={name} type="hidden" value="" />
);
return <div>{input}</div>;
}
} else {
const value = selectValue[0] ? this.getOptionValue(selectValue[0]) : '';
return <input name={name} type="hidden" value={value} />;
}
}
renderLiveRegion() {
const { commonProps } = this;
const {
ariaSelection,
focusedOption,
focusedValue,
isFocused,
selectValue,
} = this.state;
const focusableOptions = this.getFocusableOptions();
return (
<LiveRegion
{...commonProps}
id={this.getElementId('live-region')}
ariaSelection={ariaSelection}
focusedOption={focusedOption}
focusedValue={focusedValue}
isFocused={isFocused}
selectValue={selectValue}
focusableOptions={focusableOptions}
isAppleDevice={this.isAppleDevice}
/>
);
}
render() {
const { Control, IndicatorsContainer, SelectContainer, ValueContainer } =
this.getComponents();
const { className, id, isDisabled, menuIsOpen } = this.props;
const { isFocused } = this.state;
const commonProps = (this.commonProps = this.getCommonProps());
return (
<SelectContainer
{...commonProps}
className={className}
innerProps={{
id: id,
onKeyDown: this.onKeyDown,
}}
isDisabled={isDisabled}
isFocused={isFocused}
>
{this.renderLiveRegion()}
<Control
{...commonProps}
innerRef={this.getControlRef}
innerProps={{
onMouseDown: this.onControlMouseDown,
onTouchEnd: this.onControlTouchEnd,
}}
isDisabled={isDisabled}
isFocused={isFocused}
menuIsOpen={menuIsOpen}
>
<ValueContainer {...commonProps} isDisabled={isDisabled}>
{this.renderPlaceholderOrValue()}
{this.renderInput()}
</ValueContainer>
<IndicatorsContainer {...commonProps} isDisabled={isDisabled}>
{this.renderClearIndicator()}
{this.renderLoadingIndicator()}
{this.renderIndicatorSeparator()}
{this.renderDropdownIndicator()}
</IndicatorsContainer>
</Control>
{this.renderMenu()}
{this.renderFormField()}
</SelectContainer>
);
}
}
export type PublicBaseSelectProps<
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
> = JSX.LibraryManagedAttributes<typeof Select, Props<Option, IsMulti, Group>>;