Peter Sanchez: 1 Fixes issues with autocomplete wonkyness. You can now use arrow keys to navigate dropdown and also use Enter or Tab to select a drop down item once it's been navigated to. 4 files changed, 353 insertions(+), 49 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/156/mbox | git am -3Learn more about email & git
Fixes: https://todo.code.netlandish.com/~netlandish/links/85 Changelog-fixed: tag autocomplete issues with selection and navigation using the keyboard. --- static/css/style.css | 7 ++ static/js/advancedsearch.js | 223 +++++++++++++++++++++++++++++++----- static/js/autocomplete.js | 170 +++++++++++++++++++++++---- templates/link_list.html | 2 +- 4 files changed, 353 insertions(+), 49 deletions(-) diff --git a/static/css/style.css b/static/css/style.css index 36ba467..bd7d408 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -510,6 +510,13 @@ a.bullet-link:before { cursor: pointer; } +.tag-suggested.highlighted, .suggested.highlighted { + background-color: var(--color-lightGrey); + border-radius: 2px; + padding: 2px 4px; + margin: 3px 0; +} + .tour-section { margin-bottom: 25px; } diff --git a/static/js/advancedsearch.js b/static/js/advancedsearch.js index 45df0cf..4b228a4 100644 --- a/static/js/advancedsearch.js +++ b/static/js/advancedsearch.js @@ -5,6 +5,12 @@ var form = document.getElementById("advanced-search-form"); var btn = document.getElementById("advanced-search-btn") var tagInputDiv = document.getElementById("advanced-search-div") +// Track highlighted index for each autocomplete dropdown +var highlightedIndexes = new WeakMap(); + +// Track if we should allow form submission +var allowFormSubmit = true; + function slugify(s) { if (s === ", ") { return "" @@ -15,7 +21,77 @@ function slugify(s) { return s.replace(/^-+|-+$/g, ""); } -function autocomplete() { +// Function to highlight a suggestion in a specific dropdown +function highlightSuggestion(autocompleteTags, index) { + var items = autocompleteTags.querySelectorAll('.tag-suggested'); + items.forEach(function(item, i) { + if (i === index) { + item.classList.add('highlighted'); + } else { + item.classList.remove('highlighted'); + } + }); + highlightedIndexes.set(autocompleteTags, index); +} + +// Function to select the highlighted suggestion +function selectHighlightedSuggestion(tagSelector, autocompleteTags) { + var highlightedIndex = highlightedIndexes.get(autocompleteTags); + if (highlightedIndex === undefined) { + highlightedIndex = -1; + } + var items = autocompleteTags.querySelectorAll('.tag-suggested'); + if (highlightedIndex >= 0 && highlightedIndex < items.length) { + var selectedTag = items[highlightedIndex].textContent; + var tags = tagSelector.value.split(","); + var newTag = selectedTag + ", "; + if (tags.length > 1) { + newTag = " " + newTag; + } + tags[tags.length - 1] = newTag; + tagSelector.value = tags.join(","); + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); + tagSelector.focus(); + } +} + +// Function to show suggestions +function showSuggestions(autocompleteTags, data) { + if (data.length > 0) { + var currentHighlight = highlightedIndexes.get(autocompleteTags); + if (currentHighlight === undefined) { + currentHighlight = -1; + highlightedIndexes.set(autocompleteTags, -1); // Initialize it + } + var t = ""; + for (var i = 0; i < data.length; i++) { + t += '<p class="tag-suggested" data-index="' + i + '">' + data[i].Name + '</p>'; + } + autocompleteTags.innerHTML = t; + autocompleteTags.classList.remove("d-none"); + + // Preserve highlight if still valid + if (currentHighlight >= data.length) { + highlightedIndexes.set(autocompleteTags, -1); + } else if (currentHighlight >= 0) { + // Re-highlight the same index + highlightSuggestion(autocompleteTags, currentHighlight); + } + } else { + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); + } +} + +function autocomplete(event) { + var tagSelector = this; + + // Skip if it's a navigation key + if (event && ["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(event.key)) { + return; + } + tags = this.value.split(",") tag = tags[tags.length -1].trim() @@ -24,6 +100,9 @@ function autocomplete() { autocompleteTags = this.nextElementSibling; } if (tag !== "") { + // Store the query we're searching for + var searchQuery = tag; + fetch(url + "&q=" + tag) .then(function (response) { if (!response.ok) { @@ -32,44 +111,137 @@ function autocomplete() { return response.json(); }) .then(function (data) { - if (data.length > 0) { - var t = ""; - for (var i=0; i < data.length; i++) { - t += '<p class="tag-suggested">' + data[i].Name + '</p>'; - } - if (autocompleteTags !== undefined) { - autocompleteTags.innerHTML = t; - autocompleteTags.classList.remove("d-none"); - } + // Check if the current input still matches what we searched for + var currentTags = tagSelector.value.split(","); + var currentTag = currentTags[currentTags.length - 1].trim(); + + if (autocompleteTags !== undefined && currentTag === searchQuery) { + showSuggestions(autocompleteTags, data); + + // Set up click handler for this dropdown + autocompleteTags.onclick = function(event) { + if (event.target.classList.contains("tag-suggested")) { + var tags = tagSelector.value.split(","); + var newTag = event.target.textContent + ", "; + if (tags.length > 1) { + newTag = " " + newTag; + } + tags[tags.length - 1] = newTag; + tagSelector.value = tags.join(","); + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); + tagSelector.focus(); + } + }; + + // Set up mouseover handler for this dropdown + autocompleteTags.onmouseover = function(event) { + if (event.target.classList.contains("tag-suggested")) { + var index = parseInt(event.target.getAttribute("data-index")); + highlightSuggestion(autocompleteTags, index); + } + }; + } else if (autocompleteTags !== undefined) { + // Input has changed, hide the dropdown + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); } }) .catch(function (error) { console.log("Fetch error:", error); }); } else { - autocompleteTags.classList.add("d-none"); - } - - tagSelector = this; - autocompleteTags.addEventListener("click", function(event) { - if (event.target.classList.contains("tag-suggested")) { - newTag = event.target.textContent + ", "; - if (tags.length > 1) { - newTag = " " + newTag; - } - tags[tags.length - 1] = newTag; - tagSelector.value = tags.join(","); + if (autocompleteTags !== undefined) { autocompleteTags.classList.add("d-none"); - tagSelector.focus(); + highlightedIndexes.set(autocompleteTags, -1); } - }); + } } + tagSelectors.forEach(function(tagSelector) { tagSelector.addEventListener('keyup', autocomplete); + + // Handle keyboard navigation - inline like autocomplete.js + tagSelector.addEventListener('keydown', function(event) { + var autocompleteTags = this.nextElementSibling; + + if (!autocompleteTags || !autocompleteTags.classList.contains("autocomplete-tags")) { + return; + } + + var items = autocompleteTags.querySelectorAll('.tag-suggested'); + var isVisible = !autocompleteTags.classList.contains("d-none"); + var highlightedIndex = highlightedIndexes.get(autocompleteTags); + if (highlightedIndex === undefined) { + highlightedIndex = -1; + } + + if (!isVisible || items.length === 0) { + return; + } + + switch(event.key) { + case "ArrowDown": + event.preventDefault(); + if (highlightedIndex < items.length - 1) { + highlightSuggestion(autocompleteTags, highlightedIndex + 1); + } else { + highlightSuggestion(autocompleteTags, 0); // Wrap to first + } + break; + + case "ArrowUp": + event.preventDefault(); + if (highlightedIndex > 0) { + highlightSuggestion(autocompleteTags, highlightedIndex - 1); + } else { + highlightSuggestion(autocompleteTags, items.length - 1); // Wrap to last + } + break; + + case "Enter": + if (isVisible) { // Prevent form submit if dropdown is visible + event.preventDefault(); + event.stopPropagation(); + allowFormSubmit = false; + if (highlightedIndex >= 0) { + selectHighlightedSuggestion(this, autocompleteTags); + } + // Reset the flag after a short delay + setTimeout(function() { + allowFormSubmit = true; + }, 100); + return false; + } + break; + + case "Tab": + if (highlightedIndex >= 0) { + event.preventDefault(); + event.stopPropagation(); + selectHighlightedSuggestion(this, autocompleteTags); + return false; + } + break; + + case "Escape": + event.preventDefault(); + autocompleteTags.classList.add("d-none"); + highlightedIndexes.set(autocompleteTags, -1); + break; + } + }); }); +// Prevent form submission when dropdown is active form.addEventListener("submit", function(e) { + if (!allowFormSubmit) { + e.preventDefault(); + e.stopPropagation(); + allowFormSubmit = true; // Reset for next time + return false; + } e.preventDefault(); var tagValue = form.elements.tag.value; var excludeValue = form.elements.exclude.value; @@ -127,5 +299,4 @@ form.addEventListener("submit", function(e) { btn.addEventListener("click", function(e) { e.preventDefault(); tagInputDiv.classList.toggle("d-none"); -}) - +}) \ No newline at end of file diff --git a/static/js/autocomplete.js b/static/js/autocomplete.js index c94576c..5593795 100644 --- a/static/js/autocomplete.js +++ b/static/js/autocomplete.js @@ -1,12 +1,123 @@ var url = document.querySelector('body').getAttribute('data-autocomplete'); var tagSelector = document.getElementById("tag-selector"); var autocompleteTags = document.getElementById("autocomplete-tags"); +var highlightedIndex = -1; +var suggestions = []; +// Function to highlight a suggestion +function highlightSuggestion(index) { + var items = autocompleteTags.querySelectorAll('.tag-suggested'); + items.forEach(function(item, i) { + if (i === index) { + item.classList.add('highlighted'); + } else { + item.classList.remove('highlighted'); + } + }); + highlightedIndex = index; +} + +// Function to select the highlighted suggestion +function selectHighlightedSuggestion() { + var items = autocompleteTags.querySelectorAll('.tag-suggested'); + if (highlightedIndex >= 0 && highlightedIndex < items.length) { + var selectedTag = items[highlightedIndex].textContent; + var tags = tagSelector.value.split(","); + var newTag = selectedTag + ", "; + if (tags.length > 1) { + newTag = " " + newTag; + } + tags[tags.length - 1] = newTag; + tagSelector.value = tags.join(","); + autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; + tagSelector.focus(); + } +} + +// Function to show suggestions +function showSuggestions(data) { + if (data.length > 0) { + suggestions = data; + var t = ""; + for (var i = 0; i < data.length; i++) { + t += '<p class="tag-suggested" data-index="' + i + '">' + data[i].Name + '</p>'; + } + autocompleteTags.innerHTML = t; + autocompleteTags.classList.remove("d-none"); + + // Preserve highlight if still valid + if (highlightedIndex >= data.length) { + highlightedIndex = -1; + } else if (highlightedIndex >= 0) { + // Re-highlight the same index + highlightSuggestion(highlightedIndex); + } + } else { + autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; + } +} + +// Handle keyboard navigation +tagSelector.addEventListener("keydown", function(event) { + var items = autocompleteTags.querySelectorAll('.tag-suggested'); + var isVisible = !autocompleteTags.classList.contains("d-none"); + + if (!isVisible || items.length === 0) { + return; + } + + switch(event.key) { + case "ArrowDown": + event.preventDefault(); + if (highlightedIndex < items.length - 1) { + highlightSuggestion(highlightedIndex + 1); + } else { + highlightSuggestion(0); // Wrap to first + } + break; + + case "ArrowUp": + event.preventDefault(); + if (highlightedIndex > 0) { + highlightSuggestion(highlightedIndex - 1); + } else { + highlightSuggestion(items.length - 1); // Wrap to last + } + break; + + case "Enter": + case "Tab": + if (highlightedIndex >= 0) { + event.preventDefault(); + event.stopPropagation(); + selectHighlightedSuggestion(); + } + break; + + case "Escape": + event.preventDefault(); + autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; + break; + } +}); + +// Handle typing for autocomplete tagSelector.addEventListener("keyup", function(event) { - tags = this.value.split(",") - tag = tags[tags.length -1].trim() + // Skip if it's a navigation key + if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(event.key)) { + return; + } + + tags = this.value.split(","); + tag = tags[tags.length - 1].trim(); if (tag !== "") { + // Store the query we're searching for + var searchQuery = tag; + fetch(url + "&q=" + tag) .then(function (response) { if (!response.ok) { @@ -15,13 +126,16 @@ tagSelector.addEventListener("keyup", function(event) { return response.json(); }) .then(function (data) { - if (data.length > 0) { - var t = ""; - for (var i=0; i < data.length; i++) { - t += '<p class="tag-suggested">' + data[i].Name + '</p>'; - } - autocompleteTags.innerHTML = t; - autocompleteTags.classList.remove("d-none"); + // Check if the current input still matches what we searched for + var currentTags = tagSelector.value.split(","); + var currentTag = currentTags[currentTags.length - 1].trim(); + + if (currentTag === searchQuery) { + showSuggestions(data); + } else { + // Input has changed, hide the dropdown + autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; } }) .catch(function (error) { @@ -29,18 +143,30 @@ tagSelector.addEventListener("keyup", function(event) { }); } else { autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; } - - autocompleteTags.addEventListener("click", function(event) { - if (event.target.classList.contains("tag-suggested")) { - newTag = event.target.textContent + ", "; - if (tags.length > 1) { - newTag = " " + newTag; - } - tags[tags.length - 1] = newTag; - tagSelector.value = tags.join(","); - autocompleteTags.classList.add("d-none"); - tagSelector.focus(); - } - }); }); + +// Handle click selection +autocompleteTags.addEventListener("click", function(event) { + if (event.target.classList.contains("tag-suggested")) { + var tags = tagSelector.value.split(","); + var newTag = event.target.textContent + ", "; + if (tags.length > 1) { + newTag = " " + newTag; + } + tags[tags.length - 1] = newTag; + tagSelector.value = tags.join(","); + autocompleteTags.classList.add("d-none"); + highlightedIndex = -1; + tagSelector.focus(); + } +}); + +// Handle mouse hover to update highlight +autocompleteTags.addEventListener("mouseover", function(event) { + if (event.target.classList.contains("tag-suggested")) { + var index = parseInt(event.target.getAttribute("data-index")); + highlightSuggestion(index); + } +}); \ No newline at end of file diff --git a/templates/link_list.html b/templates/link_list.html index 797285a..b67432e 100644 --- a/templates/link_list.html +++ b/templates/link_list.html @@ -19,7 +19,7 @@ </svg> </button> </div> - {{ if not .hideNav }}<a href="#" class="text-right" id="advanced-search-btn">{{.pd.Data.advanced_search}}</a>{{ end }} + {{ if and (not .hideNav) .isOrgLink }}<a href="#" class="text-right" id="advanced-search-btn">{{.pd.Data.advanced_search}}</a>{{ end }} </div> </section> {{if and (not .hideNav) (.advancedSearch)}} -- 2.49.0
Applied. To git@git.code.netlandish.com:~netlandish/links fdea1e2..af4a4e4 master -> master