Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions assets/js/hooks/_sidebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* 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,
STORAGE_KEY: 'backpex-sidebar-open',

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 state from localStorage (default open)
this.mobileOpen = false
this.desktopOpen = this.loadDesktopState()

// 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()
Comment on lines +11 to +36
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook attempts to access DOM elements that may not exist when the sidebar is empty. The sidebar, overlay, and toggle button are conditionally rendered in the template when @sidebar != [], but this code doesn't check if these elements exist before trying to use them. This will cause JavaScript errors and crashes when the sidebar slot is empty. Add null checks for all DOM element lookups and early return if the sidebar doesn't exist.

Copilot uses AI. Check for mistakes.
},

updated () {
this.applyState()
this.initializeSections()
},
Comment on lines +11 to +42
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing a destroyed() lifecycle hook to clean up event listeners. The hook adds several event listeners in mounted() including click handlers, media query listeners, and a document keydown listener. Without proper cleanup, these listeners will persist even after the component is destroyed, leading to memory leaks and potential errors. Other hooks in this codebase consistently implement destroyed() to clean up resources.

Copilot uses AI. Check for mistakes.

isDesktop () {
return window.innerWidth >= this.MOBILE_BREAKPOINT
},

handleToggle () {
if (this.isDesktop()) {
this.desktopOpen = !this.desktopOpen
this.saveDesktopState()
} else {
this.mobileOpen = !this.mobileOpen
}
this.applyState()
},

loadDesktopState () {
const stored = localStorage.getItem(this.STORAGE_KEY)
// Default to open if no stored value
return stored === null ? true : stored === 'true'
},

saveDesktopState () {
localStorage.setItem(this.STORAGE_KEY, this.desktopOpen.toString())
},

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, uses CSS variable)
const showMargin = isDesktop && this.desktopOpen
this.main.classList.toggle('ml-(--sidebar-width)', showMargin)
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS class syntax ml-(--sidebar-width) appears incorrect for Tailwind CSS. The standard way to use CSS custom properties in Tailwind arbitrary values is ml-[var(--sidebar-width)]. The parentheses syntax used here may not be recognized by Tailwind's CSS processor and could result in the class not applying the expected margin. Please verify this syntax works with your version of Tailwind CSS v4, or use the standard arbitrary value syntax with square brackets.

Suggested change
this.main.classList.toggle('ml-(--sidebar-width)', showMargin)
this.main.classList.toggle('ml-[var(--sidebar-width)]', showMargin)

Copilot uses AI. Check for mistakes.
this.main.classList.toggle('ml-0', !showMargin)

// 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())
},
Comment on lines +86 to +108
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing properties on potentially null elements. If the sidebar doesn't exist, this.sidebar, this.overlay, this.main, and this.toggleBtn will be null, and calling methods like classList.toggle() on them will throw errors. This method is called from multiple places including updated() lifecycle, which can execute repeatedly.

Copilot uses AI. Check for mistakes.

// 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)
Comment on lines +134 to +136
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using toggle._handler to store the handler reference is a code smell. While this pattern works, it's unconventional and stores arbitrary properties on DOM elements. A cleaner approach would be to use a WeakMap to associate handlers with elements, or store handlers in a Map on the hook instance. This current approach could potentially conflict with other code that might use the same property name.

Copilot uses AI. Check for mistakes.
})
},

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)
}
}
74 changes: 0 additions & 74 deletions assets/js/hooks/_sidebar_sections.js

This file was deleted.

2 changes: 1 addition & 1 deletion assets/js/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { default as BackpexCancelEntry } from './_cancel_entry'
export { default as BackpexDragHover } from './_drag_hover'
export { default as BackpexSidebarSections } from './_sidebar_sections'
export { default as BackpexSidebar } from './_sidebar'
export { default as BackpexStickyActions } from './_sticky_actions'
export { default as BackpexThemeSelector } from './_theme_selector'
export { default as BackpexTooltip } from './_tooltip'
Expand Down
4 changes: 4 additions & 0 deletions demo/assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
@source "../../../lib/**/*.*ex";
@source "../../../assets/js/hooks/**/*.*js";

:root {
--sidebar-width: 16rem;
}

@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
68 changes: 36 additions & 32 deletions demo/lib/demo_web/components/layouts/admin.html.heex
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<Backpex.HTML.Layout.app_shell fluid={@fluid?} live_resource={@live_resource}>
<:topbar>
<Backpex.HTML.Layout.topbar_branding />

<div class="flex-1"></div>
<Backpex.HTML.Layout.theme_selector
socket={@socket}
class="mr-2"
Expand Down Expand Up @@ -57,36 +56,41 @@
</Backpex.HTML.Layout.topbar_dropdown>
</:topbar>
<:sidebar>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/users">
<Backpex.HTML.CoreComponents.icon name="hero-user" class="size-5" /> Users
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/addresses">
<Backpex.HTML.CoreComponents.icon name="hero-building-office-2" class="size-5" /> Addresses
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/products">
<Backpex.HTML.CoreComponents.icon name="hero-shopping-bag" class="size-5" /> Products
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/invoices">
<Backpex.HTML.CoreComponents.icon name="hero-document-text" class="size-5" /> Invoices
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/film-reviews">
<Backpex.HTML.CoreComponents.icon name="hero-film" class="size-5" /> Film Reviews
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/short-links">
<Backpex.HTML.CoreComponents.icon name="hero-link" class="size-5" /> Short Links
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_section id="blog">
<:label>Blog</:label>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/posts">
<Backpex.HTML.CoreComponents.icon name="hero-book-open" class="size-5" /> Posts
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/categories">
<Backpex.HTML.CoreComponents.icon name="hero-tag" class="size-5" /> Categories
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/tags">
<Backpex.HTML.CoreComponents.icon name="hero-tag" class="size-5" /> Tags
</Backpex.HTML.Layout.sidebar_item>
</Backpex.HTML.Layout.sidebar_section>
<Backpex.HTML.Layout.sidebar_branding />
<nav class="menu w-full flex-1 overflow-y-auto px-2 py-2">
<ul class="w-full">
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/users">
<Backpex.HTML.CoreComponents.icon name="hero-user" class="size-5" /> Users
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/addresses">
<Backpex.HTML.CoreComponents.icon name="hero-building-office-2" class="size-5" /> Addresses
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/products">
<Backpex.HTML.CoreComponents.icon name="hero-shopping-bag" class="size-5" /> Products
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/invoices">
<Backpex.HTML.CoreComponents.icon name="hero-document-text" class="size-5" /> Invoices
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/film-reviews">
<Backpex.HTML.CoreComponents.icon name="hero-film" class="size-5" /> Film Reviews
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/short-links">
<Backpex.HTML.CoreComponents.icon name="hero-link" class="size-5" /> Short Links
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_section id="blog">
<:label>Blog</:label>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/posts">
<Backpex.HTML.CoreComponents.icon name="hero-book-open" class="size-5" /> Posts
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/categories">
<Backpex.HTML.CoreComponents.icon name="hero-tag" class="size-5" /> Categories
</Backpex.HTML.Layout.sidebar_item>
<Backpex.HTML.Layout.sidebar_item current_url={@current_url} navigate="/admin/tags">
<Backpex.HTML.CoreComponents.icon name="hero-tag" class="size-5" /> Tags
</Backpex.HTML.Layout.sidebar_item>
</Backpex.HTML.Layout.sidebar_section>
</ul>
</nav>
</:sidebar>
<Backpex.HTML.Layout.flash_messages flash={@flash} />
{render_slot(@inner_block)}
Expand Down
Loading