site: rework search component

Don't reimplement a bunch of reactive stuff that Alpine.js handles by
default.
This commit is contained in:
Lucas Gabriel Vuotto 2025-04-28 12:46:29 +00:00
parent 15a98e03d1
commit 07987b7c0e
3 changed files with 102 additions and 101 deletions

View file

@ -22,6 +22,7 @@
--dark-theme-tag-kind-a: crimson; --dark-theme-tag-kind-a: crimson;
--dark-theme-tag-kind-c: seagreen; --dark-theme-tag-kind-c: seagreen;
--dark-theme-tag-kind-r: mediumorchid; --dark-theme-tag-kind-r: mediumorchid;
--dark-theme-suggetion-hover-bg: rgba(255, 255, 255, 0.1);
--light-theme-bg: white; --light-theme-bg: white;
--light-theme-text: black; --light-theme-text: black;
@ -30,6 +31,7 @@
--light-theme-tag-kind-a: crimson; --light-theme-tag-kind-a: crimson;
--light-theme-tag-kind-c: seagreen; --light-theme-tag-kind-c: seagreen;
--light-theme-tag-kind-r: mediumorchid; --light-theme-tag-kind-r: mediumorchid;
--light-theme-suggetion-hover-bg: rgba(0, 0, 0, 0.1);
--bg: var(--light-theme-bg); --bg: var(--light-theme-bg);
--text: var(--light-theme-text); --text: var(--light-theme-text);
@ -45,6 +47,8 @@
--border-thin: 0.0625rem solid var(--accent); --border-thin: 0.0625rem solid var(--accent);
--gallery-column-gap: 1.2rem; --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; position: relative;
} }
#tags-suggestions { ul.suggestions {
position: absolute; position: absolute;
background-color: var(--bg); background-color: var(--bg);
@ -181,15 +185,15 @@ form[name=search] input[name=tags] {
list-style: none; list-style: none;
} }
.suggestion-hover { ul.suggestions li {
background-color: #eee;
}
#tags-suggestions li {
padding-left: 0.1875rem; padding-left: 0.1875rem;
padding-right: 0.1875rem; padding-right: 0.1875rem;
} }
.suggestion-hover {
background-color: var(--suggestion-hover-bg);
}
body > header { body > header {
border-bottom: var(--border-thin); border-bottom: var(--border-thin);
font-size: 1.25rem; font-size: 1.25rem;
@ -420,5 +424,6 @@ a.tag-kind-r:active {
--tag-kind-a: var(--dark-theme-tag-kind-a); --tag-kind-a: var(--dark-theme-tag-kind-a);
--tag-kind-c: var(--dark-theme-tag-kind-c); --tag-kind-c: var(--dark-theme-tag-kind-c);
--tag-kind-r: var(--dark-theme-tag-kind-r); --tag-kind-r: var(--dark-theme-tag-kind-r);
--suggestion-hover-bg: var(--dark-theme-suggetion-hover-bg);
} }
} }

View file

