From 03184e7f1ce5dc185f31902905918a881576fa75 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Apr 2026 19:03:48 -0400 Subject: [PATCH] refactor(modal): reserved inline area for add-tag autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the floating autocomplete popup with a reserved ~5-row inline area below the add-tag input. It never covers provenance or suggestion chips — they stay visible side-by-side with the autocomplete list while the user types. The area scrolls when more matches are loaded, and is prefilled with top tags on every image load so it's never empty. The base .tag-autocomplete class (still used by bulk-select and the fandom picker) keeps its floating behavior; the modal opts in to the new inline layout via an added .tag-autocomplete-inline modifier. Co-Authored-By: Claude Opus 4.7 --- app/static/js/view-modal.js | 89 ++++++++++--------------------- app/static/style.css | 19 +++++++ app/templates/_gallery_modal.html | 2 +- 3 files changed, 47 insertions(+), 63 deletions(-) diff --git a/app/static/js/view-modal.js b/app/static/js/view-modal.js index 67dc5e5..b800220 100644 --- a/app/static/js/view-modal.js +++ b/app/static/js/view-modal.js @@ -178,14 +178,6 @@ document.addEventListener('DOMContentLoaded', () => { suggestionsList.appendChild(group); } suggestionsSection.style.display = total > 0 ? '' : 'none'; - - // Suggestions load async after the modal opens, so the autocomplete dropdown - // may have already positioned itself downward (before the section was visible). - // Re-run positioning so it flips above the input now that suggestions occupy - // the space below. - if (tagAutocomplete && tagAutocomplete.classList.contains('active')) { - positionAutocomplete(); - } } async function loadSuggestions(imageId) { @@ -626,44 +618,20 @@ document.addEventListener('DOMContentLoaded', () => { // --------------------------- // Autocomplete helpers // --------------------------- - function hideAutocomplete() { + // The autocomplete area is an always-reserved block below the input + // (see .tag-autocomplete in style.css), so there's nothing to show/hide — + // the area is either populated with matches, the empty-state message, or a + // muted placeholder when there's no active query. + function clearAutocomplete(placeholder = '') { if (tagAutocomplete) { - tagAutocomplete.classList.remove('active'); - tagAutocomplete.innerHTML = ''; + tagAutocomplete.innerHTML = placeholder + ? `
${escapeHtml(placeholder)}
` + : ''; } autocompleteItems = []; autocompleteSelectedIndex = -1; } - function positionAutocomplete() { - if (!tagAutocomplete || !tagInput) return; - const rect = tagInput.getBoundingClientRect(); - const gap = 4; - tagAutocomplete.style.left = `${rect.left}px`; - tagAutocomplete.style.width = `${rect.width}px`; - - // Measure the dropdown height. Requires the element to be visible - // (callers add the `active` class before invoking this function). - const dropdownHeight = tagAutocomplete.offsetHeight; - - // If the Suggestions section sits below the input and the default - // downward dropdown would overlap it, flip the dropdown above the - // input so the user can still see their suggestion chips. - let flipUp = false; - if (suggestionsSection && suggestionsSection.offsetParent !== null) { - const suggestionsRect = suggestionsSection.getBoundingClientRect(); - const wouldOverlap = (rect.bottom + gap + dropdownHeight) > suggestionsRect.top; - const fitsAbove = (rect.top - gap - dropdownHeight) >= 0; - if (wouldOverlap && fitsAbove) flipUp = true; - } - - if (flipUp) { - tagAutocomplete.style.top = `${rect.top - gap - dropdownHeight}px`; - } else { - tagAutocomplete.style.top = `${rect.bottom + gap}px`; - } - } - function renderAutocomplete(tags) { if (!tagAutocomplete) return; autocompleteItems = tags; @@ -671,8 +639,6 @@ document.addEventListener('DOMContentLoaded', () => { if (tags.length === 0) { tagAutocomplete.innerHTML = '
No matching tags
'; - tagAutocomplete.classList.add('active'); - positionAutocomplete(); return; } @@ -684,8 +650,6 @@ document.addEventListener('DOMContentLoaded', () => { ${t.kind || 'user'} `; }).join(''); - tagAutocomplete.classList.add('active'); - positionAutocomplete(); } function selectAutocompleteItem(index) { @@ -702,13 +666,13 @@ document.addEventListener('DOMContentLoaded', () => { // Exclude system-managed tag kinds from autocomplete (archive, post, source) const excludeKinds = 'archive,post,source'; const url = query - ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}` - : `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`; + ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=20&exclude_kind=${excludeKinds}` + : `/api/tags/search?limit=20&exclude_kind=${excludeKinds}`; const r = await fetch(url); const j = await r.json(); renderAutocomplete(j.tags || []); } catch { - hideAutocomplete(); + clearAutocomplete(); } } @@ -717,24 +681,19 @@ document.addEventListener('DOMContentLoaded', () => { const id = getEditorImageId(); const name = (tagInput?.value || '').trim(); if (!id || !name) return; - hideAutocomplete(); await addTag(id, name); if (tagInput) tagInput.value = ''; + // Reset the reserved area to the top-tags view so it's ready for the next add. + fetchAutocomplete(''); } if (tagInput) { - // Show autocomplete on focus + // Refresh top tags on focus so the area reflects the latest input value. tagInput.addEventListener('focus', () => { - const val = tagInput.value.trim(); - fetchAutocomplete(val); + fetchAutocomplete(tagInput.value.trim()); }); - // Hide autocomplete on blur (with delay for click) - tagInput.addEventListener('blur', () => { - setTimeout(hideAutocomplete, 200); - }); - - // Search as user types + // Search as user types. tagInput.addEventListener('input', () => { clearTimeout(autocompleteDebounce); autocompleteDebounce = setTimeout(() => { @@ -742,9 +701,9 @@ document.addEventListener('DOMContentLoaded', () => { }, 150); }); - // Keyboard navigation + // Keyboard navigation — active whenever the reserved area has items. tagInput.addEventListener('keydown', (e) => { - if (!tagAutocomplete?.classList.contains('active')) return; + if (autocompleteItems.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); @@ -762,15 +721,16 @@ document.addEventListener('DOMContentLoaded', () => { submitTag(); } } else if (e.key === 'Escape') { - hideAutocomplete(); + selectAutocompleteItem(-1); } }); } - // Click on autocomplete item - use mousedown to fire before blur + // Click an autocomplete item to select and submit it. Use mousedown so the + // interaction still fires even though the input is losing focus. if (tagAutocomplete) { tagAutocomplete.addEventListener('mousedown', (e) => { - e.preventDefault(); // Prevent blur from firing + e.preventDefault(); e.stopPropagation(); const item = e.target.closest('.tag-autocomplete-item'); if (!item) return; @@ -832,8 +792,11 @@ document.addEventListener('DOMContentLoaded', () => { // NEW: load tags for this image into the modal editor setEditorImageId(imageId); + if (tagInput) tagInput.value = ''; loadTags(imageId); loadSeriesInfo(imageId); + // Prefill the reserved autocomplete area with top tags so it's never empty. + fetchAutocomplete(''); } function openModal(index) { @@ -858,9 +821,11 @@ document.addEventListener('DOMContentLoaded', () => { resetZoom(); // clear tag UI setEditorImageId(''); + if (tagInput) tagInput.value = ''; renderTags([]); renderProvenance([]); clearSuggestions(); + clearAutocomplete(); hideSeriesInfo(); hideAddToSeries(); history.replaceState({}, '', window.location.pathname + window.location.search); diff --git a/app/static/style.css b/app/static/style.css index cb2c7eb..05391fa 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1076,6 +1076,24 @@ header { .tag-autocomplete.active { display: block; } +/* Inline variant: the modal uses a reserved area that sits below the add-tag + input instead of floating over provenance/suggestions. Height fits ~5 rows; + overflow scrolls when more matches are loaded. */ +.tag-autocomplete-inline { + display: block; + position: static; + margin-top: 0.5rem; + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + height: 11.5rem; + max-height: none; + min-width: 0; + max-width: none; + overflow-y: auto; + box-shadow: none; + z-index: auto; +} .tag-autocomplete-item { padding: 0.5rem 0.75rem; cursor: pointer; @@ -1083,6 +1101,7 @@ header { align-items: center; gap: 0.5rem; font-size: 0.9rem; + line-height: 1.2; color: var(--text); border-bottom: 1px solid rgba(255, 255, 255, 0.05); } diff --git a/app/templates/_gallery_modal.html b/app/templates/_gallery_modal.html index 81bbd66..961bfd3 100644 --- a/app/templates/_gallery_modal.html +++ b/app/templates/_gallery_modal.html @@ -58,10 +58,10 @@
-
+