in packages/eui/src/components/selectable/selectable.tsx [543:865]
render() {
const {
children,
className,
options,
onChange,
onActiveOptionChange,
searchable,
searchProps,
singleSelection,
isLoading,
listProps,
renderOption,
height,
allowExclusions,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedby,
loadingMessage,
noMatchesMessage,
emptyMessage,
errorMessage,
selectableScreenReaderText,
isPreFiltered,
optionMatcher,
...rest
} = this.props;
const { searchValue, visibleOptions, activeOptionIndex } = this.state;
// Some messy destructuring here to remove aria-label/describedby from searchProps and listProps
// Made messier by some TS requirements
// The aria attributes are then used in getAccessibleName() to place them where they need to go
const unknownAccessibleName = {
'aria-label': undefined,
'aria-describedby': undefined,
};
const {
'aria-label': searchAriaLabel,
'aria-describedby': searchAriaDescribedby,
onChange: propsOnChange,
defaultValue, // Because we control the underlying EuiFieldSearch value state with state.searchValue, we cannot pass a defaultValue prop without a React error
inputRef, // We need to store the inputRef before passing it back to consuming applications
...cleanedSearchProps
} = (searchProps || unknownAccessibleName) as typeof searchProps &
typeof unknownAccessibleName;
const {
'aria-label': listAriaLabel,
'aria-describedby': listAriaDescribedby,
isVirtualized,
rowHeight,
...cleanedListProps
} = (listProps || unknownAccessibleName) as typeof listProps &
typeof unknownAccessibleName;
let virtualizedProps: EuiSelectableOptionsListVirtualizedProps;
if (isVirtualized === false) {
virtualizedProps = {
isVirtualized,
};
} else if (rowHeight != null) {
virtualizedProps = {
rowHeight,
};
}
const classes = classNames('euiSelectable', className);
const cssStyles = [
styles.euiSelectable,
height === 'full' && styles.fullHeight,
];
/** Create message content that replaces the list if no options are available (yet) */
let messageContent: ReactNode | undefined;
if (errorMessage != null) {
messageContent =
typeof errorMessage === 'string' ? <p>{errorMessage}</p> : errorMessage;
} else if (isLoading) {
if (loadingMessage === undefined || typeof loadingMessage === 'string') {
messageContent = (
<>
<EuiLoadingSpinner size="m" />
<EuiSpacer size="xs" />
<p>
{loadingMessage || (
<EuiI18n
token="euiSelectable.loadingOptions"
default="Loading options"
/>
)}
</p>
</>
);
} else {
messageContent = React.cloneElement(loadingMessage, {
id: this.messageContentId,
...loadingMessage.props,
});
}
} else if (searchValue && visibleOptions.length === 0) {
if (
noMatchesMessage === undefined ||
typeof noMatchesMessage === 'string'
) {
messageContent = (
<p>
{noMatchesMessage || (
<EuiI18n
token="euiSelectable.noMatchingOptions"
default="{searchValue} doesn't match any options"
values={{ searchValue: <strong>{searchValue}</strong> }}
/>
)}
</p>
);
} else {
messageContent = React.cloneElement(noMatchesMessage, {
id: this.messageContentId,
...noMatchesMessage.props,
});
}
} else if (!options.length) {
if (emptyMessage === undefined || typeof emptyMessage === 'string') {
messageContent = (
<p>
{emptyMessage || (
<EuiI18n
token="euiSelectable.noAvailableOptions"
default="No options available"
/>
)}
</p>
);
} else {
messageContent = React.cloneElement(emptyMessage, {
id: this.messageContentId,
...emptyMessage.props,
});
}
}
/**
* There are lots of ways to add an accessible name
* Usually we want the same name for the input and the listbox (which is added by aria-label/describedby)
* But you can always override it using searchProps or listProps
* This finds the correct name to use
*
* TODO: This doesn't handle being labelled (<label for="idOfInput">)
*/
const getAccessibleName = (
props:
| EuiSelectableSearchableSearchProps<T>
| EuiSelectableOptionsListPropsWithDefaults
| undefined,
messageContentId?: string
) => {
if (props && props['aria-label']) {
return { 'aria-label': props['aria-label'] };
}
const messageContentIdString = messageContentId
? ` ${messageContentId}`
: '';
if (props && props['aria-describedby']) {
return {
'aria-describedby': `${props['aria-describedby']}${messageContentIdString}`,
};
}
if (ariaLabel) {
return { 'aria-label': ariaLabel };
}
if (ariaDescribedby) {
return {
'aria-describedby': `${ariaDescribedby}${messageContentIdString}`,
};
}
return {};
};
const searchAccessibleName = getAccessibleName(
searchProps,
this.messageContentId
);
const searchHasAccessibleName = Boolean(
Object.keys(searchAccessibleName).length
);
const search = searchable ? (
<EuiI18n
tokens={[
'euiSelectable.screenReaderInstructions',
'euiSelectable.placeholderName',
]}
defaults={[
'Use the Up and Down arrow keys to move focus over options. Press Enter to select. Press Escape to collapse options.',
'Filter options',
]}
>
{([screenReaderInstructions, placeholderName]: string[]) => (
<>
<EuiSelectableSearch<T>
aria-describedby={listAriaDescribedbyId}
key="listSearch"
options={options}
value={searchValue}
onChange={this.onSearchChange}
listId={this.optionsListRef.current ? this.listId : undefined} // Only pass the listId if it exists on the page
aria-activedescendant={this.makeOptionId(activeOptionIndex)} // the current faux-focused option
placeholder={placeholderName}
isPreFiltered={!!isPreFiltered}
optionMatcher={optionMatcher!}
inputRef={(node) => {
this.inputRef = node;
searchProps?.inputRef?.(node);
}}
{...(searchHasAccessibleName
? searchAccessibleName
: { 'aria-label': placeholderName })}
{...cleanedSearchProps}
/>
<EuiScreenReaderOnly>
<p id={listAriaDescribedbyId}>
{selectableScreenReaderText} {screenReaderInstructions}
</p>
</EuiScreenReaderOnly>
</>
)}
</EuiI18n>
) : undefined;
const resultsLength = visibleOptions.filter(
(option) => !option.disabled
).length;
const listScreenReaderStatus = searchable && (
<EuiI18n
token="euiSelectable.searchResults"
default={({ resultsLength }) =>
`${resultsLength} result${resultsLength === 1 ? '' : 's'} available`
}
values={{ resultsLength }}
/>
);
const listAriaDescribedbyId = this.rootId('instructions');
const listAccessibleName = getAccessibleName(
listProps,
listAriaDescribedbyId
);
const listHasAccessibleName = Boolean(
Object.keys(listAccessibleName).length
);
const list = (
<EuiI18n token="euiSelectable.placeholderName" default="Filter options">
{(placeholderName: string) => (
<>
{searchable && (
<EuiScreenReaderLive
isActive={messageContent != null || activeOptionIndex != null}
>
{messageContent || listScreenReaderStatus}
</EuiScreenReaderLive>
)}
{messageContent ? (
<EuiSelectableMessage
data-test-subj="euiSelectableMessage"
id={this.messageContentId}
bordered={listProps && listProps.bordered}
>
{messageContent}
</EuiSelectableMessage>
) : (
<EuiSelectableList<T>
data-test-subj="euiSelectableList"
key="list"
options={options}
visibleOptions={visibleOptions}
searchValue={searchValue}
isPreFiltered={isPreFiltered}
activeOptionIndex={activeOptionIndex}
setActiveOptionIndex={(index, cb) => {
this.setState({ activeOptionIndex: index }, cb);
}}
onOptionClick={this.onOptionClick}
singleSelection={singleSelection}
ref={this.optionsListRef}
renderOption={renderOption}
height={height}
allowExclusions={allowExclusions}
searchable={searchable}
makeOptionId={this.makeOptionId}
listId={this.listId}
{...(listHasAccessibleName
? listAccessibleName
: searchable && { 'aria-label': placeholderName })}
{...cleanedListProps}
{...virtualizedProps}
/>
)}
</>
)}
</EuiI18n>
);
return (
<div
ref={this.containerRef}
css={cssStyles}
className={classes}
onKeyDown={this.onKeyDown}
onBlur={this.onContainerBlur}
onFocus={this.onFocus}
onMouseDown={this.onMouseDown}
{...rest}
>
{children && children(list, search)}
</div>
);
}