diff options
Diffstat (limited to 'src/lib/components/FuzzyAutocomplete.svelte')
| -rw-r--r-- | src/lib/components/FuzzyAutocomplete.svelte | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/src/lib/components/FuzzyAutocomplete.svelte b/src/lib/components/FuzzyAutocomplete.svelte new file mode 100644 index 0000000..f5bef94 --- /dev/null +++ b/src/lib/components/FuzzyAutocomplete.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import { matchItems, type FuzzyItem } from './fuzzy-search'; + + type Item = FuzzyItem; + + type Props = { + items: Item[]; + placeholder?: string; + /** The current input text; bind to keep the parent in sync. */ + value?: string; + /** Called when the user selects an option. */ + onselect?: (item: Item) => void; + }; + + let { + items, + placeholder = 'Rechercher…', + value = $bindable(''), + onselect, + }: Props = $props(); + + let open = $state(false); + let active = $state(0); // index into results + let inputRef: HTMLInputElement; + + const results = $derived(matchItems(items, value)); + // Keep the highlighted row within bounds when the list shrinks. + $effect(() => { + if (active >= results.length) active = Math.max(0, results.length - 1); + }); + + function choose(item: Item) { + value = ''; + open = false; + onselect?.(item); + inputRef?.focus(); + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + open = false; + active = 0; + return; + } + if (!open || results.length === 0) { + if (e.key === 'Enter') { + // fallback: commit the raw text as a new/free-form entry + onselect?.({ id: 0, name: value.trim() }); + } + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + active = (active + 1) % results.length; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + active = (active - 1 + results.length) % results.length; + } else if (e.key === 'Enter') { + e.preventDefault(); + const pick = results[active]; + if (pick) choose(pick.obj); + } + } +</script> + +<div class="autocomplete"> + <input + bind:this={inputRef} + class="form-input" + type="text" + bind:value + {placeholder} + autocomplete="off" + role="combobox" + aria-expanded={open} + aria-controls="autocomplete-list" + aria-autocomplete="list" + oninput={() => { + open = true; + }} + onkeydown={onKeydown} + onfocus={() => { + open = true; + }} + onblur={() => { + // hide after a tick so click on an option still registers + setTimeout(() => { + open = false; + }, 150); + }} + /> + {#if open && results.length > 0} + <ul class="dropdown" role="listbox" id="autocomplete-list"> + {#each results as r, i (r.obj.id)} + <li + role="option" + aria-selected={active === i} + class:active={active === i} + onmousedown={(e) => { + e.preventDefault(); // keep focus on input + choose(r.obj); + }} + > + <span class="name">{@html r.highlight('<mark>', '</mark>')}</span> + </li> + {/each} + </ul> + {/if} +</div> + +<style> + .autocomplete { + position: relative; + flex: 1; + min-width: 0; + } + + .autocomplete :global(.form-input) { + width: 100%; + } + + .dropdown { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + list-style: none; + margin: 0; + padding: 0.25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.75rem; + box-shadow: 0 4px 12px var(--shadow-accent); + z-index: 20; + max-height: 16rem; + overflow-y: auto; + } + + .dropdown li { + padding: 0.5rem 0.625rem; + border-radius: 0.5rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + } + + .dropdown li:hover, + .dropdown li.active { + background: var(--surface-hover); + } + + .name :global(mark) { + background: transparent; + color: var(--accent); + font-weight: 700; + } +</style> |
