refactor(modal): reserved inline area for add-tag autocomplete

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 19:03:48 -04:00
parent 38942c211d
commit 03184e7f1c
3 changed files with 47 additions and 63 deletions
+27 -62
View File
@@ -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
? `<div class="tag-autocomplete-empty">${escapeHtml(placeholder)}</div>`
: '';
}
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 = '<div class="tag-autocomplete-empty">No matching tags</div>';
tagAutocomplete.classList.add('active');
positionAutocomplete();
return;
}
@@ -684,8 +650,6 @@ document.addEventListener('DOMContentLoaded', () => {
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).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);
+19
View File
@@ -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);
}
+1 -1
View File
@@ -58,10 +58,10 @@
<form id="modalTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="tagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button>
</form>
<div id="tagAutocomplete" class="tag-autocomplete tag-autocomplete-inline" aria-live="polite"></div>
<span id="tagActionFeedback" class="tag-feedback tag-feedback-inline"></span>
</div>