@ -1,4 +1,4 @@
function getCurrentWord(s, cursor) { function getCurrentWordFromCursor(s, cursor) {
return s.substring(s.lastIndexOf(' ', cursor - 1) + 1, 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); 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', () => { document.addEventListener('alpine:init', () => {
Alpine.data('tagsSuggestions', () => ({ Alpine.data('tagsSuggestions', (initialSearch = '') => ({
suggestions: [], items: [],
currentSuggestion: null, activeItem: null,
inputCursor: 0, search: initialSearch,
query: '', cursor: 0,
currentWord: '',
open: false,
reset() { get sortedItems() {
this.suggestions = []; return this.items.toSorted((a, b) => b.count - a.count);
this.currentSuggestion = null;
this.inputCursor = 0;
this.query = '';
}, },
async fetchSuggestions(evt) { async fetchSuggestions() {
this.inputCursor = evt.target.selectionStart; const currentWord = getCurrentWordFromCursor(this.search, this.cursor);
this.query = getCurrentWord(evt.target.value, this.inputCursor); if (!currentWord)
if (!this.query) { return;
this.reset(); if (currentWord === this.currentWord) {
/* Show previous items, if any. */
this.open = true;
return; return;
} }
this.currentWord = currentWord;
const params = new URLSearchParams({q: this.query}); const params = new URLSearchParams({q: currentWord});
const response = await fetch(`${endpoints.search}?${params}`); const response = await fetch(`${endpoints.search}?${params}`);
if (!response.ok) { if (!response.ok) {
this.reset(); this.items = [];
return; return;
} }
this.suggestions = await response.json(); this.items = await response.json();
this.open = true;
}, },
completeSuggestion() { fetchSuggestionsOnFocus(evt) {
if (this.currentSuggestion === null) this.fetchSuggestions();
},
fetchSuggestionsOnInput(evt) {
this.cursor = evt.target.selectionStart;
this.fetchSuggestions();
},
autocompleteSuggestion() {
if (this.items.length === 0)
return; return;
const value = this.$refs.tags.value; const pos = Math.max(this.cursor - this.currentWord.length, 0);
const pos = Math.max(this.inputCursor - this.query.length, 0); const tag = this.items[this.activeItem ?? 0].display;
const tag = this.suggestions[this.currentSuggestion].display;
const newValue = mySplice(value, pos, this.query.length, tag + ' ');
this.reset(); this.activeItem = null;
this.search = mySplice(this.search, pos, this.currentWord.length,
this.$refs.tags.value = newValue; tag + ' ');
this.inputCursor = this.cursor =
this.$refs.tags.selectionStart = this.$refs.search.selectionStart =
this.$refs.tags.selectionEnd = this.$refs.search.selectionEnd =
pos + tag.length + 1; pos + tag.length + 1;
this.currentWord = '';
this.open = false;
this.$refs.tags.focus(); this.$refs.search.focus();
}, },
completeClickedSuggestion(evt) { autocompleteItem(evt) {
this.currentSuggestion = suggestionIndex(evt.currentTarget); if (this.currentWord !== '') {
this.completeSuggestion(); this.autocompleteSuggestion();
evt.preventDefault();
}
}, },
setCurrentSuggestion(idx) { getListItemIndexOf(el) {
const len = this.suggestions.length; return [...this.$refs.suggestions.querySelectorAll('li')].indexOf(el);
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");
}, },
setPointerSuggestion(evt) { autocompletePointedItem(evt) {
this.setCurrentSuggestion(suggestionIndex(evt.currentTarget)); this.activeItem = this.getListItemIndexOf(evt.currentTarget);
this.autocompleteSuggestion();
}, },
moveCurrentSuggestion(amount, defaultValue) { setHoveredItem(evt) {
this.setCurrentSuggestion(this.currentSuggestion === null ? this.activeItem = this.getListItemIndexOf(evt.currentTarget);
defaultValue : this.currentSuggestion + amount);
}, },
nextSuggestion(evt) { previousItem(evt) {
this.moveCurrentSuggestion(1, 0); 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) { nextItem(evt) {
this.moveCurrentSuggestion(-1, -1); const len = this.items.length;
let item = this.activeItem === null ? 0 : this.activeItem + 1;
item = item % len;
this.activeItem = item;
}, },
})); }));
}); });

View file

@ -1,32 +1,32 @@
% stash("pooru.search" => 1); % stash("pooru.search" => 1);
<%= form_for "list_media" => <%= form_for "list_media" =>
name => "search", name => "search",
class => "layout-flex-row" => class => "layout-flex-row" =>
begin %> begin %>
<div <div class="layout-flex-item-fullsize position-relative"
class="layout-flex-item-fullsize position-relative" x-data="tagsSuggestions('<%= param "tags" %>')"
x-data="tagsSuggestions" @click.outside="open = false">
@click.outside="reset"> <input name="tags" value="<%= param "tags" %>" placeholder="Search tags..."
<input type="search" name="tags" value="<%= param "tags" %>" x-ref="search"
placeholder="Search tags..." x-model="search"
x-ref="tags" @focus="fetchSuggestionsOnFocus"
@focus="fetchSuggestions" @input.debounce="fetchSuggestionsOnInput"
@input.debounce="fetchSuggestions" @keydown.tab="autocompleteItem"
@keydown.tab.prevent="completeSuggestion" @keydown.enter="autocompleteItem"
@keydown.up.prevent="previousSuggestion" @keydown.up.prevent="previousItem"
@keydown.down.prevent="nextSuggestion" @keydown.down.prevent="nextItem"
@keydown.escape="reset"> @keydown.escape="open = false">
<ul id="tags-suggestions" <ul class="suggestions"
x-cloak x-show="suggestions.length > 0"> x-ref="suggestions"
<template x-for="suggestion in suggestions"> x-cloak x-show="open && items.length > 0">
<template x-for="(item, index) in sortedItems" :key="item.display">
<li <li
@click="completeClickedSuggestion" :class="index === activeItem ? 'suggestion-hover' : ''"
@mouseenter="setPointerSuggestion"> @click="autocompletePointedItem"
<span @mouseenter="setHoveredItem">
x-text="suggestion.display" <span x-text="item.display" :class="item.kind_class"></span>
:class="suggestion.kind_class"> <span class="float-right" x-text="item.count"></span>
</span>
<span class="float-right" x-text="suggestion.count"></span>
</li> </li>
</template> </template>
</ul> </ul>