From 1dd2b0a1b2a0e7a82a1abe6f3ab990c0aed0bf64 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 16 Feb 2026 15:31:22 +0000 Subject: [PATCH] refactor: extract AutocompleteSelect from Select component Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/components/AddUser.svelte | 6 +- frontend/src/lib/components/DBManager.svelte | 1 + .../select/AutocompleteSelect.svelte | 178 +++++++ .../src/lib/components/select/Select.svelte | 60 +-- .../src/lib/components/select/utils.svelte.ts | 11 +- .../CustomInstanceDbSelect.svelte | 1 + .../src/routes/test_dev/select/+page.svelte | 454 ++++++++++++++++++ 7 files changed, 661 insertions(+), 50 deletions(-) create mode 100644 frontend/src/lib/components/select/AutocompleteSelect.svelte create mode 100644 frontend/src/routes/test_dev/select/+page.svelte diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 4bebf7794c..f3d252c5a2 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -10,7 +10,8 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { UserPlus } from 'lucide-svelte' - import Select from './select/Select.svelte' + import AutocompleteSelect from './select/AutocompleteSelect.svelte' + import InputError from './InputError.svelte' import TextInput from './text_input/TextInput.svelte' const dispatch = createEventDispatcher() @@ -115,11 +116,10 @@ Email {#if instanceEmails} - { + if (!open) open = true + filterText = e.currentTarget.value + value = filterText || undefined + }} + onpointerdown={() => (open = true)} + bind:this={inputEl} + {id} + /> + inputEl!.getBoundingClientRect())} + {noItemsMsg} + /> + diff --git a/frontend/src/lib/components/select/Select.svelte b/frontend/src/lib/components/select/Select.svelte index baaf1ea2b3..0aab7403de 100644 --- a/frontend/src/lib/components/select/Select.svelte +++ b/frontend/src/lib/components/select/Select.svelte @@ -42,7 +42,7 @@ itemLabelWrapperClasses, itemButtonWrapperClasses, size = 'md', - allowUserInput = false, + showPlaceholderOnOpen = false, transformInputSelectedText, groupBy, sortBy, @@ -76,8 +76,7 @@ itemLabelWrapperClasses?: string itemButtonWrapperClasses?: string size?: 'sm' | 'md' | 'lg' - /** When true, any text typed by the user becomes the value when the dropdown closes */ - allowUserInput?: boolean + showPlaceholderOnOpen?: boolean transformInputSelectedText?: (text: string) => string groupBy?: (item: Item) => string sortBy?: (a: Item, b: Item) => number @@ -96,42 +95,19 @@ let inputEl: HTMLInputElement | undefined = $state() let processedItems: ProcessedItem[] = $derived.by(() => { - let args = { items, createText, filterText, groupBy, onCreateItem, sortBy, currentValue: value } + let args = { items, createText, filterText, groupBy, onCreateItem, sortBy } return untrack(() => processItems(args)) }) - let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value))) - - let rawLabel = $derived(valueEntry?.label ?? getLabel({ value }) ?? '') - - let displayText = $derived(transformInputSelectedText?.(rawLabel) ?? rawLabel) - - let inputValue = $derived(open ? filterText : displayText) - - let hasFilteredItems = $derived( - !filterText || - processedItems?.some( - (item) => !item.__is_create && item.label?.toLowerCase().includes(filterText.toLowerCase()) - ) - ) - - let dropdownVisible = $derived(open && (!allowUserInput || hasFilteredItems)) - $effect(() => { if (filterText) open = true }) $effect(() => { - if (!open) { - filterText = '' - } else { - untrack(() => { - if (rawLabel) { - filterText = rawLabel - } - }) - } + if (!open) filterText = '' }) + let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value))) + function setValue(item: ProcessedItem) { if (item.__is_create && onCreateItem) { onCreateItem(item.value) @@ -147,6 +123,11 @@ if (onClear) onClear() else value = undefined } + + let inputText = $derived.by(() => { + let text = valueEntry?.label ?? getLabel({ value }) ?? '' + return transformInputSelectedText?.(text) ?? text + })
{:else if RightIcon} @@ -179,8 +160,12 @@ {autofocus} {disabled} type="text" - value={inputValue} - placeholder={loading && !value ? 'Loading...' : placeholder} + bind:value={() => (open ? filterText : inputText), (v) => open && (filterText = v)} + placeholder={loading && !value + ? 'Loading...' + : value && !showPlaceholderOnOpen + ? inputText + : placeholder} style={containerStyle} class={twMerge( inputBaseClass, @@ -189,17 +174,16 @@ inputBorderClass({ error, forceFocus: open }), 'w-full', open ? '' : 'cursor-pointer', - 'placeholder-hint', + // Show value as placeholder when opening the dropdown and the search is empty + !value ? 'placeholder-hint' : '!placeholder-primary', (clearable || RightIcon) && !disabled && value ? 'pr-8' : '', inputClass ?? '' )} autocomplete="off" oninput={(e) => { + // Explicitly open dropdown if closed and update filterText if (!open) open = true filterText = e.currentTarget.value - if (allowUserInput) { - value = filterText || undefined - } }} onpointerdown={() => (open = true)} bind:this={inputEl} @@ -208,7 +192,7 @@ ({ groupBy, sortBy, onCreateItem, - createText, - currentValue + createText }: { items?: Item[] filterText?: string @@ -13,7 +12,6 @@ export function processItems({ sortBy?: (a: Item, b: Item) => number onCreateItem?: (value: string) => void createText?: string - currentValue?: any }): ProcessedItem[] { let items2 = items?.map((item) => ({ @@ -30,12 +28,7 @@ export function processItems({ if (sortBy) { items2 = items2?.sort(sortBy) } - if ( - onCreateItem && - filterText && - filterText !== currentValue && - !items2.some((item) => item.label === filterText) - ) { + if (onCreateItem && filterText && !items2.some((item) => item.label === filterText)) { items2.push({ label: createText ?? `Add new: "${filterText}"`, value: filterText, diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte index fe5bf10af5..b74d7c9b92 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte @@ -50,6 +50,7 @@ bind:value onCreateItem={(i) => (value = i)} placeholder="Search or create..." + showPlaceholderOnOpen items={onlySelectedTags} id="custom-instance-db-select" disabled={!$isCustomInstanceDbEnabled} diff --git a/frontend/src/routes/test_dev/select/+page.svelte b/frontend/src/routes/test_dev/select/+page.svelte new file mode 100644 index 0000000000..dabae3e1ae --- /dev/null +++ b/frontend/src/routes/test_dev/select/+page.svelte @@ -0,0 +1,454 @@ + + +
+
+
+

Select Component Sandbox

+ +
+

+ Comprehensive test page for all Select component usage patterns. +

+
+ + +
+

+ Basic Selection +

+
+
+

Default

+

Basic select with placeholder

+ +

Value: {JSON.stringify(preselected)}

+
+ +
+

No items

+

Empty items array, custom noItemsMsg

+ +

Value: {JSON.stringify(clearableVal)}

+
+ +
+

Clearable + onClear callback

+

Custom clear logic with logging

+ +

Value: {JSON.stringify(errorVal)}

+ {#if !errorVal} +

This field is required

+ {/if} +
+
+
+ + +
+

+ Disabled & Loading +

+
+
+

Disabled

+

Cannot interact

+ +

Value: {JSON.stringify(loadingVal)}

+ +
+ +
+

Loading (with value)

+

Loading spinner but input is not disabled

+ { + createVal = v + addLog(`onCreateItem: ${v}`) + }} + placeholder="Search or create schema..." + /> +

Value: {JSON.stringify(createVal)}

+
+ +
+

AutocompleteSelect

+

Type anything, value syncs live. Dropdown hides when no matches.

+ +

Value: {JSON.stringify(allowUserInputVal)}

+
+ +
+

AutocompleteSelect (pre-selected)

+

Pre-filled value, open shows it, can edit

+ +

Value: {JSON.stringify(allowUserInputPreselected)}

+
+
+
+ + +
+

+ Transform & Icons +

+
+
+

transformInputSelectedText

+

Shows "Schema: X" when closed

+ +

Value: {JSON.stringify(rightIconVal)}

+
+
+
+ + +
+

+ Groups, Long Lists & Disabled Items +

+
+
+

groupBy

+

Items grouped by category

+ +

Value: {JSON.stringify(longListVal)}

+
+ +
+

Disabled items

+

Some items cannot be selected

+ +

Value: {JSON.stringify(smallVal)}

+
+ +
+

Medium (md) - default

+ +

Value: {JSON.stringify(largeVal)}

+
+
+
+ + +
+

+ Snippets +

+
+
+

startSnippet + endSnippet

+

Custom content before/after each item label

+ +

Value: {JSON.stringify(snippetVal)}

+
+ +
+

bottomSnippet

+

Custom content at the bottom of dropdown

+ +
+
+
+ + +
+

+ Event Log +

+
+ {#if log.length === 0} +

Interact with components above to see events...

+ {:else} + {#each log as entry} +

{entry}

+ {/each} + {/if} +
+
+
+ +