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}
-
diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte
index b57edefc51..3f7a5bbaf8 100644
--- a/frontend/src/lib/components/DBManager.svelte
+++ b/frontend/src/lib/components/DBManager.svelte
@@ -251,6 +251,7 @@
id="db-schema-select"
transformInputSelectedText={(s) => `Schema: ${s}`}
RightIcon={ChevronDownIcon}
+ showPlaceholderOnOpen
placeholder="Search or create schema..."
onCreateItem={(schema) => {
schema = schema.trim().replace(/[^a-zA-Z0-9_]/g, '')
diff --git a/frontend/src/lib/components/select/AutocompleteSelect.svelte b/frontend/src/lib/components/select/AutocompleteSelect.svelte
new file mode 100644
index 0000000000..0df15bb532
--- /dev/null
+++ b/frontend/src/lib/components/select/AutocompleteSelect.svelte
@@ -0,0 +1,178 @@
+
+
+
(open = false) }}
+ onpointerdown={() => onFocus?.()}
+ onfocus={() => onFocus?.()}
+ onblur={() => onBlur?.()}
+>
+ {#if loading}
+
+
+
+ {:else if clearable && !disabled && value}
+
+
+
+ {/if}
+
+
{
+ 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(basic)}
+
+
+
+
Pre-selected
+
Starts with a value, open shows it pre-filled
+
+
Value: {JSON.stringify(preselected)}
+
+
+
+
No items
+
Empty items array, custom noItemsMsg
+
+
Value: {JSON.stringify(noItemsVal)}
+
+
+
+
+
+
+
+ Clearable & Validation
+
+
+
+
Clearable
+
X button to clear value
+
+
Value: {JSON.stringify(clearableVal)}
+
+
+
+
Clearable + onClear callback
+
Custom clear logic with logging
+
+
+
+
Error state
+
Red border when error=true
+
+
Value: {JSON.stringify(errorVal)}
+ {#if !errorVal}
+
This field is required
+ {/if}
+
+
+
+
+
+
+
+ Disabled & Loading
+
+
+
+
Disabled
+
Cannot interact
+
+
Value: {JSON.stringify(disabledVal)}
+
+
+
+
Loading (no value)
+
Shows spinner + "Loading..." placeholder
+
+
Value: {JSON.stringify(loadingVal)}
+
+
+
+
+
Loading (with value)
+
Loading spinner but input is not disabled
+
+
Value: {JSON.stringify(loadingWithVal)}
+
+
+
+
+
+
+
+ User Input & Item Creation
+
+
+
+
createText + onCreateItem
+
Manual "Use custom:" button in dropdown
+
+
+
+
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
+
+
+
+
RightIcon
+
Custom icon on the right
+
+
Value: {JSON.stringify(rightIconVal)}
+
+
+
+
+
+
+
+ Groups, Long Lists & Disabled Items
+
+
+
+
groupBy
+
Items grouped by category
+
+
+
+
Long list (50 items)
+
Scrollable dropdown, search to filter
+
+
Value: {JSON.stringify(longListVal)}
+
+
+
+
Disabled items
+
Some items cannot be selected
+
+
Value: {JSON.stringify(disabledItemsVal)}
+
+
+
+
+
+
+ Sizes
+
+
+
Small (sm)
+
+
Value: {JSON.stringify(smallVal)}
+
+
+
+
Medium (md) - default
+
+
+
+
+
Large (lg)
+
+
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}
+
+
+
+
+