diff --git a/changelogs/upcoming/7683.md b/changelogs/upcoming/7683.md new file mode 100644 index 00000000000..600f59376ec --- /dev/null +++ b/changelogs/upcoming/7683.md @@ -0,0 +1 @@ +- Updated `EuiSelectable`'s `isPreFiltered` prop to allow passing a configuration object, which allows disabling search highlighting in addition to search filtering diff --git a/src-docs/src/views/selectable/selectable_sizing.tsx b/src-docs/src/views/selectable/selectable_sizing.tsx index db6cb59549f..088824723ad 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -9,102 +9,154 @@ import { EuiPopoverFooter, EuiPopoverTitle, EuiSelectable, - EuiSelectableOption, + type EuiSelectableOption, + type EuiSelectableProps, EuiSpacer, EuiTitle, + EuiInputPopover, } from '../../../../src'; -export default () => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); +const OPTIONS: EuiSelectableOption[] = [ + { label: 'Titan' }, + { label: 'Enceladus' }, + { label: 'Mimas', checked: 'on' }, + { label: 'Dione' }, + { label: 'Iapetus' }, + { label: 'Phoebe' }, + { label: 'Rhea' }, + { label: 'Pandora' }, + { label: 'Tethys' }, + { label: 'Hyperion' }, + { label: 'Pan' }, + { label: 'Atlas' }, + { label: 'Prometheus' }, + { label: 'Janus' }, + { label: 'Epimetheus' }, + { label: 'Amalthea' }, + { label: 'Thebe' }, + { label: 'Io' }, + { label: 'Europa' }, + { label: 'Ganymede' }, + { label: 'Callisto' }, + { label: 'Himalia' }, + { label: 'Phobos' }, + { label: 'Deimos' }, + { label: 'Puck' }, + { label: 'Miranda' }, + { label: 'Ariel' }, + { label: 'Umbriel' }, + { label: 'Titania' }, + { label: 'Oberon' }, + { label: 'Despina' }, + { label: 'Galatea' }, + { label: 'Larissa' }, + { label: 'Triton' }, + { label: 'Nereid' }, + { label: 'Charon' }, + { label: 'Styx' }, + { label: 'Nix' }, + { label: 'Kerberos' }, + { label: 'Hydra' }, +]; - const [options, setOptions] = useState([ - { label: 'Titan' }, - { label: 'Enceladus' }, - { label: 'Mimas', checked: 'on' }, - { label: 'Dione' }, - { label: 'Iapetus', checked: 'on' }, - { label: 'Phoebe' }, - { label: 'Rhea' }, - { label: 'Pandora' }, - { label: 'Tethys' }, - { label: 'Hyperion' }, - { label: 'Pan' }, - { label: 'Atlas' }, - { label: 'Prometheus' }, - { label: 'Janus' }, - { label: 'Epimetheus' }, - { label: 'Amalthea' }, - { label: 'Thebe' }, - { label: 'Io' }, - { label: 'Europa' }, - { label: 'Ganymede' }, - { label: 'Callisto' }, - { label: 'Himalia' }, - { label: 'Phobos' }, - { label: 'Deimos' }, - { label: 'Puck' }, - { label: 'Miranda' }, - { label: 'Ariel' }, - { label: 'Umbriel' }, - { label: 'Titania' }, - { label: 'Oberon' }, - { label: 'Despina' }, - { label: 'Galatea' }, - { label: 'Larissa' }, - { label: 'Triton' }, - { label: 'Nereid' }, - { label: 'Charon' }, - { label: 'Styx' }, - { label: 'Nix' }, - { label: 'Kerberos' }, - { label: 'Hydra' }, - ]); +export default () => { + const [options, setOptions] = useState(OPTIONS); const onChange = (options: EuiSelectableOption[]) => { setOptions(options); }; return ( <> - setIsPopoverOpen(!isPopoverOpen)} - > - Show popover - - } - isOpen={isPopoverOpen} - closePopover={() => setIsPopoverOpen(false)} + + + + + + + +

In an input popover

+
+ + + + + +

+ Using listProps.bordered=true and{' '} + + listProps.paddingSize="none" + +

