refactor: extract AutocompleteSelect from Select component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-02-16 15:31:22 +00:00
parent 19f30ee6d5
commit 1dd2b0a1b2
7 changed files with 661 additions and 50 deletions
+3 -3
View File
@@ -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 @@
<span class="text-xs mb-1 leading-6">Email</span>
{#if instanceEmails}
<Select
<AutocompleteSelect
items={instanceEmails}
bind:value={email}
placeholder="Select or type an email"
allowUserInput
disablePortal={true}
error={!!emailError}
/>
@@ -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, '')
@@ -0,0 +1,178 @@
<script lang="ts">
import { clickOutside } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import CloseButton from '../common/CloseButton.svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { getLabel, processItems, type ProcessedItem } from './utils.svelte'
import SelectDropdown from './SelectDropdown.svelte'
import {
inputBaseClass,
inputBorderClass,
inputSizeClasses
} from '../text_input/TextInput.svelte'
import { ButtonType } from '../common/button/model'
type Item = { label?: string; value: string; subtitle?: string; disabled?: boolean }
let {
items,
placeholder = 'Please select',
value = $bindable(),
class: className = '',
clearable = false,
disabled: _disabled = false,
containerStyle = '',
inputClass = '',
disablePortal = false,
loading = false,
error = false,
autofocus,
noItemsMsg,
id,
size = 'md',
transformInputSelectedText,
onFocus,
onBlur,
onClear
}: {
items?: Item[]
value: string | undefined
placeholder?: string
class?: string
clearable?: boolean
disabled?: boolean
containerStyle?: string
inputClass?: string
disablePortal?: boolean
loading?: boolean
error?: boolean
autofocus?: boolean
noItemsMsg?: string
id?: string
size?: 'sm' | 'md' | 'lg'
transformInputSelectedText?: (text: string) => string
onFocus?: () => void
onBlur?: () => void
onClear?: () => void
} = $props()
let disabled = $derived(_disabled || (loading && !value))
let iconSize = $derived(ButtonType.UnifiedIconSizes[size])
let inputEl: HTMLInputElement | undefined = $state()
let open = $state(false)
let filterText = $state('')
let processedItems: ProcessedItem<string>[] = $derived.by(() => {
let args = { items, filterText }
return untrack(() => processItems(args))
})
let rawLabel = $derived.by(() => {
let entry = value ? processedItems?.find((item) => item.value === value) : undefined
return entry?.label ?? getLabel({ value }) ?? ''
})
let displayText = $derived(transformInputSelectedText?.(rawLabel) ?? rawLabel)
let inputValue = $derived(open ? filterText : displayText)
let hasFilteredItems = $derived(
!filterText ||
processedItems?.some((item) => item.label?.toLowerCase().includes(filterText.toLowerCase()))
)
let dropdownVisible = $derived(open && hasFilteredItems)
$effect(() => {
if (filterText) open = true
})
$effect(() => {
if (!open) {
filterText = ''
} else {
untrack(() => {
if (rawLabel) {
filterText = rawLabel
}
})
}
})
function setValue(item: ProcessedItem<string>) {
value = item.value
filterText = ''
open = false
}
function clearValue() {
filterText = ''
if (onClear) onClear()
else value = undefined
}
</script>
<div
class={`relative ${className}`}
use:clickOutside={{ onClickOutside: () => (open = false) }}
onpointerdown={() => onFocus?.()}
onfocus={() => onFocus?.()}
onblur={() => onBlur?.()}
>
{#if loading}
<div class="absolute z-10 right-2 h-full flex items-center">
<Loader2 size={iconSize} class="animate-spin" />
</div>
{:else if clearable && !disabled && value}
<div class="absolute z-10 right-2 h-full flex items-center">
<CloseButton
class="bg-transparent text-secondary hover:text-primary"
noBg
small
onClick={clearValue}
/>
</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}
{disabled}
type="text"
value={inputValue}
placeholder={loading && !value ? 'Loading...' : placeholder}
style={containerStyle}
class={twMerge(
inputBaseClass,
inputSizeClasses[size],
ButtonType.UnifiedHeightClasses[size],
inputBorderClass({ error, forceFocus: open }),
'w-full',
open ? '' : 'cursor-pointer',
'placeholder-hint',
clearable && !disabled && value ? 'pr-8' : '',
inputClass ?? ''
)}
autocomplete="off"
oninput={(e) => {
if (!open) open = true
filterText = e.currentTarget.value
value = filterText || undefined
}}
onpointerdown={() => (open = true)}
bind:this={inputEl}
{id}
/>
<SelectDropdown
{disablePortal}
onSelectValue={setValue}
open={dropdownVisible}
{processedItems}
{value}
{disabled}
{filterText}
getInputRect={inputEl && (() => inputEl!.getBoundingClientRect())}
{noItemsMsg}
/>
</div>
@@ -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<Value>[] = $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<Value>) {
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
})
</script>
<div
@@ -166,7 +147,7 @@
class="bg-transparent text-secondary hover:text-primary"
noBg
small
on:close={clearValue}
onClick={clearValue}
/>
</div>
{: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 @@
<SelectDropdown
{disablePortal}
onSelectValue={setValue}
open={dropdownVisible}
{open}
{processedItems}
{value}
{disabled}
@@ -4,8 +4,7 @@ export function processItems<Item extends { label?: string; value: any }>({
groupBy,
sortBy,
onCreateItem,
createText,
currentValue
createText
}: {
items?: Item[]
filterText?: string
@@ -13,7 +12,6 @@ export function processItems<Item extends { label?: string; value: any }>({
sortBy?: (a: Item, b: Item) => number
onCreateItem?: (value: string) => void
createText?: string
currentValue?: any
}): ProcessedItem<Item['value']>[] {
let items2 =
items?.map((item) => ({
@@ -30,12 +28,7 @@ export function processItems<Item extends { label?: string; value: any }>({
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,
@@ -50,6 +50,7 @@
bind:value
onCreateItem={(i) => (value = i)}
placeholder="Search or create..."
showPlaceholderOnOpen
items={onlySelectedTags}
id="custom-instance-db-select"
disabled={!$isCustomInstanceDbEnabled}
@@ -0,0 +1,454 @@
<script lang="ts">
import Select from '$lib/components/select/Select.svelte'
import AutocompleteSelect from '$lib/components/select/AutocompleteSelect.svelte'
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { ChevronDown, Moon, Sun, CircleDot } from 'lucide-svelte'
let darkMode: boolean = $state(false)
function toggleTheme() {
if (!document.documentElement.classList.contains('dark')) {
document.documentElement.classList.add('dark')
window.localStorage.setItem('dark-mode', 'dark')
} else {
document.documentElement.classList.remove('dark')
window.localStorage.setItem('dark-mode', 'light')
}
}
// -- Data --
const fruits = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry' },
{ label: 'Fig', value: 'fig' },
{ label: 'Grape', value: 'grape' }
]
const emails = [
{ label: 'alice@example.com', value: 'alice@example.com' },
{ label: 'bob@example.com', value: 'bob@example.com' },
{ label: 'charlie@example.com', value: 'charlie@example.com' }
]
const schemas = [
{ label: 'public', value: 'public' },
{ label: 'analytics', value: 'analytics' },
{ label: 'staging', value: 'staging' }
]
const groupedItems = [
{ label: 'JavaScript', value: 'js', group: 'Frontend' },
{ label: 'TypeScript', value: 'ts', group: 'Frontend' },
{ label: 'Svelte', value: 'svelte', group: 'Frontend' },
{ label: 'Python', value: 'python', group: 'Backend' },
{ label: 'Rust', value: 'rust', group: 'Backend' },
{ label: 'Go', value: 'go', group: 'Backend' },
{ label: 'PostgreSQL', value: 'postgres', group: 'Database' },
{ label: 'MySQL', value: 'mysql', group: 'Database' }
]
const longList = Array.from({ length: 50 }, (_, i) => ({
label: `Item ${i + 1} - ${['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'][i % 5]}`,
value: `item-${i + 1}`
}))
const disabledItems = [
{ label: 'Available', value: 'available' },
{ label: 'Disabled option', value: 'disabled', disabled: true },
{ label: 'Another available', value: 'another' },
{ label: 'Also disabled', value: 'also-disabled', disabled: true }
]
// -- State for each test case --
let basic: string | undefined = $state(undefined)
let preselected: string | undefined = $state('banana')
let clearableVal: string | undefined = $state('cherry')
let clearableWithCallback: string | undefined = $state('apple')
let errorVal: string | undefined = $state(undefined)
let disabledVal: string | undefined = $state('apple')
let loadingVal: string | undefined = $state(undefined)
let loadingWithVal: string | undefined = $state('banana')
let createVal: string | undefined = $state(undefined)
let allowUserInputVal: string | undefined = $state(undefined)
let allowUserInputPreselected: string | undefined = $state('alice@example.com')
let transformVal: string | undefined = $state('public')
let rightIconVal: string | undefined = $state(undefined)
let groupedVal: string | undefined = $state(undefined)
let longListVal: string | undefined = $state(undefined)
let disabledItemsVal: string | undefined = $state(undefined)
let snippetVal: string | undefined = $state(undefined)
let noItemsVal: string | undefined = $state(undefined)
let smallVal: string | undefined = $state(undefined)
let largeVal: string | undefined = $state(undefined)
// Loading simulation
let fakeLoading = $state(true)
let fakeLoadingItems: typeof fruits | undefined = $state(undefined)
function simulateLoad() {
fakeLoading = true
fakeLoadingItems = undefined
setTimeout(() => {
fakeLoadingItems = fruits
fakeLoading = false
}, 2000)
}
simulateLoad()
// Logging
let log: string[] = $state([])
function addLog(msg: string) {
log = [`${new Date().toLocaleTimeString()} - ${msg}`, ...log].slice(0, 20)
}
</script>
<div class="p-8 flex flex-col gap-8 max-w-5xl mx-auto bg-surface-secondary min-h-screen pb-32">
<header class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold text-emphasis">Select Component Sandbox</h1>
<Button
variant="subtle"
size="xs"
startIcon={{ icon: darkMode ? Sun : Moon }}
onclick={toggleTheme}
>
{darkMode ? 'Light mode' : 'Dark mode'}
</Button>
</div>
<p class="text-xs text-secondary">
Comprehensive test page for all Select component usage patterns.
</p>
</header>
<!-- BASIC SELECTION -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Basic Selection
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Default</h4>
<p class="text-2xs text-secondary">Basic select with placeholder</p>
<Select items={fruits} bind:value={basic} placeholder="Pick a fruit" />
<p class="text-2xs text-primary">Value: {JSON.stringify(basic)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Pre-selected</h4>
<p class="text-2xs text-secondary">Starts with a value, open shows it pre-filled</p>
<Select items={fruits} bind:value={preselected} placeholder="Pick a fruit" />
<p class="text-2xs text-primary">Value: {JSON.stringify(preselected)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">No items</h4>
<p class="text-2xs text-secondary">Empty items array, custom noItemsMsg</p>
<Select
items={[] as { label: string; value: string }[]}
bind:value={noItemsVal}
placeholder="Nothing here"
noItemsMsg="No options available"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(noItemsVal)}</p>
</div>
</div>
</section>
<!-- CLEARABLE & ERROR -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Clearable & Validation
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Clearable</h4>
<p class="text-2xs text-secondary">X button to clear value</p>
<Select items={fruits} bind:value={clearableVal} clearable placeholder="Pick a fruit" />
<p class="text-2xs text-primary">Value: {JSON.stringify(clearableVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Clearable + onClear callback</h4>
<p class="text-2xs text-secondary">Custom clear logic with logging</p>
<Select
items={fruits}
bind:value={clearableWithCallback}
clearable
onClear={() => {
clearableWithCallback = undefined
addLog('onClear fired')
}}
placeholder="Pick a fruit"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(clearableWithCallback)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Error state</h4>
<p class="text-2xs text-secondary">Red border when error=true</p>
<Select
items={fruits}
bind:value={errorVal}
error={!errorVal}
placeholder="Required field"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(errorVal)}</p>
{#if !errorVal}
<p class="text-2xs text-red-500">This field is required</p>
{/if}
</div>
</div>
</section>
<!-- DISABLED & LOADING -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Disabled & Loading
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Disabled</h4>
<p class="text-2xs text-secondary">Cannot interact</p>
<Select
items={fruits}
bind:value={disabledVal}
disabled={true}
placeholder="Disabled select"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(disabledVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Loading (no value)</h4>
<p class="text-2xs text-secondary">Shows spinner + "Loading..." placeholder</p>
<Select
items={fakeLoadingItems}
bind:value={loadingVal}
loading={fakeLoading}
placeholder="Pick a fruit"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(loadingVal)}</p>
<Button variant="subtle" size="xs" onclick={simulateLoad}>Reload (2s)</Button>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Loading (with value)</h4>
<p class="text-2xs text-secondary">Loading spinner but input is not disabled</p>
<Select
items={fakeLoadingItems}
bind:value={loadingWithVal}
loading={fakeLoading}
placeholder="Pick a fruit"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(loadingWithVal)}</p>
</div>
</div>
</section>
<!-- USER INPUT & CREATE -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
User Input & Item Creation
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">createText + onCreateItem</h4>
<p class="text-2xs text-secondary">Manual "Use custom:" button in dropdown</p>
<Select
items={schemas}
bind:value={createVal}
createText="Create schema:"
onCreateItem={(v) => {
createVal = v
addLog(`onCreateItem: ${v}`)
}}
placeholder="Search or create schema..."
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(createVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">AutocompleteSelect</h4>
<p class="text-2xs text-secondary">Type anything, value syncs live. Dropdown hides when no matches.</p>
<AutocompleteSelect
items={emails}
bind:value={allowUserInputVal}
placeholder="Select or type an email"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(allowUserInputVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">AutocompleteSelect (pre-selected)</h4>
<p class="text-2xs text-secondary">Pre-filled value, open shows it, can edit</p>
<AutocompleteSelect
items={emails}
bind:value={allowUserInputPreselected}
clearable
placeholder="Select or type an email"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(allowUserInputPreselected)}</p>
</div>
</div>
</section>
<!-- TRANSFORM & ICONS -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Transform & Icons
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">transformInputSelectedText</h4>
<p class="text-2xs text-secondary">Shows "Schema: X" when closed</p>
<Select
items={schemas}
bind:value={transformVal}
transformInputSelectedText={(s) => `Schema: ${s}`}
placeholder="Search schema..."
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(transformVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">RightIcon</h4>
<p class="text-2xs text-secondary">Custom icon on the right</p>
<Select
items={schemas}
bind:value={rightIconVal}
RightIcon={ChevronDown}
placeholder="Pick a schema"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(rightIconVal)}</p>
</div>
</div>
</section>
<!-- GROUPED & LONG LISTS -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Groups, Long Lists & Disabled Items
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">groupBy</h4>
<p class="text-2xs text-secondary">Items grouped by category</p>
<Select
items={groupedItems}
bind:value={groupedVal}
groupBy={(item) => item.group}
placeholder="Pick a language"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(groupedVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Long list (50 items)</h4>
<p class="text-2xs text-secondary">Scrollable dropdown, search to filter</p>
<Select items={longList} bind:value={longListVal} placeholder="Search 50 items..." />
<p class="text-2xs text-primary">Value: {JSON.stringify(longListVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Disabled items</h4>
<p class="text-2xs text-secondary">Some items cannot be selected</p>
<Select
items={disabledItems}
bind:value={disabledItemsVal}
placeholder="Pick an option"
/>
<p class="text-2xs text-primary">Value: {JSON.stringify(disabledItemsVal)}</p>
</div>
</div>
</section>
<!-- SIZES -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">Sizes</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Small (sm)</h4>
<Select items={fruits} bind:value={smallVal} size="sm" placeholder="Small" />
<p class="text-2xs text-primary">Value: {JSON.stringify(smallVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Medium (md) - default</h4>
<Select items={fruits} bind:value={basic} placeholder="Medium (default)" />
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">Large (lg)</h4>
<Select items={fruits} bind:value={largeVal} size="lg" placeholder="Large" />
<p class="text-2xs text-primary">Value: {JSON.stringify(largeVal)}</p>
</div>
</div>
</section>
<!-- SNIPPETS -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Snippets
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">startSnippet + endSnippet</h4>
<p class="text-2xs text-secondary">Custom content before/after each item label</p>
<Select items={fruits} bind:value={snippetVal} placeholder="Pick a fruit">
{#snippet startSnippet({ item })}
<CircleDot
size={12}
class={item.value === snippetVal ? 'text-green-500' : 'text-gray-300'}
/>
{/snippet}
{#snippet endSnippet({ item })}
<span class="text-2xs text-secondary ml-auto">{item.value}</span>
{/snippet}
</Select>
<p class="text-2xs text-primary">Value: {JSON.stringify(snippetVal)}</p>
</div>
<div class="flex flex-col gap-3 p-5 border border-border-light rounded-lg bg-surface">
<h4 class="text-xs font-semibold text-emphasis">bottomSnippet</h4>
<p class="text-2xs text-secondary">Custom content at the bottom of dropdown</p>
<Select items={fruits} bind:value={snippetVal} placeholder="Pick a fruit">
{#snippet bottomSnippet({ close })}
<div class="p-2 border-t border-border-light">
<button
class="text-2xs text-accent hover:underline"
onclick={() => {
addLog('Bottom action clicked')
close()
}}
>
+ Add custom option
</button>
</div>
{/snippet}
</Select>
</div>
</div>
</section>
<!-- EVENT LOG -->
<section class="flex flex-col gap-4">
<h2 class="text-lg font-semibold text-emphasis border-b border-border-light pb-2">
Event Log
</h2>
<div class="p-4 bg-surface border border-border-light rounded-lg max-h-48 overflow-y-auto">
{#if log.length === 0}
<p class="text-2xs text-secondary">Interact with components above to see events...</p>
{:else}
{#each log as entry}
<p class="text-2xs text-primary font-mono">{entry}</p>
{/each}
{/if}
</div>
</section>
</div>
<DarkModeObserver bind:darkMode />