all repos — weimar @ ab494a4c3e2c99426dd9f18f02bcb3fb7ee678a3

Unnamed repository; edit this file 'description' to name the repository.

feat: keyboard navigation for tag suggestion dropdowns

Tab/ArrowDown cycles through suggestions, Enter selects the active one,
ArrowUp goes backwards, Escape dismisses. Works on browse filter inputs,
upload page, image detail page, and image viewer tag editor.
Maxwell Jensen maxwelljensen@posteo.net
Thu, 14 May 2026 17:20:59 +0200
commit

ab494a4c3e2c99426dd9f18f02bcb3fb7ee678a3

parent

8dcf6e79e30fa40a7f7da17169314b250793210c

M web/src/lib/ImageViewer.svelteweb/src/lib/ImageViewer.svelte

@@ -23,6 +23,7 @@

let tagInput = $state(''); let suggestions = $state<string[]>([]); let showSuggestions = $state(false); + let sugIndex = $state(-1); let tagError = $state(''); let tagBusy = $state(false);

@@ -102,6 +103,7 @@ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';

} async function onTagInput() { + sugIndex = -1; const q = tagInput.split(',').pop()?.trim() || ''; if (q.length < 1) { suggestions = [];

@@ -123,6 +125,7 @@ const parts = tagInput.split(',').slice(0, -1);

parts.push(s); tagInput = parts.join(', ') + ', '; showSuggestions = false; + sugIndex = -1; } async function handleAddTags() {

@@ -161,6 +164,14 @@ $effect(() => {

current.id; loadTagDetails(); }); + + // Auto-scroll active suggestion into view. + $effect(() => { + if (sugIndex >= 0) { + const el = document.querySelector('.viewer-sug-btn.active') as HTMLElement | null; + if (el) el.scrollIntoView({ block: 'nearest' }); + } + }); </script> <svelte:window onkeydown={handleKeydown} on:mousemove={resetIdleTimer} />

@@ -275,13 +286,36 @@ type="text"

placeholder="funny, meme, cat" bind:value={tagInput} oninput={onTagInput} - onblur={() => setTimeout(() => { showSuggestions = false; }, 200)} + onkeydown={(e) => { + if (e.key === 'Enter') { + if (sugIndex >= 0 && sugIndex < suggestions.length) { + e.preventDefault(); + selectSuggestion(suggestions[sugIndex]); + } else { + handleAddTags(); + } + } else if (e.key === 'Tab' || e.key === 'ArrowDown') { + if (showSuggestions && suggestions.length > 0) { + e.preventDefault(); + sugIndex = (sugIndex + 1) % suggestions.length; + } + } else if (e.key === 'ArrowUp') { + if (showSuggestions && suggestions.length > 0) { + e.preventDefault(); + sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1; + } + } else if (e.key === 'Escape') { + showSuggestions = false; + sugIndex = -1; + } + }} + onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)} disabled={tagBusy} /> {#if showSuggestions} <div class="viewer-suggestions"> - {#each suggestions as s} - <button type="button" class="viewer-sug-btn" onmousedown={() => selectSuggestion(s)}>{s}</button> + {#each suggestions as s, i} + <button type="button" class="viewer-sug-btn" class:active={i === sugIndex} onmousedown={() => selectSuggestion(s)}>{s}</button> {/each} </div> {/if}

@@ -636,6 +670,9 @@ cursor: pointer;

border-radius: 0; } .viewer-sug-btn:hover { + background: #c62828; + } + .viewer-sug-btn.active { background: #c62828; } .viewer-tag-btn {
M web/src/routes/browse/+page.svelteweb/src/routes/browse/+page.svelte

@@ -19,6 +19,8 @@ let uploaderSuggestions = $state<Tag[]>([]);

let showTagSugs = $state(false); let showUploaderSugs = $state(false); let sugTimer: ReturnType<typeof setTimeout> | undefined; + let tagSugIndex = $state(-1); + let uploaderSugIndex = $state(-1); onMount(() => { const mainEl = document.querySelector('.main') as HTMLElement;

@@ -90,6 +92,7 @@ }

function onTagInput() { clearTimeout(sugTimer); + tagSugIndex = -1; const q = filterTag.trim(); if (q.length < 1) { tagSuggestions = [];

@@ -109,6 +112,7 @@ }

function onUploaderInput() { clearTimeout(sugTimer); + uploaderSugIndex = -1; const q = filterUploader.trim(); if (q.length < 1) { uploaderSuggestions = [];

@@ -154,6 +158,14 @@ mainEl.style.padding = '0';

mainEl.style.maxWidth = 'none'; } } + + // Auto-scroll the active suggestion into view when keyboard-navigated. + $effect(() => { + if (tagSugIndex >= 0 || uploaderSugIndex >= 0) { + const el = document.querySelector('.sug-opt.active') as HTMLElement | null; + if (el) el.scrollIntoView({ block: 'nearest' }); + } + }); </script> <div class="header">

@@ -169,13 +181,41 @@ type="text"

placeholder="Filter by tag..." bind:value={filterTag} oninput={onTagInput} - onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} - onblur={() => setTimeout(() => { showTagSugs = false; }, 200)} + onkeydown={(e) => { + if (e.key === 'Enter') { + if (tagSugIndex >= 0 && tagSugIndex < tagSuggestions.length) { + selectTagSug(tagSuggestions[tagSugIndex]); + } else { + applyFilter(); + } + } else if (e.key === 'Tab' || e.key === 'ArrowDown') { + if (showTagSugs && tagSuggestions.length > 0) { + e.preventDefault(); + tagSugIndex = (tagSugIndex + 1) % tagSuggestions.length; + } + } else if (e.key === 'ArrowUp') { + if (showTagSugs && tagSuggestions.length > 0) { + e.preventDefault(); + tagSugIndex = tagSugIndex <= 0 ? tagSuggestions.length - 1 : tagSugIndex - 1; + } + } else if (e.key === 'Escape') { + showTagSugs = false; + tagSugIndex = -1; + } + }} + onblur={() => setTimeout(() => { showTagSugs = false; tagSugIndex = -1; }, 200)} /> {#if showTagSugs} <div class="sugs" role="listbox"> - {#each tagSuggestions as s} - <button type="button" role="option" aria-selected={false} class="sug-opt" onmousedown={() => selectTagSug(s)}> + {#each tagSuggestions as s, i} + <button + type="button" + role="option" + aria-selected={i === tagSugIndex} + class="sug-opt" + class:active={i === tagSugIndex} + onmousedown={() => selectTagSug(s)} + > <span class="sug-label">{s.tag}</span> <span class="sug-count">{s.usage_count}</span> </button>

@@ -189,13 +229,41 @@ type="text"

placeholder="Filter by uploader..." bind:value={filterUploader} oninput={onUploaderInput} - onkeydown={(e) => { if (e.key === 'Enter') applyFilter(); }} - onblur={() => setTimeout(() => { showUploaderSugs = false; }, 200)} + onkeydown={(e) => { + if (e.key === 'Enter') { + if (uploaderSugIndex >= 0 && uploaderSugIndex < uploaderSuggestions.length) { + selectUploaderSug(uploaderSuggestions[uploaderSugIndex]); + } else { + applyFilter(); + } + } else if (e.key === 'Tab' || e.key === 'ArrowDown') { + if (showUploaderSugs && uploaderSuggestions.length > 0) { + e.preventDefault(); + uploaderSugIndex = (uploaderSugIndex + 1) % uploaderSuggestions.length; + } + } else if (e.key === 'ArrowUp') { + if (showUploaderSugs && uploaderSuggestions.length > 0) { + e.preventDefault(); + uploaderSugIndex = uploaderSugIndex <= 0 ? uploaderSuggestions.length - 1 : uploaderSugIndex - 1; + } + } else if (e.key === 'Escape') { + showUploaderSugs = false; + uploaderSugIndex = -1; + } + }} + onblur={() => setTimeout(() => { showUploaderSugs = false; uploaderSugIndex = -1; }, 200)} /> {#if showUploaderSugs} <div class="sugs" role="listbox"> - {#each uploaderSuggestions as s} - <button type="button" role="option" aria-selected={false} class="sug-opt" onmousedown={() => selectUploaderSug(s)}> + {#each uploaderSuggestions as s, i} + <button + type="button" + role="option" + aria-selected={i === uploaderSugIndex} + class="sug-opt" + class:active={i === uploaderSugIndex} + onmousedown={() => selectUploaderSug(s)} + > <span class="sug-label">{s.tag}</span> <span class="sug-count">{s.usage_count}</span> </button>

@@ -352,6 +420,13 @@ border-radius: 0;

} .sug-opt:hover { background: #f0f0f0; + } + .sug-opt.active { + background: #c62828; + color: #fff; + } + .sug-opt.active .sug-count { + color: rgba(255, 255, 255, 0.7); } .sug-label { overflow: hidden;
M web/src/routes/image/[id]/+page.svelteweb/src/routes/image/[id]/+page.svelte

@@ -12,6 +12,7 @@

let tagInput = $state(''); let suggestions = $state<string[]>([]); let showSuggestions = $state(false); + let sugIndex = $state(-1); let tagError = $state(''); onMount(async () => {

@@ -35,6 +36,7 @@ return new Date(s).toLocaleString();

} async function onTagInput() { + sugIndex = -1; const q = tagInput.split(',').pop()?.trim() || ''; if (q.length < 1) { suggestions = [];

@@ -56,6 +58,7 @@ const parts = tagInput.split(',').slice(0, -1);

parts.push(s); tagInput = parts.join(', ') + ', '; showSuggestions = false; + sugIndex = -1; } async function handleAddTags() {

@@ -87,6 +90,14 @@ } catch (err) {

tagError = err instanceof Error ? err.message : 'Failed to remove tags'; } } + + // Auto-scroll active suggestion into view. + $effect(() => { + if (sugIndex >= 0) { + const el = document.querySelector('.sug-btn.active') as HTMLElement | null; + if (el) el.scrollIntoView({ block: 'nearest' }); + } + }); </script> {#if loading}

@@ -168,12 +179,33 @@ <div class="tag-input-row">

<div class="tag-wrapper"> <input type="text" placeholder="funny, meme, cat" bind:value={tagInput} oninput={onTagInput} - onblur={() => setTimeout(() => { showSuggestions = false; }, 200)} + onkeydown={(e) => { + if (e.key === 'Enter') { + if (sugIndex >= 0 && sugIndex < suggestions.length) { + e.preventDefault(); + selectSuggestion(suggestions[sugIndex]); + } + } else if (e.key === 'Tab' || e.key === 'ArrowDown') { + if (showSuggestions && suggestions.length > 0) { + e.preventDefault(); + sugIndex = (sugIndex + 1) % suggestions.length; + } + } else if (e.key === 'ArrowUp') { + if (showSuggestions && suggestions.length > 0) { + e.preventDefault(); + sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1; + } + } else if (e.key === 'Escape') { + showSuggestions = false; + sugIndex = -1; + } + }} + onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)} /> {#if showSuggestions} <div class="suggestions"> - {#each suggestions as s} - <button type="button" class="sug-btn" onmousedown={() => selectSuggestion(s)}>{s}</button> + {#each suggestions as s, i} + <button type="button" class="sug-btn" class:active={i === sugIndex} onmousedown={() => selectSuggestion(s)}>{s}</button> {/each} </div> {/if}

@@ -441,6 +473,10 @@ border-radius: 0;

} .sug-btn:hover { background: #f0f0f0; + } + .sug-btn.active { + background: #c62828; + color: #fff; } .tag-btn { padding: 0.4rem 0.65rem;
M web/src/routes/upload/+page.svelteweb/src/routes/upload/+page.svelte

@@ -9,6 +9,7 @@ let success = $state('');

let loading = $state(false); let suggestions = $state<string[]>([]); let showSuggestions = $state(false); + let sugIndex = $state(-1); let dragActive = $state(false); let fileInput = $state<HTMLInputElement | null>(null);

@@ -60,7 +61,16 @@ document.removeEventListener('dragover', onDocumentDragOver);

document.removeEventListener('drop', onDocumentDrop); }); + // Auto-scroll active suggestion into view. + $effect(() => { + if (sugIndex >= 0) { + const el = document.querySelector('.sug-btn.active') as HTMLElement | null; + if (el) el.scrollIntoView({ block: 'nearest' }); + } + }); + async function onTagInput() { + sugIndex = -1; const q = tags.split(',').pop()?.trim() || ''; if (q.length < 1) { suggestions = [];

@@ -82,6 +92,7 @@ const parts = tags.split(',').slice(0, -1);

parts.push(s); tags = parts.join(', ') + ', '; showSuggestions = false; + sugIndex = -1; } function openFilePicker() {

@@ -145,13 +156,35 @@ <span class="label-text">TAGS (COMMA-SEPARATED)</span>

<div class="tag-wrapper"> <input type="text" placeholder="funny, meme, cat" bind:value={tags} oninput={onTagInput} - onblur={() => setTimeout(() => { showSuggestions = false; }, 200)} + onkeydown={(e) => { + if (e.key === 'Enter') { + if (sugIndex >= 0 && sugIndex < suggestions.length) { + e.preventDefault(); + selectSuggestion(suggestions[sugIndex]); + } + } else if (e.key === 'Tab' || e.key === 'ArrowDown') { + if (showSuggestions && suggestions.length > 0) { + e.preventDefault(); + sugIndex = (sugIndex + 1) % suggestions.length; + } + } else if (e.key === 'ArrowUp') { + if (showSuggestions && suggestions.length > 0) { + e.preventDefault(); + sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1; + } + } else if (e.key === 'Escape') { + showSuggestions = false; + sugIndex = -1; + } + }} + onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)} disabled={loading} /> {#if showSuggestions} <div class="suggestions"> - {#each suggestions as s} - <button type="button" class="sug-btn" onmousedown={() => selectSuggestion(s)}>{s}</button> + {#each suggestions as s, i} + <button type="button" class="sug-btn" class:active={i === sugIndex} + onmousedown={() => selectSuggestion(s)}>{s}</button> {/each} </div> {/if}

@@ -289,6 +322,10 @@ border-radius: 0;

} .sug-btn:hover { background: #f0f0f0; + } + .sug-btn.active { + background: #c62828; + color: #fff; } .btn {