aboutsummaryrefslogtreecommitdiff
path: root/src/lib/components/FuzzyAutocomplete.svelte
blob: 4ae52dd53fb6abf735d60a2948ace75ffec1c430 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160

<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%;
		background: var(--surface);
	}

	.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>