diff --git a/.changeset/late-shrimps-sniff.md b/.changeset/late-shrimps-sniff.md new file mode 100644 index 000000000..171976637 --- /dev/null +++ b/.changeset/late-shrimps-sniff.md @@ -0,0 +1,5 @@ +--- +'svelte-ux': patch +--- + +Unify and enhance search functionality of SelectField and MultiSelect components, enabling new custom search capability in MultiSelect. diff --git a/packages/svelte-ux/src/lib/components/MultiSelect.svelte b/packages/svelte-ux/src/lib/components/MultiSelect.svelte index edbe14b28..0afabe15c 100644 --- a/packages/svelte-ux/src/lib/components/MultiSelect.svelte +++ b/packages/svelte-ux/src/lib/components/MultiSelect.svelte @@ -8,6 +8,7 @@ import { dirtyStore, selectionStore, uniqueStore, changeStore } from '@layerstack/svelte-stores'; import { cls } from '@layerstack/tailwind'; + import { Logger } from '@layerstack/utils'; import Button from './Button.svelte'; import InfiniteScroll from './InfiniteScroll.svelte'; @@ -17,11 +18,12 @@ import type { MenuOption } from '../types/index.js'; import { getComponentClasses } from './theme.js'; + const logger = new Logger('MultiSelect'); + export let options: MenuOption<TValue>[]; export let value: TValue[] = []; export let indeterminateSelected: typeof value = []; export let duration = 200; - export let inlineSearch = false; export let autoFocusSearch = false; export let placeholder = 'Search items'; export let optionProps: Partial<ComponentProps<MultiSelectOption>> | undefined = undefined; @@ -88,17 +90,39 @@ $: [selectedOptions, unselectedOptions] = partition(options, (o) => value.includes(o.value)); // Filter by search text - function applyFilter(option: MenuOption<TValue>, searchText: string) { - if (searchText) { - return option.label.toLowerCase().includes(searchText.toLowerCase()); + let defaultSearch = async (text: string, options: MenuOption<TValue>[]) => { + logger.debug('search', { text, open }); + + if (text === '' || options.length === 0) { + // Reset options + return options; } else { - // show all if no search set - return true; + const words = text?.toLowerCase().split(' ') ?? []; + return options.filter((option) => { + const label = option.label.toLowerCase(); + return words.every((word) => label.includes(word)); + }); + } + }; + let customSearch: typeof defaultSearch | boolean = false; + export { customSearch as search }; + $: search = typeof customSearch === 'boolean' ? defaultSearch : customSearch; + $: usingSearch = customSearch !== false; + + let filteredOptions: MenuOption<TValue>[] = [...(options ?? [])]; + let filteredSelectedOptions: MenuOption<TValue>[] = [...(selectedOptions ?? [])]; + let filteredUnselectedOptions: MenuOption<TValue>[] = [...(unselectedOptions ?? [])]; + async function updateFilteredOptions() { + if (usingSearch) { + [filteredOptions, filteredSelectedOptions, filteredUnselectedOptions] = await Promise.all([ + search(searchText, options ?? []), + search(searchText, selectedOptions ?? []), + search(searchText, unselectedOptions ?? []), + ]); } } - $: filteredOptions = options.filter((x) => applyFilter(x, searchText)); - $: filteredSelectedOptions = selectedOptions.filter((x) => applyFilter(x, searchText)); - $: filteredUnselectedOptions = unselectedOptions.filter((x) => applyFilter(x, searchText)); + // Re-filter options when `searchText` changes + $: searchText, updateFilteredOptions(); const selection = selectionStore({ max }); // Only "subscribe" to value changes (not `$selection`) to fix correct `value` / topological ordering. Should be simplified with Svelte 5 @@ -135,7 +159,7 @@ } </script> -{#if inlineSearch} +{#if usingSearch} <div class={cls( 'search', diff --git a/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte b/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte index ffdffca54..898030f78 100644 --- a/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte +++ b/packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte @@ -20,8 +20,8 @@ export let duration = 200; export let placement: Placement = 'bottom-start'; export let autoPlacement = true; - export let inlineSearch = false; - export let autoFocusSearch = inlineSearch; + export let search: MultiSelectProps['search'] = false; + export let autoFocusSearch = Boolean(search); export let placeholder: string | undefined = undefined; export let infiniteScroll = false; export let searchText = ''; @@ -65,7 +65,7 @@ {max} {open} {duration} - {inlineSearch} + {search} {autoFocusSearch} {placeholder} {infiniteScroll} diff --git a/packages/svelte-ux/src/lib/components/SelectField.svelte b/packages/svelte-ux/src/lib/components/SelectField.svelte index 7b57f9088..860ad9ff4 100644 --- a/packages/svelte-ux/src/lib/components/SelectField.svelte +++ b/packages/svelte-ux/src/lib/components/SelectField.svelte @@ -144,16 +144,18 @@ // Reactively call anytime `selected`, `value`, or `options` change $: updateSelected(selected, value, options); - export let search = async (text: string) => { + // Filter by search text + export let search = async (text: string, options: MenuOption<TValue>[]) => { logger.debug('search', { text, open }); - if (text === '') { + if (text === '' || options.length === 0) { // Reset options - filteredOptions = options; + return options; } else { const words = text?.toLowerCase().split(' ') ?? []; - filteredOptions = options.filter((option) => { - return words.every((word) => option.label.toLowerCase().includes(word)); + return options.filter((option) => { + const label = option.label.toLowerCase(); + return words.every((word) => label.includes(word)); }); } }; @@ -198,32 +200,40 @@ } } + let previousSearchText = ''; + // Do not search if menu is not open / closing on selection $: if (open) { // Capture current highlighted item (attempt to restore after searching) const prevHighlightedOption = filteredOptions[highlightIndex]; // Do not search if menu is not open / closing on selection - search(searchText).then(() => { - // TODO: Find a way for scrollIntoView to still highlight after the menu height transition finishes - const selectedIndex = filteredOptions.findIndex((o) => o.value === value); - if (highlightIndex === -1) { - // Highlight selected if none currently - highlightIndex = selectedIndex === -1 ? nextOptionIndex(-1) : selectedIndex; - } else { - // Attempt to re-highlight previously highlighted option after search - const prevHighlightedOptionIndex = filteredOptions.findIndex( - (o) => o === prevHighlightedOption - ); - - if (prevHighlightedOptionIndex !== -1) { - // Maintain previously highlight index after filter update (option still available) - highlightIndex = prevHighlightedOptionIndex; + if (searchText.trim() && previousSearchText !== searchText) { + previousSearchText = searchText; + search(searchText, options ?? []).then((options) => { + // Update filtered options with new results + filteredOptions = options; + // TODO: Find a way for scrollIntoView to still highlight after the menu height transition finishes + const selectedIndex = options.findIndex((o) => o.value === value); + if (highlightIndex === -1) { + // Highlight selected if none currently + highlightIndex = selectedIndex === -1 ? nextOptionIndex(-1) : selectedIndex; } else { - // Highlight first option - highlightIndex = nextOptionIndex(-1); + // Attempt to re-highlight previously highlighted option after search + const prevHighlightedOptionIndex = options.findIndex((o) => o === prevHighlightedOption); + + if (prevHighlightedOptionIndex !== -1) { + // Maintain previously highlight index after filter update (option still available) + highlightIndex = prevHighlightedOptionIndex; + } else { + // Highlight first option + highlightIndex = nextOptionIndex(-1); + } } - } - }); + }); + } else if (searchText.trim() === '') { + // Restore options if cleared (show all options) + filteredOptions = options; + } } function onChange(e: ComponentEvents<TextField>['change']) { diff --git a/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte b/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte index c331b71c6..89bfed545 100644 --- a/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte +++ b/packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte @@ -60,11 +60,11 @@ /> </Preview> -<h2>inlineSearch</h2> +<h2>search</h2> <Preview> {value.length} selected - <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch /> + <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search /> </Preview> <h2>maintainOrder</h2> @@ -133,17 +133,12 @@ </div> </Preview> -<h2>many options w/ inlineSearch</h2> +<h2>many options w/ search</h2> <Preview> {value.length} selected <div class="flex flex-col max-h-[360px] overflow-auto"> - <MultiSelect - options={manyOptions} - {value} - on:change={(e) => (value = e.detail.value)} - inlineSearch - /> + <MultiSelect options={manyOptions} {value} on:change={(e) => (value = e.detail.value)} search /> </div> </Preview> @@ -156,7 +151,7 @@ options={manyOptions} {value} on:change={(e) => (value = e.detail.value)} - inlineSearch + search infiniteScroll /> </div> @@ -167,7 +162,7 @@ <Preview> {value.length} selected <div class="flex flex-col max-h-[360px] overflow-auto"> - <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch> + <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search> <div slot="actions"> <Button color="primary" icon={mdiPlus}>Add item</Button> </div> @@ -180,7 +175,7 @@ <Preview> {value.length} selected <div class="flex flex-col max-h-[360px] overflow-auto"> - <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch max={2}> + <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search max={2}> <div slot="actions" let:selection class="flex items-center"> {#if selection.isMaxSelected()} <div class="text-sm text-danger">Maximum selection reached</div> @@ -195,7 +190,7 @@ <Preview> {value.length} selected <div class="flex flex-col max-h-[360px] overflow-auto"> - <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch> + <MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search> <MultiSelectOption slot="option" let:option @@ -264,6 +259,56 @@ </div> </Preview> +<h2>option slot with custom search</h2> + +<Preview> + <div class="mb-4 text-surface-content text-sm"> + Options can be searched by their values ({options.map((o) => o.value).join(', ')}) + </div> + {value.length} selected + <MultiSelect + {options} + {value} + on:change={(e) => (value = e.detail.value)} + search={async (text, options) => { + text = text?.trim(); + if (!text || options.length === 0) { + return options; + } else { + const words = text?.toLowerCase().split(' ') ?? []; + return options.filter((option) => { + const searchableText = [option.label, option.value].join(' ').toLowerCase(); + return words.every((word) => searchableText.includes(word)); + }); + } + }} + > + <MultiSelectOption + slot="option" + let:label + let:value + let:checked + let:indeterminate + let:disabled + let:onChange + {checked} + {indeterminate} + {disabled} + on:change={onChange} + classes={{ + container: 'flex items-center gap-1', + }} + > + <span + class="grid place-items-center size-6 text-xs rounded-full bg-surface-content/15 text-surface-content/75" + > + {value} + </span> + {label} + </MultiSelectOption> + </MultiSelect> +</Preview> + <h2>Form integration</h2> <Preview> diff --git a/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte b/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte index bbdae0b33..2c2263148 100644 --- a/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte +++ b/packages/svelte-ux/src/routes/docs/components/MultiSelectMenu/+page.svelte @@ -92,7 +92,7 @@ </div> </Preview> -<h2>inlineSearch</h2> +<h2>search</h2> <Preview> <span> @@ -107,7 +107,7 @@ }} {open} on:close={toggleOff} - inlineSearch + search placeholder="Pick a number" /> </ToggleButton> @@ -136,7 +136,7 @@ </span> </Preview> -<h2>maintainOrder w/ inlineSearch</h2> +<h2>maintainOrder w/ search</h2> <Preview> <span> @@ -151,7 +151,7 @@ }} {open} on:close={toggleOff} - inlineSearch + search maintainOrder placeholder="Pick a number" /> @@ -180,7 +180,7 @@ </span> </Preview> -<h2>many options w/ inlineSearch</h2> +<h2>many options w/ search</h2> <Preview> <span> @@ -196,7 +196,7 @@ {open} on:close={toggleOff} classes={{ menu: 'max-h-[360px] w-[360px]' }} - inlineSearch + search /> </ToggleButton> </span> @@ -218,7 +218,7 @@ {open} on:close={toggleOff} classes={{ menu: 'max-h-[360px] w-[360px]' }} - inlineSearch + search infiniteScroll /> </ToggleButton> @@ -287,7 +287,7 @@ {open} on:close={toggleOff} classes={{ menu: 'w-[360px]' }} - inlineSearch + search > <div slot="actions"> <Button color="primary" icon={mdiPlus}>Add item</Button> @@ -313,7 +313,7 @@ {open} on:close={toggleOff} classes={{ menu: 'w-[360px]' }} - inlineSearch + search > <MultiSelectOption slot="option" @@ -336,3 +336,63 @@ </ToggleButton> </span> </Preview> + +<h2>option slot with custom search</h2> + +<Preview> + <div class="mb-4 text-surface-content text-sm"> + Options can be searched by their values ({options.map((o) => o.value).join(', ')}) + </div> + <span> + <ToggleButton let:on={open} let:toggleOff transition={false}> + {value.length} selected + <MultiSelectMenu + {options} + {value} + on:change={(e) => { + // @ts-expect-error + value = e.detail.value; + }} + {open} + on:close={toggleOff} + classes={{ menu: 'w-[360px]' }} + search={async (text, options) => { + text = text?.trim(); + if (!text || options.length === 0) { + return options; + } else { + const words = text?.toLowerCase().split(' ') ?? []; + return options.filter((option) => { + const searchableText = [option.label, option.value].join(' ').toLowerCase(); + return words.every((word) => searchableText.includes(word)); + }); + } + }} + > + <MultiSelectOption + slot="option" + let:option + let:label + let:value + let:checked + let:indeterminate + let:onChange + {checked} + {indeterminate} + on:change={onChange} + classes={{ + root: 'py-1', + container: 'flex items-center gap-1', + }} + > + <span + class="grid place-items-center size-6 text-xs rounded-full bg-surface-content/15 text-surface-content/75" + > + {value} + </span> + {label} + </MultiSelectOption> + </MultiSelectMenu> + </ToggleButton> + </span> +</Preview> diff --git a/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte b/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte index f1284b771..4cd0d2222 100644 --- a/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte +++ b/packages/svelte-ux/src/routes/docs/components/SelectField/+page.svelte @@ -8,6 +8,7 @@ Form, MenuItem, SelectField, + State, TextField, Toggle, type MenuOption, @@ -324,6 +325,49 @@ </SelectField> </Preview> +<h2>option slot with custom search</h2> + +<Preview> + <div class="mb-4 text-surface-content text-sm"> + Options can be searched by their values ({options.map((o) => o.value).join(', ')}) + </div> + <SelectField + {options} + search={async (text, options) => { + text = text?.trim(); + if (!text || options.length === 0) { + return options; + } else { + const words = text?.toLowerCase().split(' ') ?? []; + return options.filter((option) => { + const searchableText = [option.label, option.value].join(' ').toLowerCase(); + return words.every((word) => searchableText.includes(word)); + }); + } + }} + > + <MenuItem + slot="option" + let:option + let:index + let:selected + let:highlightIndex + class={cls( + index === highlightIndex && 'bg-surface-content/5', + option === selected && 'font-semibold', + option.group ? 'px-4' : 'px-2' + )} + scrollIntoView={index === highlightIndex} + disabled={option.disabled} + > + <div> + <div>{option.label}</div> + <div class="text-sm text-surface-content/50">{option.value}</div> + </div> + </MenuItem> + </SelectField> +</Preview> + <h2>Prepend slot</h2> <Preview> @@ -485,17 +529,23 @@ </SelectField> </Preview> -<h2>Search</h2> +<h2>Custom search (case sensitive)</h2> <Preview> - <SelectField - {options} - search={async () => { - console.log('search override...'); - await delay(1000); - console.log('search override done'); - }} - /> + <State initial={false} let:value={loading} let:set={setLoading}> + {loading ? 'Loading...' : 'Search'} + <SelectField + {options} + search={async (text, options) => { + setLoading(true); + console.log('search override...'); + await delay(1000); + console.log('search override done'); + setLoading(false); + return options.filter((option) => option.label.includes(text)); + }} + /> + </State> </Preview> <h2>Placement</h2>