Subject: [PATCH links] 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.

From: Peter Sanchez <[email-masked]>
Date: Thu, 17 Jul 2025 13:55:53 -0600

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

---

From: "Peter Sanchez" <[email-masked]>
Date: Thu, 17 Jul 2025 14:01:02 -0600

Applied.

To [email-masked]:~netlandish/links
   fdea1e2..af4a4e4  master -> master