aboutsummaryrefslogtreecommitdiff
path: root/src/lib/components/FuzzyAutocomplete.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/components/FuzzyAutocomplete.svelte')
-rw-r--r--src/lib/components/FuzzyAutocomplete.svelte159
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>