From 25041e2b7c3fe7c9ef09700c7be0ff0188404c52 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:09:54 +0100 Subject: [PATCH 01/11] Implement collapsible sidebar --- assets/js/hooks/_sidebar.js | 151 ++++++++++++++++++ assets/js/hooks/index.js | 1 + .../components/layouts/admin.html.heex | 3 +- lib/backpex/html/layout.ex | 120 ++++++++------ 4 files changed, 225 insertions(+), 50 deletions(-) create mode 100644 assets/js/hooks/_sidebar.js diff --git a/assets/js/hooks/_sidebar.js b/assets/js/hooks/_sidebar.js new file mode 100644 index 000000000..c8b86eeb1 --- /dev/null +++ b/assets/js/hooks/_sidebar.js @@ -0,0 +1,151 @@ +/** + * Manages sidebar open/close state for mobile and desktop and handles sidebar section expand/collapse. + * + * Desktop: sidebar visible by default, content shifts when closed + * Mobile: sidebar hidden by default, overlays content when opened + */ +export default { + MOBILE_BREAKPOINT: 768, + + mounted() { + this.sidebar = document.getElementById("backpex-sidebar"); + this.overlay = document.getElementById("backpex-sidebar-overlay"); + this.main = document.getElementById("backpex-main"); + this.toggleBtn = document.getElementById("backpex-sidebar-toggle"); + + // State: mobile closed by default, desktop open by default + this.mobileOpen = false; + this.desktopOpen = true; + + // Apply initial state (CSS sets visible by default, JS hides on mobile) + this.applyState(); + + // Event listeners + this.toggleBtn.addEventListener("click", () => this.handleToggle()); + this.overlay.addEventListener("click", () => this.closeMobile()); + + this.mediaQuery = window.matchMedia( + `(min-width: ${this.MOBILE_BREAKPOINT}px)`, + ); + this.mediaQuery.addEventListener("change", (e) => this.handleResize(e)); + + document.addEventListener("keydown", (e) => this.handleKeydown(e)); + + // Initialize sidebar sections + this.initializeSections(); + }, + + updated() { + this.initializeSections(); + }, + + isDesktop() { + return window.innerWidth >= this.MOBILE_BREAKPOINT; + }, + + handleToggle() { + if (this.isDesktop()) { + this.desktopOpen = !this.desktopOpen; + } else { + this.mobileOpen = !this.mobileOpen; + } + this.applyState(); + }, + + closeMobile() { + this.mobileOpen = false; + this.applyState(); + }, + + handleResize(event) { + if (event.matches) { + this.mobileOpen = false; + } + this.applyState(); + }, + + handleKeydown(event) { + if (event.key === "Escape" && this.mobileOpen && !this.isDesktop()) { + this.closeMobile(); + } + }, + + applyState() { + const isDesktop = this.isDesktop(); + const sidebarVisible = isDesktop ? this.desktopOpen : this.mobileOpen; + + // Sidebar transform + this.sidebar.classList.toggle("-translate-x-full", !sidebarVisible); + this.sidebar.classList.toggle("translate-x-0", sidebarVisible); + + // Main content margin (desktop only) + this.main.classList.toggle("md:ml-64", isDesktop && this.desktopOpen); + this.main.classList.toggle("md:ml-0", !isDesktop || !this.desktopOpen); + + // Overlay (mobile only) + const showOverlay = !isDesktop && this.mobileOpen; + this.overlay.classList.toggle("opacity-0", !showOverlay); + this.overlay.classList.toggle("pointer-events-none", !showOverlay); + this.overlay.classList.toggle("opacity-100", showOverlay); + this.overlay.classList.toggle("pointer-events-auto", showOverlay); + + // ARIA + this.toggleBtn.setAttribute("aria-expanded", sidebarVisible.toString()); + }, + + // Sidebar Sections + + initializeSections() { + const sections = this.el.querySelectorAll("[data-section-id]"); + + sections.forEach((section) => { + const sectionId = section.dataset.sectionId; + const toggle = section.querySelector("[data-menu-dropdown-toggle]"); + const content = section.querySelector("[data-menu-dropdown-content]"); + + if (!this.hasContent(content)) { + content.style.display = "none"; + return; + } + + const isOpen = + localStorage.getItem(`sidebar-section-${sectionId}`) === "true"; + if (!isOpen) { + toggle.classList.remove("menu-dropdown-show"); + content.style.display = "none"; + } + + section.classList.remove("hidden"); + + toggle.removeEventListener("click", toggle._handler); + toggle._handler = (e) => this.handleSectionToggle(e); + toggle.addEventListener("click", toggle._handler); + }); + }, + + hasContent(element) { + if (!element || element.children.length === 0) return false; + for (const child of element.children) { + const childContent = child.querySelector("[data-menu-dropdown-content]"); + if (childContent) { + if (this.hasContent(childContent)) return true; + } else { + return true; + } + } + return false; + }, + + handleSectionToggle(event) { + const section = event.currentTarget.closest("[data-section-id]"); + const sectionId = section.dataset.sectionId; + const toggle = section.querySelector("[data-menu-dropdown-toggle]"); + const content = section.querySelector("[data-menu-dropdown-content]"); + + toggle.classList.toggle("menu-dropdown-show"); + content.style.display = content.style.display === "none" ? "block" : "none"; + + const isNowOpen = toggle.classList.contains("menu-dropdown-show"); + localStorage.setItem(`sidebar-section-${sectionId}`, isNowOpen); + }, +}; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index bd98dbe14..96a463aaa 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1,5 +1,6 @@ export { default as BackpexCancelEntry } from './_cancel_entry' export { default as BackpexDragHover } from './_drag_hover' +export { default as BackpexSidebar } from './_sidebar' export { default as BackpexSidebarSections } from './_sidebar_sections' export { default as BackpexStickyActions } from './_sticky_actions' export { default as BackpexThemeSelector } from './_theme_selector' diff --git a/demo/lib/demo_web/components/layouts/admin.html.heex b/demo/lib/demo_web/components/layouts/admin.html.heex index 38e8851e3..7c33d09a4 100644 --- a/demo/lib/demo_web/components/layouts/admin.html.heex +++ b/demo/lib/demo_web/components/layouts/admin.html.heex @@ -1,7 +1,6 @@ <:topbar> - - +
- -
-
-