Fix load filter dropdown positioning on bookshelf page

The load filter dropdown was being cut off when the button was positioned
on the left side of the screen due to static right-0 alignment. This became
more problematic as the button position changes with window resize.

Changes:
- Added dynamic dropdown alignment calculation based on button position
  and available viewport space
- Implemented smart positioning logic that checks available space on both
  left and right sides before deciding alignment
- Added window resize listener using requestAnimationFrame to dynamically
  update dropdown position while open
- Added data-load-filter-btn attribute for reliable DOM querying
- Changed from static right-0 to dynamic :class binding for left/right
  alignment

Technical details:
- Alpine.js state: dropdownAlign tracks current alignment (left/right)
- calculateAlignment() method computes button position and available space
- Uses getBoundingClientRect() to measure button position relative to viewport
- Prefers side with >=320px space, otherwise chooses larger side
- requestAnimationFrame ensures smooth updates during resize without
  performance degradation

Fixes issue where dropdown extends beyond viewport edge when button
is near left or right edge of screen.
This commit is contained in:
2026-03-28 20:35:40 -04:00
parent 0c1a55d6e3
commit 9dccdfbde0
3 changed files with 54 additions and 7 deletions
+3 -1
View File
@@ -247,6 +247,7 @@ templ BookShelf(
<div class="flex items-end relative"> <div class="flex items-end relative">
<button <button
@click="toggleFiltersDropdown()" @click="toggleFiltersDropdown()"
data-load-filter-btn
class="px-4 py-2 rounded-lg font-medium border" class="px-4 py-2 rounded-lg font-medium border"
style="border-color: var(--border); color: var(--text-primary);" style="border-color: var(--border); color: var(--text-primary);"
> >
@@ -262,7 +263,8 @@ templ BookShelf(
x-transition:leave="transition ease-in duration-150" x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95" x-transition:leave-end="opacity-0 scale-95"
class="absolute top-full mt-2 right-0 w-80 rounded-lg shadow-lg z-50" class="absolute top-full mt-2 w-80 rounded-lg shadow-lg z-50"
:class="dropdownAlign === 'left' ? 'left-0' : 'right-0'"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;" style="background-color: var(--bg-secondary); border: 1px solid var(--border); display: none;"
> >
<div class="p-4"> <div class="p-4">
File diff suppressed because one or more lines are too long
+46 -1
View File
@@ -80,6 +80,8 @@ Alpine.data("bookshelf", () => ({
showSaveModal: false, showSaveModal: false,
showFiltersDropdown: false, showFiltersDropdown: false,
hasCoverState: null as boolean | null, hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null,
// Standalone function references (don't access component state) // Standalone function references (don't access component state)
clearFilters, clearFilters,
@@ -89,9 +91,52 @@ Alpine.data("bookshelf", () => ({
showSaveFilterModal() { showSaveFilterModal() {
this.showSaveModal = true; this.showSaveModal = true;
}, },
// Toggle the filters dropdown // Helper method to calculate alignment
calculateAlignment() {
const button = document.querySelector("[data-load-filter-btn]");
if (!button) return;
const rect = button.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const dropdownWidth = 320;
const spaceOnLeft = rect.left;
const spaceOnRight = viewportWidth - rect.right;
if (spaceOnRight >= dropdownWidth) {
this.dropdownAlign = "left";
} else if (spaceOnLeft >= dropdownWidth) {
this.dropdownAlign = "right";
} else {
this.dropdownAlign = spaceOnLeft > spaceOnRight ? "right" : "left";
}
},
toggleFiltersDropdown() { toggleFiltersDropdown() {
this.showFiltersDropdown = !this.showFiltersDropdown; this.showFiltersDropdown = !this.showFiltersDropdown;
if (this.showFiltersDropdown) {
this.$nextTick(() => {
this.calculateAlignment();
});
}
},
// Alpine lifecycle hook - runs when component initializes
init() {
let rafId: number | null = null;
window.addEventListener("resize", () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
rafId = requestAnimationFrame(() => {
if (this.showFiltersDropdown) {
this.calculateAlignment();
}
rafId = null;
});
});
}, },
// Cycle through has_cover states: null -> true -> false -> null // Cycle through has_cover states: null -> true -> false -> null
cycleHasCover(): void { cycleHasCover(): void {