site: rework search component
Don't reimplement a bunch of reactive stuff that Alpine.js handles by default.
This commit is contained in:
parent
15a98e03d1
commit
07987b7c0e
3 changed files with 102 additions and 101 deletions
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
140
public/js/app.js
140
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);
|
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;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue