From 07987b7c0e309986e80a9e58f4780a1b7925d3ef Mon Sep 17 00:00:00 2001 From: Lucas Gabriel Vuotto Date: Mon, 28 Apr 2025 12:46:29 +0000 Subject: [PATCH] site: rework search component Don't reimplement a bunch of reactive stuff that Alpine.js handles by default. --- public/css/style.css | 17 +++-- public/js/app.js | 140 ++++++++++++++++++-------------------- templates/_search.html.ep | 46 ++++++------- 3 files changed, 102 insertions(+), 101 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 60bc0c6..cc220e6 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -22,6 +22,7 @@ --dark-theme-tag-kind-a: crimson; --dark-theme-tag-kind-c: seagreen; --dark-theme-tag-kind-r: mediumorchid; + --dark-theme-suggetion-hover-bg: rgba(255, 255, 255, 0.1); --light-theme-bg: white; --light-theme-text: black; @@ -30,6 +31,7 @@ --light-theme-tag-kind-a: crimson; --light-theme-tag-kind-c: seagreen; --light-theme-tag-kind-r: mediumorchid; + --light-theme-suggetion-hover-bg: rgba(0, 0, 0, 0.1); --bg: var(--light-theme-bg); --text: var(--light-theme-text); @@ -45,6 +47,8 @@ --border-thin: 0.0625rem solid var(--accent); --gallery-column-gap: 1.2rem; + + --suggestion-hover-bg: var(--light-theme-suggetion-hover-bg); } *, @@ -169,7 +173,7 @@ form[name=search] input[name=tags] { position: relative; } -#tags-suggestions { +ul.suggestions { position: absolute; background-color: var(--bg); @@ -181,15 +185,15 @@ form[name=search] input[name=tags] { list-style: none; } -.suggestion-hover { - background-color: #eee; -} - -#tags-suggestions li { +ul.suggestions li { padding-left: 0.1875rem; padding-right: 0.1875rem; } +.suggestion-hover { + background-color: var(--suggestion-hover-bg); +} + body > header { border-bottom: var(--border-thin); font-size: 1.25rem; @@ -420,5 +424,6 @@ a.tag-kind-r:active { --tag-kind-a: var(--dark-theme-tag-kind-a); --tag-kind-c: var(--dark-theme-tag-kind-c); --tag-kind-r: var(--dark-theme-tag-kind-r); + --suggestion-hover-bg: var(--dark-theme-suggetion-hover-bg); } } diff --git a/public/js/app.js b/public/js/app.js index 2ee7ca6..0760bef 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,4 +1,4 @@ -function getCurrentWord(s, cursor) { +function getCurrentWordFromCursor(s, cursor) { return s.substring(s.lastIndexOf(' ', cursor - 1) + 1, cursor); } @@ -6,109 +6,105 @@ function mySplice(s, idx, del, n) { return s.substring(0, idx) + n + s.substring(idx + del); } -function suggestionIndex(el) { - // el.parentNode is ul#tags-suggestion, which include a template. That's why - // a -1 is needed here. - const idx = [...el.parentNode.children].indexOf(el); - return idx <= 0 ? null : idx - 1; -} - -function currentSuggestionElement(idx) { - if (idx === null) - return null; - return document.querySelector("#tags-suggestions").children[idx + 1]; -} - document.addEventListener('alpine:init', () => { - Alpine.data('tagsSuggestions', () => ({ - suggestions: [], - currentSuggestion: null, - inputCursor: 0, - query: '', + Alpine.data('tagsSuggestions', (initialSearch = '') => ({ + items: [], + activeItem: null, + search: initialSearch, + cursor: 0, + currentWord: '', + open: false, - reset() { - this.suggestions = []; - this.currentSuggestion = null; - this.inputCursor = 0; - this.query = ''; + get sortedItems() { + return this.items.toSorted((a, b) => b.count - a.count); }, - async fetchSuggestions(evt) { - this.inputCursor = evt.target.selectionStart; - this.query = getCurrentWord(evt.target.value, this.inputCursor); - if (!this.query) { - this.reset(); + async fetchSuggestions() { + const currentWord = getCurrentWordFromCursor(this.search, this.cursor); + if (!currentWord) + return; + if (currentWord === this.currentWord) { + /* Show previous items, if any. */ + this.open = true; return; } + this.currentWord = currentWord; - const params = new URLSearchParams({q: this.query}); + const params = new URLSearchParams({q: currentWord}); const response = await fetch(`${endpoints.search}?${params}`); if (!response.ok) { - this.reset(); + this.items = []; return; } - this.suggestions = await response.json(); + this.items = await response.json(); + this.open = true; }, - completeSuggestion() { - if (this.currentSuggestion === null) + fetchSuggestionsOnFocus(evt) { + this.fetchSuggestions(); + }, + + fetchSuggestionsOnInput(evt) { + this.cursor = evt.target.selectionStart; + this.fetchSuggestions(); + }, + + autocompleteSuggestion() { + if (this.items.length === 0) return; - const value = this.$refs.tags.value; - const pos = Math.max(this.inputCursor - this.query.length, 0); - const tag = this.suggestions[this.currentSuggestion].display; - const newValue = mySplice(value, pos, this.query.length, tag + ' '); + const pos = Math.max(this.cursor - this.currentWord.length, 0); + const tag = this.items[this.activeItem ?? 0].display; - this.reset(); - - this.$refs.tags.value = newValue; - this.inputCursor = - this.$refs.tags.selectionStart = - this.$refs.tags.selectionEnd = + this.activeItem = null; + this.search = mySplice(this.search, pos, this.currentWord.length, + tag + ' '); + this.cursor = + this.$refs.search.selectionStart = + this.$refs.search.selectionEnd = pos + tag.length + 1; + this.currentWord = ''; + this.open = false; - this.$refs.tags.focus(); + this.$refs.search.focus(); }, - completeClickedSuggestion(evt) { - this.currentSuggestion = suggestionIndex(evt.currentTarget); - this.completeSuggestion(); + autocompleteItem(evt) { + if (this.currentWord !== '') { + this.autocompleteSuggestion(); + evt.preventDefault(); + } }, - setCurrentSuggestion(idx) { - const len = this.suggestions.length; - if (len === 0) - return; - - currentSuggestionElement(this.currentSuggestion) - ?.classList.remove("suggestion-hover"); - - idx = idx % len; - if (idx < 0) - idx += len; - this.currentSuggestion = idx; - - currentSuggestionElement(this.currentSuggestion) - ?.classList.add("suggestion-hover"); + getListItemIndexOf(el) { + return [...this.$refs.suggestions.querySelectorAll('li')].indexOf(el); }, - setPointerSuggestion(evt) { - this.setCurrentSuggestion(suggestionIndex(evt.currentTarget)); + autocompletePointedItem(evt) { + this.activeItem = this.getListItemIndexOf(evt.currentTarget); + this.autocompleteSuggestion(); }, - moveCurrentSuggestion(amount, defaultValue) { - this.setCurrentSuggestion(this.currentSuggestion === null ? - defaultValue : this.currentSuggestion + amount); + setHoveredItem(evt) { + this.activeItem = this.getListItemIndexOf(evt.currentTarget); }, - nextSuggestion(evt) { - this.moveCurrentSuggestion(1, 0); + previousItem(evt) { + const len = this.items.length; + let item = this.activeItem === null ? -1 : this.activeItem - 1; + item = item % len; + if (item < 0) + item += len; + this.activeItem = item; }, - previousSuggestion(evt) { - this.moveCurrentSuggestion(-1, -1); + nextItem(evt) { + const len = this.items.length; + let item = this.activeItem === null ? 0 : this.activeItem + 1; + item = item % len; + this.activeItem = item; }, })); }); diff --git a/templates/_search.html.ep b/templates/_search.html.ep index 1957b9d..62d081a 100644 --- a/templates/_search.html.ep +++ b/templates/_search.html.ep @@ -1,32 +1,32 @@ % stash("pooru.search" => 1); + <%= form_for "list_media" => name => "search", class => "layout-flex-row" => begin %> -
- " - placeholder="Search tags..." - x-ref="tags" - @focus="fetchSuggestions" - @input.debounce="fetchSuggestions" - @keydown.tab.prevent="completeSuggestion" - @keydown.up.prevent="previousSuggestion" - @keydown.down.prevent="nextSuggestion" - @keydown.escape="reset"> -