This commit is contained in:
Philipp Dormann 2025-05-20 00:55:05 +02:00
parent 2c91f46375
commit 50b5e4e455
Signed by: philipp
GPG Key ID: 3BB9ADD52DCA4314
2 changed files with 166 additions and 244 deletions

View File

@ -12,235 +12,99 @@
// //
let options = [ let options = [
"Bulbasaur", { label: "Bulbasaur", value: { id: 456 } },
"Ivysaur", { label: "Ivysaur", value: { id: 456 } },
"Venusaur", { label: "Venusaur", value: { id: 456 } },
"Charmander", { label: "Charmander", value: { id: 456 } },
"Charmeleon", { label: "Charmeleon", value: { id: 456 } },
"Charizard", { label: "Charizard", value: { id: 456 } },
"Squirtle", { label: "Squirtle", value: { id: 456 } },
"Wartortle", { label: "Wartortle", value: { id: 456 } },
"Blastoise", { label: "Blastoise", value: { id: 456 } },
"Caterpie", { label: "Caterpie", value: { id: 456 } },
"Metapod", { label: "Metapod", value: { id: 456 } },
"Butterfree", { label: "Butterfree", value: { id: 456 } },
"Weedle", { label: "Weedle", value: { id: 456 } },
"Kakuna", { label: "Kakuna", value: { id: 456 } },
"Beedrill", { label: "Beedrill", value: { id: 456 } },
"Pidgey", { label: "Pidgey", value: { id: 456 } },
"Pidgeotto", { label: "Pidgeotto", value: { id: 456 } },
"Pidgeot", { label: "Pidgeot", value: { id: 456 } },
"Rattata", { label: "Rattata", value: { id: 456 } },
"Raticate", { label: "Raticate", value: { id: 456 } },
"Spearow", { label: "Spearow", value: { id: 456 } },
"Fearow", { label: "Fearow", value: { id: 456 } },
"Ekans", { label: "Ekans", value: { id: 456 } },
"Arbok", { label: "Arbok", value: { id: 456 } },
"Pikachu", { label: "Pikachu", value: { id: 456 } },
"Raichu", { label: "Raichu", value: { id: 456 } },
"Sandshrew", { label: "Sandshrew", value: { id: 456 } },
"Sandslash", { label: "Sandslash", value: { id: 456 } },
"Nidoran♀", { label: "Nidoran♀", value: { id: 456 } },
"Nidorina", { label: "Nidorina", value: { id: 456 } },
"Nidoqueen", { label: "Nidoqueen", value: { id: 456 } },
"Nidoran♂", { label: "Nidoran♂", value: { id: 456 } },
"Nidorino", { label: "Nidorino", value: { id: 456 } },
"Nidoking", { label: "Nidoking", value: { id: 456 } },
"Clefairy", { label: "Clefairy", value: { id: 456 } },
"Clefable", { label: "Clefable", value: { id: 456 } },
"Vulpix", { label: "Vulpix", value: { id: 456 } },
"Ninetales", { label: "Ninetales", value: { id: 456 } },
"Jigglypuff", { label: "Jigglypuff", value: { id: 456 } },
"Wigglytuff", { label: "Wigglytuff", value: { id: 456 } },
"Zubat", { label: "Zubat", value: { id: 456 } },
"Golbat", { label: "Golbat", value: { id: 456 } },
"Oddish", { label: "Oddish", value: { id: 456 } },
"Gloom", { label: "Gloom", value: { id: 456 } },
"Vileplume", { label: "Vileplume", value: { id: 456 } },
"Paras", { label: "Paras", value: { id: 456 } },
"Parasect", { label: "Parasect", value: { id: 456 } },
"Venonat", { label: "Venonat", value: { id: 456 } },
"Venomoth", { label: "Venomoth", value: { id: 456 } },
"Diglett", { label: "Diglett", value: { id: 456 } },
"Dugtrio", { label: "Dugtrio", value: { id: 456 } },
"Meowth", { label: "Meowth", value: { id: 456 } },
"Persian", { label: "Persian", value: { id: 456 } },
"Psyduck", { label: "Psyduck", value: { id: 456 } },
"Golduck", { label: "Golduck", value: { id: 456 } },
"Mankey", { label: "Mankey", value: { id: 456 } },
"Primeape", { label: "Primeape", value: { id: 456 } },
"Growlithe", { label: "Growlithe", value: { id: 456 } },
"Arcanine", { label: "Arcanine", value: { id: 456 } },
"Poliwag", { label: "Poliwag", value: { id: 456 } },
"Poliwhirl", { label: "Poliwhirl", value: { id: 456 } },
"Poliwrath", { label: "Poliwrath", value: { id: 456 } },
"Abra", { label: "Abra", value: { id: 456 } },
"Kadabra", { label: "Kadabra", value: { id: 456 } },
"Alakazam", { label: "Alakazam", value: { id: 456 } },
"Machop", { label: "Machop", value: { id: 456 } },
"Machoke", { label: "Machoke", value: { id: 456 } },
"Machamp", { label: "Machamp", value: { id: 456 } },
"Bellsprout", { label: "Bellsprout", value: { id: 456 } },
"Weepinbell", { label: "Weepinbell", value: { id: 456 } },
"Victreebel", { label: "Victreebel", value: { id: 456 } },
"Tentacool", { label: "Tentacool", value: { id: 456 } },
"Tentacruel", { label: "Tentacruel", value: { id: 456 } },
"Geodude", { label: "Geodude", value: { id: 456 } },
"Graveler", { label: "Graveler", value: { id: 456 } },
"Golem", { label: "Golem", value: { id: 456 } },
"Ponyta", { label: "Ponyta", value: { id: 456 } },
"Rapidash", { label: "Rapidash", value: { id: 456 } },
"Slowpoke", { label: "Slowpoke", value: { id: 456 } },
"Slowbro", { label: "Slowbro", value: { id: 456 } },
"Magnemite", { label: "Magnemite", value: { id: 456 } },
"Magneton", { label: "Magneton", value: { id: 456 } },
"Farfetch'd", { label: "Farfetch'd", value: { id: 456 } },
"Doduo", { label: "Doduo", value: { id: 456 } },
"Dodrio", { label: "Dodrio", value: { id: 456 } },
"Seel", { label: "Seel", value: { id: 456 } },
"Dewgong", { label: "Dewgong", value: { id: 456 } },
"Grimer", { label: "Grimer", value: { id: 456 } },
"Muk", { label: "Muk", value: { id: 456 } },
"Shellder", { label: "Shellder", value: { id: 456 } },
"Cloyster", { label: "Cloyster", value: { id: 456 } },
"Gastly", { label: "Gastly", value: { id: 456 } },
"Haunter", { label: "Haunter", value: { id: 456 } },
"Gengar",
"Onix",
"Drowzee",
"Hypno",
"Krabby",
"Kingler",
"Voltorb",
"Electrode",
"Exeggcute",
"Exeggutor",
"Cubone",
"Marowak",
"Hitmonlee",
"Hitmonchan",
"Lickitung",
"Koffing",
"Weezing",
"Rhyhorn",
"Rhydon",
"Chansey",
"Tangela",
"Kangaskhan",
"Horsea",
"Seadra",
"Goldeen",
"Seaking",
"Staryu",
"Starmie",
"Mr. Mime",
"Scyther",
"Jynx",
"Electabuzz",
"Magmar",
"Pinsir",
"Tauros",
"Magikarp",
"Gyarados",
"Lapras",
"Ditto",
"Eevee",
"Vaporeon",
"Jolteon",
"Flareon",
"Porygon",
"Omanyte",
"Omastar",
"Kabuto",
"Kabutops",
"Aerodactyl",
"Snorlax",
"Articuno",
"Zapdos",
"Moltres",
"Dratini",
"Dragonair",
"Dragonite",
"Mewtwo",
"Mew",
"Chikorita",
"Bayleef",
"Meganium",
"Cyndaquil",
"Quilava",
"Typhlosion",
"Totodile",
"Croconaw",
"Feraligatr",
"Sentret",
"Furret",
"Hoothoot",
"Noctowl",
"Ledyba",
"Ledian",
"Spinarak",
"Ariados",
"Crobat",
"Chinchou",
"Lanturn",
"Pichu",
"Cleffa",
"Igglybuff",
"Togepi",
"Togetic",
"Natu",
"Xatu",
"Mareep",
"Flaaffy",
"Ampharos",
"Bellossom",
"Marill",
"Azumarill",
"Sudowoodo",
"Politoed",
"Hoppip",
"Skiploom",
"Jumpluff",
"Aipom",
"Sunkern",
"Sunflora",
"Yanma",
"Wooper",
"Quagsire",
"Espeon",
"Umbreon",
"Murkrow",
"Slowking",
"Misdreavus",
"Unown",
"Wobbuffet",
"Girafarig",
"Pineco",
"Forretress",
"Dunsparce",
"Gligar",
"Steelix",
"Snubbull",
"Granbull",
"Qwilfish",
"Scizor",
"Shuckle",
"Heracross",
"Sneasel",
"Teddiursa",
"Ursaring",
"Slugma",
"Magcargo",
"Swinub",
"Piloswine",
"Corsola",
"Remoraid",
"Octillery",
"Delibird",
"Mantine",
"Skarmory",
"Houndour",
"Houndoom",
]; ];
let selectedOption; let selectedOption;
@ -351,14 +215,14 @@
{options} {options}
filterFn={(option, searchTerm) => { filterFn={(option, searchTerm) => {
// Example: Match if option starts with search term (case-insensitive) // Example: Match if option starts with search term (case-insensitive)
return option.toLowerCase().startsWith(searchTerm.toLowerCase()); return option.label.toLowerCase().startsWith(searchTerm.toLowerCase());
}} }}
bind:selected={selectedOption} bind:selected={selectedOption}
placeholder="Search fruits..." placeholder="Search fruits..."
on:select={handleSelect} on:select={handleSelect}
/> />
{#if selectedOption} {#if selectedOption}
<p class="mt-4 text-lg">Selected: {selectedOption}</p> <p class="mt-4 text-lg">Selected: {JSON.stringify(selectedOption)}</p>
{/if} {/if}
<!-- --> <!-- -->

View File

@ -4,7 +4,11 @@
// Props // Props
export let options = []; export let options = [];
export let selected = null; export let selected = null;
export let placeholder = "Search options..."; export let inputPlaceholder = "Search options...";
export let noOptionsText = "No options found";
export let inputAriaLabel = "Search and select an option";
export let toggleAriaLabel = "Toggle dropdown";
export let clearAriaLabel = "Clear selection";
export let filterFn = null; // Custom filter function export let filterFn = null; // Custom filter function
// Internal state // Internal state
@ -26,7 +30,7 @@
? filterFn ? filterFn
? options.filter((option) => filterFn(option, searchTerm)) ? options.filter((option) => filterFn(option, searchTerm))
: options.filter((option) => : options.filter((option) =>
option.toLowerCase().includes(searchTerm.toLowerCase()) option.label.toLowerCase().includes(searchTerm.toLowerCase())
) )
: options; : options;
// Reset scroll and focus when filtered options change // Reset scroll and focus when filtered options change
@ -63,11 +67,19 @@
// Handle option selection // Handle option selection
function selectOption(option) { function selectOption(option) {
selected = option; selected = option.value;
isOpen = false; isOpen = false;
searchTerm = ""; searchTerm = "";
focusedIndex = -1; focusedIndex = -1;
dispatch("onSelected", option); dispatch("onSelected", option.value);
}
// Handle clear selection
function clearSelection() {
selected = null;
searchTerm = "";
focusedIndex = -1;
dispatch("onSelected", null);
} }
// Toggle dropdown // Toggle dropdown
@ -147,6 +159,13 @@
return () => resizeObserver.disconnect(); return () => resizeObserver.disconnect();
} }
}); });
// Get display text for the input
function getDisplayText() {
if (!selected) return inputPlaceholder;
const selectedOption = options.find((option) => option.value === selected);
return selectedOption ? selectedOption.label : inputPlaceholder;
}
</script> </script>
<svelte:window on:click={handleClickOutside} /> <svelte:window on:click={handleClickOutside} />
@ -154,14 +173,14 @@
<div class="select-container relative w-full"> <div class="select-container relative w-full">
<!-- Select element with inline search --> <!-- Select element with inline search -->
<div <div
class="border rounded-md px-3 py-2 bg-white shadow-sm flex items-center" class="border rounded-md px-3 py-2 bg-white shadow-sm flex items-center gap-2"
role="combobox" role="combobox"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<input <input
type="text" type="text"
bind:value={searchTerm} bind:value={searchTerm}
placeholder={selected || placeholder} placeholder={getDisplayText()}
class="w-full bg-transparent focus:outline-none {selected class="w-full bg-transparent focus:outline-none {selected
? 'text-black' ? 'text-black'
: 'text-gray-700'}" : 'text-gray-700'}"
@ -173,8 +192,42 @@
handleKeydown(e, focusedIndex); handleKeydown(e, focusedIndex);
} }
}} }}
aria-label="Search and select an option" aria-label={inputAriaLabel}
/> />
{#if selected}
<button
type="button"
class="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-700"
on:click={clearSelection}
on:keydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
clearSelection();
} else if (e.key === "Escape") {
e.preventDefault();
isOpen = false;
focusedIndex = -1;
}
}}
role="button"
tabindex="0"
aria-label={clearAriaLabel}
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
<svg <svg
class="w-4 h-4 text-gray-500 transform {isOpen ? 'rotate-180' : ''}" class="w-4 h-4 text-gray-500 transform {isOpen ? 'rotate-180' : ''}"
fill="none" fill="none"
@ -186,11 +239,12 @@
on:keydown={(e) => { on:keydown={(e) => {
if (e.key === "Enter") toggleDropdown(); if (e.key === "Enter") toggleDropdown();
else if (e.key === "Escape") { else if (e.key === "Escape") {
e.preventDefault();
isOpen = false; isOpen = false;
focusedIndex = -1; focusedIndex = -1;
} }
}} }}
aria-label="Toggle dropdown" aria-label={toggleAriaLabel}
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -213,10 +267,10 @@
<!-- Virtualized list container --> <!-- Virtualized list container -->
<div style="height: {filteredOptions.length * itemHeight}px;"> <div style="height: {filteredOptions.length * itemHeight}px;">
<div style="transform: translateY({startIndex * itemHeight}px);"> <div style="transform: translateY({startIndex * itemHeight}px);">
{#each visibleItems as item, i (item + "-" + (startIndex + i))} {#each visibleItems as item, i (item.label + "-" + (startIndex + i))}
<div <div
class="px-3 py-2 hover:bg-blue-100 cursor-pointer {selected === class="px-3 py-2 hover:bg-blue-100 cursor-pointer {selected ===
item item.value
? 'bg-blue-50' ? 'bg-blue-50'
: ''} {focusedIndex === startIndex + i : ''} {focusedIndex === startIndex + i
? 'bg-blue-200 outline outline-2 outline-blue-500' ? 'bg-blue-200 outline outline-2 outline-blue-500'
@ -225,15 +279,15 @@
on:keydown={(e) => handleKeydown(e, startIndex + i)} on:keydown={(e) => handleKeydown(e, startIndex + i)}
role="option" role="option"
tabindex="0" tabindex="0"
aria-selected={selected === item} aria-selected={selected === item.value}
> >
{item} {item.label}
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
{:else} {:else}
<div class="px-3 py-2 text-gray-500">No options found</div> <div class="px-3 py-2 text-gray-500">{noOptionsText}</div>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -248,4 +302,8 @@
outline: 2px solid #3b82f6; outline: 2px solid #3b82f6;
outline-offset: -2px; outline-offset: -2px;
} }
:global([role="button"]:focus) {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
</style> </style>