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.
@@ -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 {
@@ -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;
@@ -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;
@@ -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 {