~netlandish/links-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[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.

Details
Message ID
<20250717195555.28181-1-peter@netlandish.com>
Sender timestamp
1752760553
DKIM signature
missing
Download raw message
Patch: +353 -49
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
Details
Message ID
<DBELIYYFOV6F.1S10BNF0YZV3N@netlandish.com>
In-Reply-To
<20250717195555.28181-1-peter@netlandish.com> (view parent)
Sender timestamp
1752760862
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   fdea1e2..af4a4e4  master -> master
Reply to thread Export thread (mbox)