133 lines
3.2 KiB
Svelte
133 lines
3.2 KiB
Svelte
<script>
|
|
/**
|
|
* Searchable combobox input.
|
|
* @type {{ id?: string, value: string, options: string[], placeholder?: string, required?: boolean, onchange?: (v: string) => void }}
|
|
*/
|
|
let { id, value = $bindable(), options, placeholder = '', required = false, onselect, onblurcommit } = $props();
|
|
|
|
let query = $state(value);
|
|
let open = $state(false);
|
|
let focused = $state(-1);
|
|
|
|
let filtered = $derived(
|
|
query.trim() === ''
|
|
? options
|
|
: options.filter(o => o.toLowerCase().includes(query.toLowerCase()))
|
|
);
|
|
|
|
function select(opt) {
|
|
query = opt;
|
|
value = opt;
|
|
open = false;
|
|
focused = -1;
|
|
onselect?.(opt);
|
|
}
|
|
|
|
function onInput(e) {
|
|
query = e.currentTarget.value;
|
|
value = query;
|
|
open = true;
|
|
focused = -1;
|
|
}
|
|
|
|
function onKeydown(e) {
|
|
if (!open) { if (e.key === 'ArrowDown') { open = true; } return; }
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); focused = Math.min(focused + 1, filtered.length - 1); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); focused = Math.max(focused - 1, 0); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); if (focused >= 0) { select(filtered[focused]); } else if (query.trim()) { select(query.trim()); } }
|
|
else if (e.key === 'Escape') { open = false; focused = -1; }
|
|
}
|
|
|
|
function onBlur() {
|
|
setTimeout(() => {
|
|
open = false;
|
|
focused = -1;
|
|
if (onblurcommit && query.trim()) onblurcommit(query.trim());
|
|
}, 150);
|
|
}
|
|
|
|
// Keep query in sync if value is changed externally
|
|
$effect(() => { query = value; });
|
|
</script>
|
|
|
|
<div class="combo">
|
|
<input
|
|
{id}
|
|
{required}
|
|
{placeholder}
|
|
class="combo-input"
|
|
type="text"
|
|
autocomplete="off"
|
|
value={query}
|
|
oninput={onInput}
|
|
onkeydown={onKeydown}
|
|
onfocus={() => open = true}
|
|
onblur={onBlur}
|
|
/>
|
|
{#if open && filtered.length > 0}
|
|
<ul class="dropdown" role="listbox">
|
|
{#each filtered as opt, i}
|
|
<li
|
|
class="option"
|
|
class:highlighted={i === focused}
|
|
role="option"
|
|
aria-selected={opt === value}
|
|
onmousedown={() => select(opt)}
|
|
>{opt}</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.combo {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.combo-input {
|
|
font-family: var(--sans);
|
|
font-size: 14px;
|
|
font-weight: 300;
|
|
color: var(--text-h);
|
|
background: var(--bg-subtle);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 8px 12px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
width: 100%;
|
|
}
|
|
.combo-input:focus { border-color: var(--accent-border); }
|
|
|
|
.dropdown {
|
|
position: absolute;
|
|
top: calc(100% + 4px);
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
|
list-style: none;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
z-index: 50;
|
|
padding: 4px;
|
|
}
|
|
|
|
.option {
|
|
font-size: 13px;
|
|
font-weight: 300;
|
|
color: var(--text);
|
|
padding: 7px 10px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: background 0.1s, color 0.1s;
|
|
}
|
|
.option:hover, .option.highlighted {
|
|
background: var(--accent-bg);
|
|
color: var(--accent);
|
|
}
|
|
</style>
|