Add device autocomplete to todo forms: type name or paste ID
Deploy to LXC / deploy (push) Successful in 21s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 16:11:06 +07:00
parent 8ab4673059
commit da27ae5541
2 changed files with 149 additions and 17 deletions
@@ -0,0 +1,138 @@
<script lang="ts">
interface Device {
id: string;
title: string;
}
interface Props {
id: string;
name: string;
value: string;
devices: Device[];
placeholder?: string;
}
let {
id,
name,
value = $bindable(''),
devices,
placeholder = 'Type device name or paste ID…'
}: Props = $props();
const byId = $derived(new Map(devices.map((d) => [d.id, d])));
const initialTitle = $derived(value ? (byId.get(value)?.title ?? value) : '');
let query = $state(initialTitle);
let showDropdown = $state(false);
let activeIndex = $state(-1);
$effect(() => {
query = initialTitle;
});
const suggestions = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return devices.slice(0, 20);
return devices
.filter((d) => d.title.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))
.slice(0, 20);
});
function select(d: Device) {
value = d.id;
query = d.title;
showDropdown = false;
activeIndex = -1;
}
function handleInput(e: Event) {
query = (e.target as HTMLInputElement).value;
showDropdown = true;
activeIndex = -1;
const trimmed = query.trim();
if (!trimmed) {
value = '';
return;
}
const exact = devices.find(
(d) => d.id === trimmed || d.title.toLowerCase() === trimmed.toLowerCase()
);
if (exact) value = exact.id;
else value = '';
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, suggestions.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, -1);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
select(suggestions[activeIndex]);
} else if (e.key === 'Escape') {
showDropdown = false;
}
}
function handleBlur() {
setTimeout(() => {
showDropdown = false;
// If text doesn't match a real device, keep text but clear hidden id
const trimmed = query.trim();
if (!trimmed) {
value = '';
return;
}
const exact = devices.find(
(d) => d.id === trimmed || d.title.toLowerCase() === trimmed.toLowerCase()
);
if (exact) {
value = exact.id;
query = exact.title;
}
}, 150);
}
</script>
<div class="relative">
<input
type="text"
{id}
value={query}
{placeholder}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => (showDropdown = true)}
onblur={handleBlur}
autocomplete="off"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
/>
<input type="hidden" {name} {value} />
{#if showDropdown && suggestions.length > 0}
<ul
class="absolute z-10 mt-1 max-h-56 w-full overflow-y-auto rounded-md border border-gray-200 bg-white shadow-md dark:border-gray-600 dark:bg-gray-700"
>
{#each suggestions as d, i}
<li>
<button
type="button"
class="flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-600 {i ===
activeIndex
? 'bg-gray-100 dark:bg-gray-600'
: ''}"
onmousedown={() => select(d)}
>
<span class="text-gray-700 dark:text-gray-200">{d.title}</span>
<span class="font-mono text-xs text-gray-400 dark:text-gray-500">{d.id}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
+11 -17
View File
@@ -4,11 +4,14 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { TODO_STATUSES, TODO_STATUS_LABELS, TODO_PRIORITIES } from '$lib/constants.js'; import { TODO_STATUSES, TODO_STATUS_LABELS, TODO_PRIORITIES } from '$lib/constants.js';
import { formatDate } from '$lib/utils/date.js'; import { formatDate } from '$lib/utils/date.js';
import DeviceAutocomplete from '$lib/components/ui/DeviceAutocomplete.svelte';
let { data } = $props(); let { data } = $props();
let showNewForm = $state(false); let showNewForm = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let newDeviceId = $state('');
let editDeviceId = $state('');
const view = $derived(data.view); const view = $derived(data.view);
function setView(v: string) { function setView(v: string) {
@@ -83,7 +86,10 @@
<form method="POST" action="?/create" use:enhance={() => { <form method="POST" action="?/create" use:enhance={() => {
return async ({ update, result }) => { return async ({ update, result }) => {
await update(); await update();
if (result.type === 'success') showNewForm = false; if (result.type === 'success') {
showNewForm = false;
newDeviceId = '';
}
}; };
}} class="space-y-3"> }} class="space-y-3">
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
@@ -128,13 +134,7 @@
</div> </div>
<div> <div>
<label for="deviceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Linked Device</label> <label for="deviceId" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Linked Device</label>
<select id="deviceId" name="deviceId" <DeviceAutocomplete id="deviceId" name="deviceId" bind:value={newDeviceId} devices={data.devices} />
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">None</option>
{#each data.devices as d}
<option value={d.id}>{d.title}</option>
{/each}
</select>
</div> </div>
</div> </div>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create</button> <button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Create</button>
@@ -184,13 +184,7 @@
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-2 sm:grid-cols-2">
<textarea name="description" rows="2" placeholder="Description..." <textarea name="description" rows="2" placeholder="Description..."
class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{todo.description ?? ''}</textarea> class="w-full rounded-md border border-gray-300 px-3 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white">{todo.description ?? ''}</textarea>
<select name="deviceId" <DeviceAutocomplete id="edit-deviceId-{todo.id}" name="deviceId" bind:value={editDeviceId} devices={data.devices} />
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">
<option value="">No linked device</option>
{#each data.devices as d}
<option value={d.id} selected={d.id === todo.deviceId}>{d.title}</option>
{/each}
</select>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button> <button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Save</button>
@@ -256,7 +250,7 @@
</svg> </svg>
</a> </a>
{/if} {/if}
<button type="button" onclick={() => (editingId = todo.id)} <button type="button" onclick={() => { editDeviceId = todo.deviceId ?? ''; editingId = todo.id; }}
class="rounded p-1 text-gray-400 hover:text-blue-600 dark:text-gray-500 dark:hover:text-blue-400" title="Edit"> class="rounded p-1 text-gray-400 hover:text-blue-600 dark:text-gray-500 dark:hover:text-blue-400" title="Edit">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@@ -336,7 +330,7 @@
</form> </form>
{/if} {/if}
<div class="ml-auto flex gap-1"> <div class="ml-auto flex gap-1">
<button type="button" onclick={() => { editingId = todo.id; showNewForm = false; setView('list'); }} <button type="button" onclick={() => { editDeviceId = todo.deviceId ?? ''; editingId = todo.id; showNewForm = false; setView('list'); }}
class="rounded p-0.5 text-gray-400 hover:text-blue-600 dark:text-gray-500" title="Edit"> class="rounded p-0.5 text-gray-400 hover:text-blue-600 dark:text-gray-500" title="Edit">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />