feat(bookshelf): replace broken datalist tag filter with custom autocomplete
The HTML <datalist> approach for tag autocomplete was unreliable across browsers — showed empty suggestions or no dropdown at all. Replace with a custom Alpine.js dropdown: - New tag-dropdown.ts shared module: searchTagSuggestions() queries /api/media-items/search?tags=...&library_id=... and returns results - Bookshelf: absolute-positioned dropdown below tags_filter input, shows tag name + book count per suggestion - Keyboard navigation: ArrowUp/Down to highlight, Enter to select, Escape to close - Click suggestion to populate the filter input
This commit is contained in:
@@ -93,7 +93,7 @@ templ BookShelf(
|
|||||||
<datalist id="author-datalist"></datalist>
|
<datalist id="author-datalist"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<!-- Tags Filter with Autocomplete -->
|
<!-- Tags Filter with Autocomplete -->
|
||||||
<div class="flex-1 min-w-[150px]">
|
<div class="flex-1 min-w-[150px] relative">
|
||||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Tags</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -101,10 +101,29 @@ templ BookShelf(
|
|||||||
placeholder="Filter by tags"
|
placeholder="Filter by tags"
|
||||||
class="w-full px-3 py-2 border rounded-lg"
|
class="w-full px-3 py-2 border rounded-lg"
|
||||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||||
list="tags-datalist"
|
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
||||||
|
@keydown="onTagFilterKeydown($event)"
|
||||||
|
@blur="hideTagDropdown()"
|
||||||
/>
|
/>
|
||||||
<datalist id="tags-datalist"></datalist>
|
<div
|
||||||
|
x-show="showTagDropdown"
|
||||||
|
x-transition
|
||||||
|
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg border max-h-48 overflow-y-auto"
|
||||||
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
||||||
|
>
|
||||||
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
||||||
|
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
||||||
|
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
||||||
|
@click="selectTagSuggestion(sug.value)"
|
||||||
|
>
|
||||||
|
<span x-text="sug.value"></span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
// <!-- Genre Filter with Autocomplete -->
|
// <!-- Genre Filter with Autocomplete -->
|
||||||
// <div class="flex-1 min-w-[150px]">
|
// <div class="flex-1 min-w-[150px]">
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+50
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||||
|
|
||||||
function clearFilters(): void {
|
function clearFilters(): void {
|
||||||
// Clear all form state (reuses helper)
|
// Clear all form state (reuses helper)
|
||||||
@@ -82,6 +83,9 @@ Alpine.data("bookshelf", () => ({
|
|||||||
hasCoverState: null as boolean | null,
|
hasCoverState: null as boolean | null,
|
||||||
dropdownAlign: "right" as "left" | "right",
|
dropdownAlign: "right" as "left" | "right",
|
||||||
resizeTimeout: null as number | null,
|
resizeTimeout: null as number | null,
|
||||||
|
tagSuggestions: [] as TagSuggestion[],
|
||||||
|
showTagDropdown: false,
|
||||||
|
tagHighlightIndex: -1,
|
||||||
|
|
||||||
// Standalone function references (don't access component state)
|
// Standalone function references (don't access component state)
|
||||||
clearFilters,
|
clearFilters,
|
||||||
@@ -382,9 +386,53 @@ Alpine.data("bookshelf", () => ({
|
|||||||
await this.fetchFieldValues("author", input.value, "author-datalist");
|
await this.fetchFieldValues("author", input.value, "author-datalist");
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fetch tag values for autocomplete
|
// Fetch tag values for autocomplete (custom dropdown)
|
||||||
async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
||||||
await this.fetchFieldValues("tags", input.value, "tags-datalist");
|
const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value;
|
||||||
|
if (!libraryId || input.value.length < 2) {
|
||||||
|
this.tagSuggestions = [];
|
||||||
|
this.showTagDropdown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results = await searchTagSuggestions(input.value, libraryId);
|
||||||
|
this.tagSuggestions = results;
|
||||||
|
this.showTagDropdown = results.length > 0;
|
||||||
|
this.tagHighlightIndex = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectTagSuggestion(tag: string) {
|
||||||
|
const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement;
|
||||||
|
if (input) input.value = tag;
|
||||||
|
this.showTagDropdown = false;
|
||||||
|
this.tagSuggestions = [];
|
||||||
|
this.tagHighlightIndex = -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
hideTagDropdown() {
|
||||||
|
setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
onTagFilterKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||||
|
this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length;
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
||||||
|
this.tagHighlightIndex = this.tagHighlightIndex <= 0
|
||||||
|
? this.tagSuggestions.length - 1
|
||||||
|
: this.tagHighlightIndex - 1;
|
||||||
|
}
|
||||||
|
} else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
this.showTagDropdown = false;
|
||||||
|
this.tagSuggestions = [];
|
||||||
|
this.tagHighlightIndex = -1;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// // Fetch genre values for autocomplete
|
// // Fetch genre values for autocomplete
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface TagSuggestion {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTagSuggestions(
|
||||||
|
query: string,
|
||||||
|
libraryId: string,
|
||||||
|
): Promise<TagSuggestion[]> {
|
||||||
|
if (!query || query.length < 2 || !libraryId) return [];
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/media-items/search?tags=${encodeURIComponent(query)}&library_id=${libraryId}&limit=20`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return (data.results || []) as TagSuggestion[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user