Files
Map-Jurnal/src/lib/shared/SearchInput.svelte
2026-06-16 15:20:07 +09:00

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>