Skip to content

Commit 7ad0cf1

Browse files
Improve search functionality in SelectField and MultiSelect (#577)
* Add support for optional `searchLabel` property in `SelectField` * Refine examples * Format * Format as string, even if num typed * Format as string, even if num typed (2/2) * Refactor PR to update `search` logic (see description for details) - update `search` prop logic - remove `searchLabel` - rename `inlineSearch` -> `search` - add support in `MultiSelect` for passing custom search function into `search` prop * Format * Fix errors * Update changeset * Fix bug
1 parent 59ef8e0 commit 7ad0cf1

File tree

7 files changed

+262
-68
lines changed

7 files changed

+262
-68
lines changed

.changeset/late-shrimps-sniff.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte-ux': patch
3+
---
4+
5+
Unify and enhance search functionality of SelectField and MultiSelect components, enabling new custom search capability in MultiSelect.

packages/svelte-ux/src/lib/components/MultiSelect.svelte

+34-10
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
99
import { dirtyStore, selectionStore, uniqueStore, changeStore } from '@layerstack/svelte-stores';
1010
import { cls } from '@layerstack/tailwind';
11+
import { Logger } from '@layerstack/utils';
1112
1213
import Button from './Button.svelte';
1314
import InfiniteScroll from './InfiniteScroll.svelte';
@@ -17,11 +18,12 @@
1718
import type { MenuOption } from '../types/index.js';
1819
import { getComponentClasses } from './theme.js';
1920
21+
const logger = new Logger('MultiSelect');
22+
2023
export let options: MenuOption<TValue>[];
2124
export let value: TValue[] = [];
2225
export let indeterminateSelected: typeof value = [];
2326
export let duration = 200;
24-
export let inlineSearch = false;
2527
export let autoFocusSearch = false;
2628
export let placeholder = 'Search items';
2729
export let optionProps: Partial<ComponentProps<MultiSelectOption>> | undefined = undefined;
@@ -88,17 +90,39 @@
8890
$: [selectedOptions, unselectedOptions] = partition(options, (o) => value.includes(o.value));
8991
9092
// Filter by search text
91-
function applyFilter(option: MenuOption<TValue>, searchText: string) {
92-
if (searchText) {
93-
return option.label.toLowerCase().includes(searchText.toLowerCase());
93+
let defaultSearch = async (text: string, options: MenuOption<TValue>[]) => {
94+
logger.debug('search', { text, open });
95+
96+
if (text === '' || options.length === 0) {
97+
// Reset options
98+
return options;
9499
} else {
95-
// show all if no search set
96-
return true;
100+
const words = text?.toLowerCase().split(' ') ?? [];
101+
return options.filter((option) => {
102+
const label = option.label.toLowerCase();
103+
return words.every((word) => label.includes(word));
104+
});
105+
}
106+
};
107+
let customSearch: typeof defaultSearch | boolean = false;
108+
export { customSearch as search };
109+
$: search = typeof customSearch === 'boolean' ? defaultSearch : customSearch;
110+
$: usingSearch = customSearch !== false;
111+
112+
let filteredOptions: MenuOption<TValue>[] = [...(options ?? [])];
113+
let filteredSelectedOptions: MenuOption<TValue>[] = [...(selectedOptions ?? [])];
114+
let filteredUnselectedOptions: MenuOption<TValue>[] = [...(unselectedOptions ?? [])];
115+
async function updateFilteredOptions() {
116+
if (usingSearch) {
117+
[filteredOptions, filteredSelectedOptions, filteredUnselectedOptions] = await Promise.all([
118+
search(searchText, options ?? []),
119+
search(searchText, selectedOptions ?? []),
120+
search(searchText, unselectedOptions ?? []),
121+
]);
97122
}
98123
}
99-
$: filteredOptions = options.filter((x) => applyFilter(x, searchText));
100-
$: filteredSelectedOptions = selectedOptions.filter((x) => applyFilter(x, searchText));
101-
$: filteredUnselectedOptions = unselectedOptions.filter((x) => applyFilter(x, searchText));
124+
// Re-filter options when `searchText` changes
125+
$: searchText, updateFilteredOptions();
102126
103127
const selection = selectionStore({ max });
104128
// Only "subscribe" to value changes (not `$selection`) to fix correct `value` / topological ordering. Should be simplified with Svelte 5
@@ -135,7 +159,7 @@
135159
}
136160
</script>
137161

138-
{#if inlineSearch}
162+
{#if usingSearch}
139163
<div
140164
class={cls(
141165
'search',

packages/svelte-ux/src/lib/components/MultiSelectMenu.svelte

+3-3
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
export let duration = 200;
2121
export let placement: Placement = 'bottom-start';
2222
export let autoPlacement = true;
23-
export let inlineSearch = false;
24-
export let autoFocusSearch = inlineSearch;
23+
export let search: MultiSelectProps['search'] = false;
24+
export let autoFocusSearch = Boolean(search);
2525
export let placeholder: string | undefined = undefined;
2626
export let infiniteScroll = false;
2727
export let searchText = '';
@@ -65,7 +65,7 @@
6565
{max}
6666
{open}
6767
{duration}
68-
{inlineSearch}
68+
{search}
6969
{autoFocusSearch}
7070
{placeholder}
7171
{infiniteScroll}

packages/svelte-ux/src/lib/components/SelectField.svelte

+34-24
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,18 @@
144144
// Reactively call anytime `selected`, `value`, or `options` change
145145
$: updateSelected(selected, value, options);
146146
147-
export let search = async (text: string) => {
147+
// Filter by search text
148+
export let search = async (text: string, options: MenuOption<TValue>[]) => {
148149
logger.debug('search', { text, open });
149150
150-
if (text === '') {
151+
if (text === '' || options.length === 0) {
151152
// Reset options
152-
filteredOptions = options;
153+
return options;
153154
} else {
154155
const words = text?.toLowerCase().split(' ') ?? [];
155-
filteredOptions = options.filter((option) => {
156-
return words.every((word) => option.label.toLowerCase().includes(word));
156+
return options.filter((option) => {
157+
const label = option.label.toLowerCase();
158+
return words.every((word) => label.includes(word));
157159
});
158160
}
159161
};
@@ -198,32 +200,40 @@
198200
}
199201
}
200202
203+
let previousSearchText = '';
204+
// Do not search if menu is not open / closing on selection
201205
$: if (open) {
202206
// Capture current highlighted item (attempt to restore after searching)
203207
const prevHighlightedOption = filteredOptions[highlightIndex];
204208
205209
// Do not search if menu is not open / closing on selection
206-
search(searchText).then(() => {
207-
// TODO: Find a way for scrollIntoView to still highlight after the menu height transition finishes
208-
const selectedIndex = filteredOptions.findIndex((o) => o.value === value);
209-
if (highlightIndex === -1) {
210-
// Highlight selected if none currently
211-
highlightIndex = selectedIndex === -1 ? nextOptionIndex(-1) : selectedIndex;
212-
} else {
213-
// Attempt to re-highlight previously highlighted option after search
214-
const prevHighlightedOptionIndex = filteredOptions.findIndex(
215-
(o) => o === prevHighlightedOption
216-
);
217-
218-
if (prevHighlightedOptionIndex !== -1) {
219-
// Maintain previously highlight index after filter update (option still available)
220-
highlightIndex = prevHighlightedOptionIndex;
210+
if (searchText.trim() && previousSearchText !== searchText) {
211+
previousSearchText = searchText;
212+
search(searchText, options ?? []).then((options) => {
213+
// Update filtered options with new results
214+
filteredOptions = options;
215+
// TODO: Find a way for scrollIntoView to still highlight after the menu height transition finishes
216+
const selectedIndex = options.findIndex((o) => o.value === value);
217+
if (highlightIndex === -1) {
218+
// Highlight selected if none currently
219+
highlightIndex = selectedIndex === -1 ? nextOptionIndex(-1) : selectedIndex;
221220
} else {
222-
// Highlight first option
223-
highlightIndex = nextOptionIndex(-1);
221+
// Attempt to re-highlight previously highlighted option after search
222+
const prevHighlightedOptionIndex = options.findIndex((o) => o === prevHighlightedOption);
223+
224+
if (prevHighlightedOptionIndex !== -1) {
225+
// Maintain previously highlight index after filter update (option still available)
226+
highlightIndex = prevHighlightedOptionIndex;
227+
} else {
228+
// Highlight first option
229+
highlightIndex = nextOptionIndex(-1);
230+
}
224231
}
225-
}
226-
});
232+
});
233+
} else if (searchText.trim() === '') {
234+
// Restore options if cleared (show all options)
235+
filteredOptions = options;
236+
}
227237
}
228238
229239
function onChange(e: ComponentEvents<TextField>['change']) {

packages/svelte-ux/src/routes/docs/components/MultiSelect/+page.svelte

+58-13
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@
6060
/>
6161
</Preview>
6262

63-
<h2>inlineSearch</h2>
63+
<h2>search</h2>
6464

6565
<Preview>
6666
{value.length} selected
67-
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch />
67+
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search />
6868
</Preview>
6969

7070
<h2>maintainOrder</h2>
@@ -133,17 +133,12 @@
133133
</div>
134134
</Preview>
135135

136-
<h2>many options w/ inlineSearch</h2>
136+
<h2>many options w/ search</h2>
137137

138138
<Preview>
139139
{value.length} selected
140140
<div class="flex flex-col max-h-[360px] overflow-auto">
141-
<MultiSelect
142-
options={manyOptions}
143-
{value}
144-
on:change={(e) => (value = e.detail.value)}
145-
inlineSearch
146-
/>
141+
<MultiSelect options={manyOptions} {value} on:change={(e) => (value = e.detail.value)} search />
147142
</div>
148143
</Preview>
149144

@@ -156,7 +151,7 @@
156151
options={manyOptions}
157152
{value}
158153
on:change={(e) => (value = e.detail.value)}
159-
inlineSearch
154+
search
160155
infiniteScroll
161156
/>
162157
</div>
@@ -167,7 +162,7 @@
167162
<Preview>
168163
{value.length} selected
169164
<div class="flex flex-col max-h-[360px] overflow-auto">
170-
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch>
165+
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search>
171166
<div slot="actions">
172167
<Button color="primary" icon={mdiPlus}>Add item</Button>
173168
</div>
@@ -180,7 +175,7 @@
180175
<Preview>
181176
{value.length} selected
182177
<div class="flex flex-col max-h-[360px] overflow-auto">
183-
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch max={2}>
178+
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search max={2}>
184179
<div slot="actions" let:selection class="flex items-center">
185180
{#if selection.isMaxSelected()}
186181
<div class="text-sm text-danger">Maximum selection reached</div>
@@ -195,7 +190,7 @@
195190
<Preview>
196191
{value.length} selected
197192
<div class="flex flex-col max-h-[360px] overflow-auto">
198-
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} inlineSearch>
193+
<MultiSelect {options} {value} on:change={(e) => (value = e.detail.value)} search>
199194
<MultiSelectOption
200195
slot="option"
201196
let:option
@@ -264,6 +259,56 @@
264259
</div>
265260
</Preview>
266261

262+
<h2>option slot with custom search</h2>
263+
264+
<Preview>
265+
<div class="mb-4 text-surface-content text-sm">
266+
Options can be searched by their values ({options.map((o) => o.value).join(', ')})
267+
</div>
268+
{value.length} selected
269+
<MultiSelect
270+
{options}
271+
{value}
272+
on:change={(e) => (value = e.detail.value)}
273+
search={async (text, options) => {
274+
text = text?.trim();
275+
if (!text || options.length === 0) {
276+
return options;
277+
} else {
278+
const words = text?.toLowerCase().split(' ') ?? [];
279+
return options.filter((option) => {
280+
const searchableText = [option.label, option.value].join(' ').toLowerCase();
281+
return words.every((word) => searchableText.includes(word));
282+
});
283+
}
284+
}}
285+
>
286+
<MultiSelectOption
287+
slot="option"
288+
let:label
289+
let:value
290+
let:checked
291+
let:indeterminate
292+
let:disabled
293+
let:onChange
294+
{checked}
295+
{indeterminate}
296+
{disabled}
297+
on:change={onChange}
298+
classes={{
299+
container: 'flex items-center gap-1',
300+
}}
301+
>
302+
<span
303+
class="grid place-items-center size-6 text-xs rounded-full bg-surface-content/15 text-surface-content/75"
304+
>
305+
{value}
306+
</span>
307+
{label}
308+
</MultiSelectOption>
309+
</MultiSelect>
310+
</Preview>
311+
267312
<h2>Form integration</h2>
268313

269314
<Preview>

0 commit comments

Comments
 (0)