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:
+27
-62
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user