Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166]) by code.netlandish.com (Postfix) with ESMTP id 839FBAF for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 17 Jul 2025 19:55:17 +0000 (UTC) Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.219.180; helo=mail-yb1-f180.google.com; envelope-from=peter@netlandish.com; receiver= Authentication-Results: mail.netlandish.com; dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=bXSdY53N Received: from mail-yb1-f180.google.com (mail-yb1-f180.google.com [209.85.219.180]) by mail.netlandish.com (Postfix) with ESMTP id 456D11D6434 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 17 Jul 2025 19:55:59 +0000 (UTC) Received: by mail-yb1-f180.google.com with SMTP id 3f1490d57ef6-e740a09eb00so1239235276.0 for <~netlandish/links-dev@lists.code.netlandish.com>; Thu, 17 Jul 2025 12:55:59 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=netlandish.com; s=google; t=1752782158; x=1753386958; darn=lists.code.netlandish.com; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:from:to:cc:subject:date:message-id:reply-to; bh=/qdnaQdYx6gAbeWUtRd6N83MH7IhYwS94MEYoaTjIzA=; b=bXSdY53NQ+h8D1MCV0JP7z++9aYMdnw2XGObq5eIzelc06h3SQ2KzIbGMWdMR79dWn SS3eknRH4dWHMb+e1WoXWK4GUglC9gb4k7n/+RVi6PEfsDyyPJ5JiYILY7dyCxTBSY5Y THZAsOSXfoR4d9sSMtwOy/hBqfvfk7HnE1wjE= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20230601; t=1752782158; x=1753386958; h=content-transfer-encoding:mime-version:message-id:date:subject:cc :to:from:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=/qdnaQdYx6gAbeWUtRd6N83MH7IhYwS94MEYoaTjIzA=; b=dMVemXwjM6p/cKdV8A7c3R2c6KuNRUQRaqPq4zjgtr9ln23GCZxpHd82uqV64KMqJd NygDcQnaSrzC58ksLmfNu2YLFwqBXeiNWM4JGFGPE8bqmtJvFtmQnioN95x5sk9jz32l 5SAuDMLuq1dlyirKDpou0PB+PK5+rlYRRKysQc86vkzp8UGXwJ3pMyMHSQNjPd6Njxyt bcls6tgXnrSIJmnrHDMJhq+KzGcdk9XZ6vC1VwZ82MhXZezUTTDnZF9t2xAXl0wINtqt unSWlge+kAAlTTU39+mWHbG4TV1VoW9lRS1Rz3lovh/HLUeqOJKg3MlEsUjGKfJGkiJX IcCg== X-Gm-Message-State: AOJu0YwpxaqhizVSCvFGGexHzm/XeMBEVwSKfDR/qIAPF0LSmdAKC9xc 2STjYwTSNn+avNUYJdbXz3yN8m6miIB/2rY9SVN3eVktv6Wwd9ymFX6it86aPEHRIOJKmwyWBJp SqJ6GigI= X-Gm-Gg: ASbGncvmKuHF+HNPLj2SF8gbBpzPfW560xVLCxYCrFqgttS+Qbz/0zSdslF5rmkWhnk ZVHdyo+2NP/hSkBurutXqtvAuYoE/Cf+zheMkPgLx5JiY2sXlXiWpzk7OaSXpQaxWhZT0wGjO1w oPZojam5Nxw5OU7Vn1cIf3/8P86BfajhOWma913dF85aDTkAvxBwrcoG2rxetk/7782ZdpPOrew fnjxXXzZYtWz+88n2VUC4s8HdsCRHAS8HUZbGMZCWYKM9IJAl2rqOw7YmdBPa3Z+gVvoKTWaIR5 O8DEQWauQOjP0xPpey27F7lnB1sJBBZ6Cf7nWKgqigjppDAHddG3URvj0Ph0h0GK5cfnFtyJPeZ dLX1aDgEb6m0YfBo8ltoUuDtLv9erVAf+0w== X-Google-Smtp-Source: AGHT+IFqr4Xq9wqxBMrlromf/jN7nQZftOoOKXvFA0uJDJYWn5kaMXZy/tuVgatUCA76GXOPfKQyDA== X-Received: by 2002:a05:6902:138f:b0:e8b:60b6:bd2d with SMTP id 3f1490d57ef6-e8bc26bda5cmr10195893276.12.1752782158162; Thu, 17 Jul 2025 12:55:58 -0700 (PDT) Received: from localhost ([2803:2d60:1107:87f:bece:ca90:cb5f:8535]) by smtp.gmail.com with UTF8SMTPSA id 3f1490d57ef6-e8bc1b83113sm1553055276.33.2025.07.17.12.55.57 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Thu, 17 Jul 2025 12:55:57 -0700 (PDT) From: Peter Sanchez To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez 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. Date: Thu, 17 Jul 2025 13:55:53 -0600 Message-ID: <20250717195555.28181-1-peter@netlandish.com> X-Mailer: git-send-email 2.49.0 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit 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 += '

' + data[i].Name + '

'; + } + 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 += '

' + data[i].Name + '

'; - } - 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 += '

' + data[i].Name + '

'; + } + 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 += '

' + data[i].Name + '

'; - } - 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 @@ - {{ if not .hideNav }}{{.pd.Data.advanced_search}}{{ end }} + {{ if and (not .hideNav) .isOrgLink }}{{.pd.Data.advanced_search}}{{ end }} {{if and (not .hideNav) (.advancedSearch)}} -- 2.49.0