aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md4
-rw-r--r--mise.toml4
-rw-r--r--package-lock.json7
-rw-r--r--package.json1
-rw-r--r--src/lib/components/FuzzyAutocomplete.svelte159
-rw-r--r--src/lib/components/fuzzy-search.test.ts53
-rw-r--r--src/lib/components/fuzzy-search.ts25
-rw-r--r--src/routes/+page.svelte51
9 files changed, 256 insertions, 49 deletions
diff --git a/.gitignore b/.gitignore
index ef89b6a..705c34f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules
build
.svelte-kit
+.juggler
.env
*.db
*.db-journal
diff --git a/README.md b/README.md
index 0916549..d391479 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/mise.toml b/mise.toml
index deaa202..e3380dd 100644
--- a/mise.toml
+++ b/mise.toml
@@ -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;