Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve search functionality in SelectField and MultiSelect #577

Merged
5 changes: 5 additions & 0 deletions .changeset/late-shrimps-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte-ux': patch
---

Unify and enhance search functionality of SelectField and MultiSelect components, enabling new custom search capability in MultiSelect.
44 changes: 34 additions & 10 deletions packages/svelte-ux/src/lib/components/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -135,7 +159,7 @@
}
</script>

{#if inlineSearch}
{#if usingSearch}
<div
class={cls(
'search',
Expand Down
6 changes: 3 additions & 3 deletions packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -65,7 +65,7 @@
{max}
{open}
{duration}
{inlineSearch}
{search}
{autoFocusSearch}
{placeholder}
{infiniteScroll}
Expand Down
58 changes: 34 additions & 24 deletions packages/svelte-ux/src/lib/components/SelectField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
}
};
Expand Down Expand Up @@ -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']) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>

Expand All @@ -156,7 +151,7 @@
options={manyOptions}
{value}
on:change={(e) => (value = e.detail.value)}
inlineSearch
search
infiniteScroll
/>
</div>
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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
Expand Down Expand Up @@ -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>
Expand Down
Loading