diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 4 | ||||
| -rw-r--r-- | mise.toml | 4 | ||||
| -rw-r--r-- | package-lock.json | 7 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/lib/components/FuzzyAutocomplete.svelte | 159 | ||||
| -rw-r--r-- | src/lib/components/fuzzy-search.test.ts | 53 | ||||
| -rw-r--r-- | src/lib/components/fuzzy-search.ts | 25 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 51 |
9 files changed, 256 insertions, 49 deletions
@@ -1,6 +1,7 @@ node_modules build .svelte-kit +.juggler .env *.db *.db-journal @@ -61,10 +61,6 @@ worker precaches assets; works offline after first visit. ## TODO -### Bugs / Polish - -- [x] When I type "sel régénérant", i'm interrupted at the l, it seems to auto submit "sel" - ### Features - [ ] Live updates from server @@ -1,3 +1,7 @@ +[env] +DATABASE_URL = "file:./data.db" +AUTH_PASSWORD = "test" + [tasks.deploy] description = "Build image, push to remote, and restart container" run = """ diff --git a/package-lock.json b/package-lock.json index cb66a7d..1cd516b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@libsql/client": "^0.15.0", "drizzle-orm": "^0.44.0", + "fuzzysort": "^4.0.2", "valibot": "^1.4.2" }, "devDependencies": { @@ -5869,6 +5870,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzysort": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-4.0.2.tgz", + "integrity": "sha512-3c7yD6rK1EXIsqpcu1ZVzXJugODWNMzvUij6yTiziaAHrpNS8kzFOI9mJLE8pmG4K9JOaVgzQRgklfOEk8f4Tg==", + "license": "MIT" + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", diff --git a/package.json b/package.json index b46917a..6b95ea5 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "dependencies": { "@libsql/client": "^0.15.0", "drizzle-orm": "^0.44.0", + "fuzzysort": "^4.0.2", "valibot": "^1.4.2" } } 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> diff --git a/src/lib/components/fuzzy-search.test.ts b/src/lib/components/fuzzy-search.test.ts new file mode 100644 index 0000000..57d0d1d --- /dev/null +++ b/src/lib/components/fuzzy-search.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { matchItems, FUZZ_LIMIT } from './fuzzy-search'; +import type { FuzzyItem } from './fuzzy-search'; + +const catalog: FuzzyItem[] = [ + { id: 1, name: 'Citron' }, + { id: 2, name: 'Citrons' }, + { id: 3, name: 'Concombre' }, + { id: 4, name: 'Tomate' }, + { id: 5, name: 'Carotte' }, + { id: 6, name: 'Lait' }, +]; + +describe('matchItems', () => { + it('returns nothing for an empty or whitespace query', () => { + expect(matchItems(catalog, '')).toHaveLength(0); + expect(matchItems(catalog, ' ')).toHaveLength(0); + }); + + it('matches a fuzzy query, ranking the best match first', () => { + const res = matchItems(catalog, 'citrn'); + expect(res.length).toBeGreaterThanOrEqual(1); + // exact-ish run should beat the pluralised variant + expect(res[0].obj.name).toBe('Citron'); + }); + + it('is case-insensitive', () => { + const res = matchItems(catalog, 'TOMA'); + expect(res[0].obj.name).toBe('Tomate'); + }); + + it('keeps matched-across-junk subsequence results', () => { + const res = matchItems(catalog, 'cb'); // C-o-n-**c**ombre → c arguments + const names = res.map((r) => r.obj.name); + expect(names).toContain('Concombre'); + }); + + it('respects the limit', () => { + const many = Array.from({ length: 40 }, (_, i) => ({ + id: i, + name: `Article ${i}`, + })); + const res = matchItems(many, 'article'); + expect(res.length).toBeLessThanOrEqual(FUZZ_LIMIT); + }); + + it('sorts best matches first by descending score', () => { + const res = matchItems(catalog, 'tomate'); + for (let i = 1; i < res.length; i++) { + expect(res[i - 1].score).toBeGreaterThanOrEqual(res[i].score); + } + }); +}); diff --git a/src/lib/components/fuzzy-search.ts b/src/lib/components/fuzzy-search.ts new file mode 100644 index 0000000..31597b0 --- /dev/null +++ b/src/lib/components/fuzzy-search.ts @@ -0,0 +1,25 @@ +import fuzzysort, { type KeyResult } from 'fuzzysort'; + +export type FuzzyItem = { id: number; name: string }; + +export const FUZZ_LIMIT = 8; +export const FUZZ_THRESHOLD = 0.2; + +export type FuzzyMatch = KeyResult<FuzzyItem>; + +/** Fuzzy-match `items` against `query`, ranked best-first. Empty/whitespace + * query returns no matches. Results carry `.score`, `.target`, `.highlight()` + * and `.obj` (the matched item). */ +export function matchItems( + items: FuzzyItem[], + query: string, + { + limit = FUZZ_LIMIT, + threshold = FUZZ_THRESHOLD, + }: { limit?: number; threshold?: number } = {}, +): FuzzyMatch[] { + const q = query.trim(); + if (q === '') return []; + const snap = fuzzysort.snapshot(items, { key: 'name' }); + return Array.from(fuzzysort.go(q, snap, { limit, threshold })); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 9900bb3..66380a4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,10 +2,10 @@ import { deserialize } from '$app/forms'; import { goto } from '$app/navigation'; import { enhance } from '$app/forms'; + import FuzzyAutocomplete from '$lib/components/FuzzyAutocomplete.svelte'; let { data, form } = $props(); let name = $state(''); - let inputRef: HTMLInputElement; // eslint-disable-next-line svelte/prefer-writable-derived let localItems = $state([]); let addError = $state(''); @@ -41,7 +41,6 @@ const action = redirectToEdit ? '?/addAndEdit' : '?/add'; name = ''; - inputRef.focus(); try { const response = await fetch(action, { @@ -79,48 +78,15 @@ </script> <div class="add-bar"> - <input - bind:this={inputRef} - class="form-input" - type="text" + <FuzzyAutocomplete + items={data.catalog} bind:value={name} placeholder="Ajouter un article..." - autocomplete="off" - list="item-suggestions" - onkeydown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleAdd(false); - } - }} - oninput={(e) => { - if ((e as InputEvent).inputType === 'insertReplacementText') { - const val = e.currentTarget.value.trim().toLowerCase(); - const match = data.catalog.find( - (item) => item.name.toLowerCase() === val, - ); - if (match) { - name = match.name; - handleAdd(false); - } - } - }} - onchange={(e) => { - const val = e.currentTarget.value.trim().toLowerCase(); - const match = data.catalog.find( - (item) => item.name.toLowerCase() === val, - ); - if (match) { - name = match.name; - handleAdd(false); - } + onselect={(item) => { + name = item.name; + handleAdd(false); }} /> - <datalist id="item-suggestions"> - {#each data.catalog as item (item.id)} - <option value={item.name}></option> - {/each} - </datalist> <button type="button" aria-label="Ajouter" onclick={() => handleAdd(false)} >+</button > @@ -187,11 +153,6 @@ z-index: 10; } - .add-bar input { - flex: 1; - background: var(--surface); - } - .add-bar button { width: 3rem; height: 3rem; |
