render()

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>
    );
  }