From 84add4b9337edd7567c9bc6818b55299cef11d7b Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 12 Apr 2024 09:13:22 -0700 Subject: [PATCH 1/7] Add an accessible example of EuiSelectable & EuiInputPopover NOTE: This is not meant to be a 1:1 equivalent to EuiComboBox --- .../views/selectable/selectable_sizing.tsx | 154 +++++++++++++----- 1 file changed, 110 insertions(+), 44 deletions(-) diff --git a/src-docs/src/views/selectable/selectable_sizing.tsx b/src-docs/src/views/selectable/selectable_sizing.tsx index db6cb59549f..ee103e61438 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -12,54 +12,57 @@ import { EuiSelectableOption, EuiSpacer, EuiTitle, + EuiInputPopover, } from '../../../../src'; +const OPTIONS: EuiSelectableOption[] = [ + { 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 [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - 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' }, - ]); + const [options, setOptions] = useState(OPTIONS); const onChange = (options: EuiSelectableOption[]) => { setOptions(options); }; @@ -145,6 +148,13 @@ export default () => { + +

In an input popover

+
+ + + +

Using listProps.bordered=true and{' '} @@ -153,8 +163,7 @@ export default () => {

- - + { ); }; + +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); + + 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 !== 'Escape') setIsOpen(true); + }, + onClick: () => setIsOpen(true), + onFocus: () => setIsOpen(true), + onBlur: () => setIsOpen(false), + }} + isPreFiltered={!isSearching} // 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} + + )} + + ); +}; From d751965401152099c67cfef45168cab3ae1a5a9c Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Sat, 13 Apr 2024 13:03:50 -0700 Subject: [PATCH 2/7] Switch initial checked to only one option to account for the new single select example --- src-docs/src/views/selectable/selectable_sizing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-docs/src/views/selectable/selectable_sizing.tsx b/src-docs/src/views/selectable/selectable_sizing.tsx index ee103e61438..c1c38a88e0c 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -20,7 +20,7 @@ const OPTIONS: EuiSelectableOption[] = [ { label: 'Enceladus' }, { label: 'Mimas', checked: 'on' }, { label: 'Dione' }, - { label: 'Iapetus', checked: 'on' }, + { label: 'Iapetus' }, { label: 'Phoebe' }, { label: 'Rhea' }, { label: 'Pandora' }, From 2476f381d16385acd26713a1e7689926297ef08f Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Sat, 13 Apr 2024 12:50:09 -0700 Subject: [PATCH 3/7] Split out popover and flyout examples into their own subcomponents for readability --- .../views/selectable/selectable_sizing.tsx | 158 ++++++++++-------- 1 file changed, 89 insertions(+), 69 deletions(-) diff --git a/src-docs/src/views/selectable/selectable_sizing.tsx b/src-docs/src/views/selectable/selectable_sizing.tsx index c1c38a88e0c..e350192d129 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -9,7 +9,8 @@ import { EuiPopoverFooter, EuiPopoverTitle, EuiSelectable, - EuiSelectableOption, + type EuiSelectableOption, + type EuiSelectableProps, EuiSpacer, EuiTitle, EuiInputPopover, @@ -59,9 +60,6 @@ const OPTIONS: EuiSelectableOption[] = [ ]; export default () => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - const [options, setOptions] = useState(OPTIONS); const onChange = (options: EuiSelectableOption[]) => { setOptions(options); @@ -69,45 +67,96 @@ export default () => { 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 @@ -119,7 +168,7 @@ export default () => { aria-labelledby="selectableFlyout" > { )} - - - - -

In an input popover

-
- - - - - -

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

