refactor: replace Select placeholder hack with autocomplete model

Replace the bind:value getter/setter and placeholder-as-display hack with
a standard controlled input pattern (value={inputValue} + oninput handler).

- Remove showPlaceholderOnOpen prop (replaced by default behavior)
- Add allowUserInput prop: typed text syncs to value in real-time
- Separate rawLabel (for filtering) from displayText (for transformed display)
- Hide dropdown when no items match in allowUserInput mode
- Simplify AddUser to use allowUserInput instead of createText/onCreateItem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-02-16 14:51:06 +00:00
parent bfb65fc8b4
commit 4aa1f01210
4 changed files with 43 additions and 31 deletions
+14 -8
View File
@@ -119,23 +119,27 @@
items={instanceEmails}
bind:value={email}
placeholder="Select or type an email"
createText="Use custom email:"
onCreateItem={(v) => {
email = v
}}
allowUserInput
disablePortal={true}
error={!!emailError}
/>
{:else}
<TextInput inputProps={{ type: 'email', onkeyup: handleKeyUp, placeholder: 'email' }} bind:value={email} error={!!emailError} />
<TextInput
inputProps={{ type: 'email', onkeyup: handleKeyUp, placeholder: 'email' }}
bind:value={email}
error={!!emailError}
/>
{/if}
{#if emailError}
<span class="text-2xs text-red-500 mt-0.5">{emailError}</span>
<InputError error={emailError} />
{/if}
{#if !automateUsernameCreation}
<span class="text-xs mb-1 pt-2 leading-6">Username</span>
<TextInput inputProps={{ type: 'text', onkeyup: handleKeyUp, placeholder: 'username' }} bind:value={username} />
<TextInput
inputProps={{ type: 'text', onkeyup: handleKeyUp, placeholder: 'username' }}
bind:value={username}
/>
{/if}
<span class="text-xs mb-1 pt-6 leading-6">Role</span>
@@ -172,7 +176,9 @@
username = undefined
})
}}
disabled={email === undefined || !!emailError || (!automateUsernameCreation && username === undefined)}
disabled={email === undefined ||
!!emailError ||
(!automateUsernameCreation && username === undefined)}
>
Add
</Button>
@@ -252,7 +252,6 @@
transformInputSelectedText={(s) => `Schema: ${s}`}
RightIcon={ChevronDownIcon}
placeholder="Search or create schema..."
showPlaceholderOnOpen
onCreateItem={(schema) => {
schema = schema.trim().replace(/[^a-zA-Z0-9_]/g, '')
if (dbType === 'snowflake') schema = schema.toUpperCase()
@@ -42,7 +42,7 @@
itemLabelWrapperClasses,
itemButtonWrapperClasses,
size = 'md',
showPlaceholderOnOpen = false,
allowUserInput = false,
transformInputSelectedText,
groupBy,
sortBy,
@@ -76,7 +76,8 @@
itemLabelWrapperClasses?: string
itemButtonWrapperClasses?: string
size?: 'sm' | 'md' | 'lg'
showPlaceholderOnOpen?: boolean
/** When true, any text typed by the user becomes the value when the dropdown closes */
allowUserInput?: boolean
transformInputSelectedText?: (text: string) => string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
@@ -99,6 +100,23 @@
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
})
@@ -107,15 +125,13 @@
filterText = ''
} else {
untrack(() => {
if (value && !valueEntry) {
filterText = inputText
if (rawLabel) {
filterText = rawLabel
}
})
}
})
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
function setValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
onCreateItem(item.value)
@@ -131,11 +147,6 @@
if (onClear) onClear()
else value = undefined
}
let inputText = $derived.by(() => {
let text = valueEntry?.label ?? getLabel({ value }) ?? ''
return transformInputSelectedText?.(text) ?? text
})
</script>
<div
@@ -168,12 +179,8 @@
{autofocus}
{disabled}
type="text"
bind:value={() => (open ? filterText : inputText), (v) => open && (filterText = v)}
placeholder={loading && !value
? 'Loading...'
: value && !showPlaceholderOnOpen && !(open && !valueEntry)
? inputText
: placeholder}
value={inputValue}
placeholder={loading && !value ? 'Loading...' : placeholder}
style={containerStyle}
class={twMerge(
inputBaseClass,
@@ -182,16 +189,17 @@
inputBorderClass({ error, forceFocus: open }),
'w-full',
open ? '' : 'cursor-pointer',
// Show value as placeholder when opening the dropdown and the search is empty
!value ? 'placeholder-hint' : '!placeholder-primary',
'placeholder-hint',
(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}
@@ -200,7 +208,7 @@
<SelectDropdown
{disablePortal}
onSelectValue={setValue}
{open}
open={dropdownVisible}
{processedItems}
{value}
{disabled}
@@ -50,7 +50,6 @@
bind:value
onCreateItem={(i) => (value = i)}
placeholder="Search or create..."
showPlaceholderOnOpen
items={onlySelectedTags}
id="custom-instance-db-select"
disabled={!$isCustomInstanceDbEnabled}