+
+ + - list} + + + ); +}; + +const SelectablePopover = ( + props: Pick +) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { options, onChange } = props; + + return ( + setIsPopoverOpen(!isPopoverOpen)} > - {(list, search) => ( -
- {search} - {list} - - - Manage this list - - -
- )} -
-
+ Show popover + + } + isOpen={isPopoverOpen} + closePopover={() => setIsPopoverOpen(false)} + > + + {(list, search) => ( +
+ {search} + {list} + + + Manage this list + + +
+ )} +
+ + ); +}; - +const SelectableFlyout = ( + props: Pick +) => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const { options, onChange } = props; + return ( + <> setIsFlyoutVisible(true)}> Show flyout @@ -116,7 +168,7 @@ export default () => { aria-labelledby="selectableFlyout" > { )} + + ); +}; - - - -

- Using listProps.bordered=true and{' '} - - listProps.paddingSize="none" - -

-
+const SelectableInputPopover = () => { + const [options, setOptions] = useState(OPTIONS); + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [isSearching, setIsSearching] = useState(true); - + return ( + { + setOptions(newOptions); + setIsOpen(false); - - {(list) => list} - - + if (changedOption.checked === 'on') { + setInputValue(changedOption.label); + setIsSearching(false); + } else { + setInputValue(''); + } + }} + singleSelection + searchable + searchProps={{ + value: inputValue, + onChange: (value) => { + setInputValue(value); + setIsSearching(true); + }, + onKeyDown: (event) => { + if (event.key === 'Tab') return setIsOpen(false); + if (event.key !== 'Escape') return setIsOpen(true); + }, + onClick: () => setIsOpen(true), + onFocus: () => setIsOpen(true), + }} + isPreFiltered={isSearching ? false : { highlightSearch: false }} // Shows the full list when not actively typing to search + listProps={{ + css: { '.euiSelectableList__list': { maxBlockSize: 200 } }, + }} + > + {(list, search) => ( + setIsOpen(false)} + disableFocusTrap + closeOnScroll + isOpen={isOpen} + input={search!} + panelPaddingSize="none" + > + {list} + + )} + ); }; diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index 938b8788a4a..819e265570e 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -168,10 +168,17 @@ export type EuiSelectableProps = CommonProps & */ errorMessage?: ReactElement | string | null; /** - * Control whether or not options get filtered internally or if consumer will filter - * Default: false + * Control whether or not options get filtered internally (i.e., whether filtering is + * handled by EUI or by you, the consumer). + * If set to `true`, all passed `options` will be displayed regardless of the user's + * search input. + * + * Additionally allows passing a configuration object which enables turning off + * search highlighting if needed. + * + * @default false */ - isPreFiltered?: boolean; + isPreFiltered?: boolean | { highlightSearch?: boolean }; /** * Optional screen reader instructions to announce upon focus/interaction. This text is read out * after the `EuiSelectable` label and a brief pause, but before the default keyboard instructions for @@ -222,7 +229,7 @@ export class EuiSelectable extends Component< const visibleOptions = getMatchingOptions( options, initialSearchValue, - isPreFiltered + !!isPreFiltered ); searchProps?.onChange?.(initialSearchValue, visibleOptions); @@ -262,7 +269,7 @@ export class EuiSelectable extends Component< stateUpdate.visibleOptions = getMatchingOptions( options, stateUpdate.searchValue ?? '', - isPreFiltered + !!isPreFiltered ); if ( @@ -482,7 +489,7 @@ export class EuiSelectable extends Component< const visibleOptions = getMatchingOptions( options, searchValue, - isPreFiltered + !!isPreFiltered ); this.setState({ visibleOptions }); @@ -712,7 +719,7 @@ export class EuiSelectable extends Component< 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 ?? false} + isPreFiltered={!!isPreFiltered} inputRef={(node) => { this.inputRef = node; searchProps?.inputRef?.(node); @@ -781,6 +788,7 @@ export class EuiSelectable extends Component< options={options} visibleOptions={visibleOptions} searchValue={searchValue} + isPreFiltered={isPreFiltered} activeOptionIndex={activeOptionIndex} setActiveOptionIndex={(index, cb) => { this.setState({ activeOptionIndex: index }, cb); diff --git a/src/components/selectable/selectable_list/selectable_list.test.tsx b/src/components/selectable/selectable_list/selectable_list.test.tsx index 897721906bd..aa6484b7d88 100644 --- a/src/components/selectable/selectable_list/selectable_list.test.tsx +++ b/src/components/selectable/selectable_list/selectable_list.test.tsx @@ -99,6 +99,19 @@ describe('EuiSelectableListItem', () => { container.querySelector('.euiTextTruncate') ); }); + + it('does not highlight/mark the current `searchValue` if `isPreFiltered.highlightSearch` is false', () => { + const { container } = render( + + ); + + expect(container.querySelector('.euiMark')).not.toBeInTheDocument(); + }); }); test('renderOption', () => { diff --git a/src/components/selectable/selectable_list/selectable_list.tsx b/src/components/selectable/selectable_list/selectable_list.tsx index 5f9a4a540b7..874344e333e 100644 --- a/src/components/selectable/selectable_list/selectable_list.tsx +++ b/src/components/selectable/selectable_list/selectable_list.tsx @@ -31,8 +31,11 @@ import { EuiHighlight } from '../../highlight'; import { EuiMark } from '../../mark'; import { EuiTextTruncate } from '../../text_truncate'; -import { EuiSelectableOption } from '../selectable_option'; -import { EuiSelectableOnChangeEvent } from '../selectable'; +import type { EuiSelectableOption } from '../selectable_option'; +import type { + EuiSelectableOnChangeEvent, + EuiSelectableProps, +} from '../selectable'; import { EuiSelectableListItem, EuiSelectableListItemProps, @@ -153,6 +156,7 @@ export type EuiSelectableListProps = EuiSelectableOptionsListProps & { */ allowExclusions?: boolean; searchable?: boolean; + isPreFiltered?: EuiSelectableProps['isPreFiltered']; makeOptionId: (index: number | undefined) => string; listId: string; setActiveOptionIndex: (index: number, cb?: () => void) => void; @@ -329,6 +333,7 @@ export class EuiSelectableList extends Component< setActiveOptionIndex, searchable, searchValue, + isPreFiltered, isVirtualized, } = this.props; @@ -351,6 +356,14 @@ export class EuiSelectableList extends Component< const id = makeOptionId(index); const isFocused = activeOptionIndex === index; + // Search highlighting + const hasSearch = !!searchValue; + const highlightSearch = + hasSearch && + (typeof isPreFiltered === 'object' + ? isPreFiltered.highlightSearch !== false + : true); + // Text wrapping const canWrap = !isVirtualized; const _textWrap = option.textWrap ?? this.props.textWrap; @@ -359,7 +372,7 @@ export class EuiSelectableList extends Component< // Truncation config (if any). If none, CSS truncation is used const truncationProps = textWrap === 'truncate' - ? this.getTruncationProps(option, isFocused) + ? this.getTruncationProps(option, highlightSearch, isFocused) : undefined; return ( @@ -397,7 +410,7 @@ export class EuiSelectableList extends Component< { ..._option, ...optionData }, searchValue ) - : searchValue + : highlightSearch ? this.renderSearchedText(label, truncationProps) : truncationProps ? this.renderTruncatedText(label, truncationProps) @@ -513,7 +526,11 @@ export class EuiSelectableList extends Component< }); }; - getTruncationProps = (option: EuiSelectableOption, isFocused: boolean) => { + getTruncationProps = ( + option: EuiSelectableOption, + highlightSearch: boolean, + isFocused: boolean + ) => { // Individual truncation settings should override component-wide settings const truncationProps = { ...this.props.truncationProps, @@ -522,7 +539,7 @@ export class EuiSelectableList extends Component< // If we're not actually using EuiTextTruncate, no need to continue const hasComplexTruncation = - this.props.searchValue || Object.keys(truncationProps).length > 0; + highlightSearch || Object.keys(truncationProps).length > 0; if (!hasComplexTruncation) return undefined; // Determine whether we can use the optimized default option width @@ -618,6 +635,7 @@ export class EuiSelectableList extends Component< 'aria-labelledby': ariaLabelledby, 'aria-describedby': ariaDescribedby, role, + isPreFiltered, isVirtualized, textWrap, truncationProps,