-
- - - - {(list) => list} - ); }; From 103a3ddbbeb8d691746c6dde9e88aa5599c5977c Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 15 Apr 2024 09:55:30 -0700 Subject: [PATCH 4/7] =?UTF-8?q?Fix=20UX=20bugs=20via=20internal=20prop=20o?= =?UTF-8?q?verride=20=F0=9F=98=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-docs/src/views/selectable/selectable_sizing.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src-docs/src/views/selectable/selectable_sizing.tsx b/src-docs/src/views/selectable/selectable_sizing.tsx index e350192d129..98d0cd23192 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -222,6 +222,8 @@ const SelectableInputPopover = () => { singleSelection searchable searchProps={{ + // wrapperProps: { isDropdown: true }, + // placeholder: 'Select an option', value: inputValue, onChange: (value) => { setInputValue(value); @@ -237,6 +239,8 @@ const SelectableInputPopover = () => { isPreFiltered={!isSearching} // Shows the full list when not actively typing to search listProps={{ css: { '.euiSelectableList__list': { maxBlockSize: 200 } }, + // @ts-ignore - Override search highlighting when a selection is chosen + searchValue: isSearching ? inputValue : '', }} > {(list, search) => ( From 51f951afc142564cbe9e0b00bcde62dba49ff3f7 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 15 Apr 2024 10:20:49 -0700 Subject: [PATCH 5/7] Extend `isPreFiltered` API to allow disabling search highlighting as well as search filtering --- changelogs/upcoming/7683.md | 1 + .../views/selectable/selectable_sizing.tsx | 6 +--- src/components/selectable/selectable.tsx | 22 +++++++++----- .../selectable_list/selectable_list.test.tsx | 13 ++++++++ .../selectable_list/selectable_list.tsx | 30 +++++++++++++++---- 5 files changed, 54 insertions(+), 18 deletions(-) create mode 100644 changelogs/upcoming/7683.md 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 98d0cd23192..754e6ac0665 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -222,8 +222,6 @@ const SelectableInputPopover = () => { singleSelection searchable searchProps={{ - // wrapperProps: { isDropdown: true }, - // placeholder: 'Select an option', value: inputValue, onChange: (value) => { setInputValue(value); @@ -236,11 +234,9 @@ const SelectableInputPopover = () => { onFocus: () => setIsOpen(true), onBlur: () => setIsOpen(false), }} - isPreFiltered={!isSearching} // Shows the full list when not actively typing to search + isPreFiltered={isSearching ? false : { highlightSearch: false }} // Shows the full list when not actively typing to search listProps={{ css: { '.euiSelectableList__list': { maxBlockSize: 200 } }, - // @ts-ignore - Override search highlighting when a selection is chosen - searchValue: isSearching ? inputValue : '', }} > {(list, search) => ( diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index 938b8788a4a..6629c066039 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?: false }; /** * 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, From b400a4df9e76dafc93a2627f1522f93c01d1aaaa Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 15 Apr 2024 15:34:45 -0700 Subject: [PATCH 6/7] [wtf] Fix Chromium onBlur shenanigans - blur event was firing before the click event on the options - reverse was true for Firefox --- src-docs/src/views/selectable/selectable_sizing.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-docs/src/views/selectable/selectable_sizing.tsx b/src-docs/src/views/selectable/selectable_sizing.tsx index 754e6ac0665..088824723ad 100644 --- a/src-docs/src/views/selectable/selectable_sizing.tsx +++ b/src-docs/src/views/selectable/selectable_sizing.tsx @@ -228,11 +228,11 @@ const SelectableInputPopover = () => { setIsSearching(true); }, onKeyDown: (event) => { - if (event.key !== 'Escape') setIsOpen(true); + if (event.key === 'Tab') return setIsOpen(false); + if (event.key !== 'Escape') return setIsOpen(true); }, onClick: () => setIsOpen(true), onFocus: () => setIsOpen(true), - onBlur: () => setIsOpen(false), }} isPreFiltered={isSearching ? false : { highlightSearch: false }} // Shows the full list when not actively typing to search listProps={{ From eefdc7e5cb387bd41612578ad7cac09f3a7a54d9 Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Tue, 16 Apr 2024 07:56:53 -0700 Subject: [PATCH 7/7] [PR feedback] typing --- src/components/selectable/selectable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/selectable/selectable.tsx b/src/components/selectable/selectable.tsx index 6629c066039..819e265570e 100644 --- a/src/components/selectable/selectable.tsx +++ b/src/components/selectable/selectable.tsx @@ -178,7 +178,7 @@ export type EuiSelectableProps = CommonProps & * * @default false */ - isPreFiltered?: boolean | { highlightSearch?: false }; + 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