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
+46 -1
View File
@@ -80,6 +80,8 @@ Alpine.data("bookshelf", () => ({
showSaveModal: false,
showFiltersDropdown: false,
hasCoverState: null as boolean | null,
dropdownAlign: "right" as "left" | "right",
resizeTimeout: null as number | null,
// Standalone function references (don't access component state)
clearFilters,
@@ -89,9 +91,52 @@ Alpine.data("bookshelf", () => ({
showSaveFilterModal() {
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() {
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
cycleHasCover(): void {