Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
25041e2
Implement collapsible sidebar
Flo0807 Jan 8, 2026
7fed7c7
Delete BackpexSidebar hook
Flo0807 Jan 8, 2026
81bbf2c
Update build
Flo0807 Jan 8, 2026
f784fc0
Save sidebar state in local storage
Flo0807 Jan 8, 2026
dae6800
Format
Flo0807 Jan 8, 2026
f618d27
Update translations
Flo0807 Jan 8, 2026
b8a52a3
Update build
Flo0807 Jan 8, 2026
c720cb1
Call applyState in updated hook
Flo0807 Jan 8, 2026
b99de96
Rename topbar_branding into sidebar_branding
Flo0807 Jan 8, 2026
eb1792c
Use CSS variable for sidebar width
Flo0807 Jan 8, 2026
44b0e79
Build assets
Flo0807 Jan 8, 2026
6b244a9
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 Apr 17, 2026
ae41454
Fix stale topbar_branding references in template and docs
Flo0807 Apr 17, 2026
871c339
Add v0.19 upgrade guide for collapsible sidebar changes
Flo0807 Apr 17, 2026
1eb98c8
Add 16rem fallback for --sidebar-width CSS variable
Flo0807 Apr 17, 2026
4f77f90
Make sidebar section toggle a keyboard-accessible button
Flo0807 Apr 17, 2026
156bd12
Trap focus and restore it for the mobile sidebar drawer
Flo0807 Apr 17, 2026
00b45bc
Mark collapsed sidebar as inert
Flo0807 Apr 17, 2026
d05bb1d
Render sidebar with responsive defaults to reduce first-paint flash
Flo0807 Apr 17, 2026
116517c
Clean up sidebar hook listeners on destroy
Flo0807 Apr 17, 2026
d5017f4
Guard sidebar hook when no sidebar slot is rendered
Flo0807 Apr 17, 2026
3869b2a
Use single nav landmark for sidebar
Flo0807 Apr 17, 2026
edc71b6
Write CSS translate (not transform) to toggle sidebar
Flo0807 Apr 17, 2026
979eb4e
Gate sidebar transitions behind motion-safe
Flo0807 Apr 17, 2026
a8770d8
Raise sidebar breakpoint from md to lg
Flo0807 Apr 17, 2026
6c7ac92
Use daisyUI neutral for sidebar overlay
Flo0807 Apr 17, 2026
5ecfabe
Narrow sidebar main transition to margin-left
Flo0807 Apr 17, 2026
b0b3ccf
Require explicit id on sidebar_section
Flo0807 Apr 17, 2026
0781dad
Allow requestAnimationFrame in standard lint config
Flo0807 Apr 17, 2026
b5a7eb0
Track sidebar section handlers in a WeakMap
Flo0807 Apr 17, 2026
b37b5d6
Read sidebar breakpoint from --breakpoint-lg
Flo0807 Apr 17, 2026
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
268 changes: 268 additions & 0 deletions assets/js/hooks/_sidebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
/**
* 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 {
STORAGE_KEY: 'backpex-sidebar-open',
FOCUSABLE_SELECTOR:
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',

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')

// No sidebar slot rendered; hook has nothing to do.
if (!this.sidebar || !this.toggleBtn) return

// State: mobile closed by default, desktop state from localStorage (default open)
this.mobileOpen = false
this.desktopOpen = this.loadDesktopState()
// Element focused before the mobile drawer was opened, for focus restore.
this.previousFocus = null
// Per-toggle click handlers, keyed off the toggle element (section dropdowns).
this._sectionHandlers = new WeakMap()

// Track Tailwind's lg breakpoint via its CSS custom property so CSS
// `lg:` utilities and this hook stay in sync if the user customizes it.
// Falls back to the Tailwind v4 default when the variable is not defined.
const breakpoint =
getComputedStyle(document.documentElement)
.getPropertyValue('--breakpoint-lg')
.trim() || '64rem'
this.mediaQuery = window.matchMedia(`(min-width: ${breakpoint})`)

// Apply initial state (CSS sets visible by default, JS hides on mobile)
this.applyState()

// Re-enable transitions on the next frame so the initial snap to the
// stored desktop preference doesn't animate on first paint.
requestAnimationFrame(() => {
this.sidebar.removeAttribute('data-suppress-transition')
this.main.removeAttribute('data-suppress-transition')
})

// Event listeners (bound so they can be removed in destroyed())
this._onToggleClick = () => this.handleToggle()
this._onOverlayClick = () => this.closeMobile()
this._onMediaChange = (e) => this.handleResize(e)
this._onKeydown = (e) => this.handleKeydown(e)

this.toggleBtn.addEventListener('click', this._onToggleClick)
this.overlay.addEventListener('click', this._onOverlayClick)
this.mediaQuery.addEventListener('change', this._onMediaChange)

document.addEventListener('keydown', this._onKeydown)

// Initialize sidebar sections
this.initializeSections()
Comment thread
Flo0807 marked this conversation as resolved.
},

updated () {
if (!this.sidebar || !this.toggleBtn) return
this.applyState()
this.initializeSections()
},
Comment thread
Flo0807 marked this conversation as resolved.

destroyed () {
this.toggleBtn?.removeEventListener('click', this._onToggleClick)
this.overlay?.removeEventListener('click', this._onOverlayClick)
this.mediaQuery?.removeEventListener('change', this._onMediaChange)
document.removeEventListener('keydown', this._onKeydown)

const sections = this.el.querySelectorAll('[data-section-id]')
sections.forEach((section) => {
const toggle = section.querySelector('[data-menu-dropdown-toggle]')
const handler = toggle && this._sectionHandlers.get(toggle)
if (handler) {
toggle.removeEventListener('click', handler)
this._sectionHandlers.delete(toggle)
}
})
},

isDesktop () {
return this.mediaQuery.matches
},

handleToggle () {
if (this.isDesktop()) {
this.desktopOpen = !this.desktopOpen
this.saveDesktopState()
} else {
if (!this.mobileOpen) this.previousFocus = document.activeElement
this.mobileOpen = !this.mobileOpen
}
this.applyState()
if (!this.isDesktop() && this.mobileOpen) this.focusFirstInSidebar()
},

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 () {
const wasOpen = this.mobileOpen
this.mobileOpen = false
this.applyState()
if (wasOpen) this.restorePreviousFocus()
},

handleResize (event) {
if (event.matches) {
this.mobileOpen = false
this.previousFocus = null
}
this.applyState()
},

handleKeydown (event) {
if (!this.mobileOpen || this.isDesktop()) return

if (event.key === 'Escape') {
this.closeMobile()
return
}

if (event.key === 'Tab') this.trapTab(event)
},

trapTab (event) {
const focusable = this.sidebar.querySelectorAll(this.FOCUSABLE_SELECTOR)
if (focusable.length === 0) {
event.preventDefault()
return
}

const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement

if (event.shiftKey && (active === first || !this.sidebar.contains(active))) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && active === last) {
event.preventDefault()
first.focus()
}
},

focusFirstInSidebar () {
const focusable = this.sidebar.querySelector(this.FOCUSABLE_SELECTOR)
if (focusable) focusable.focus()
},

restorePreviousFocus () {
if (this.previousFocus && document.contains(this.previousFocus)) {
this.previousFocus.focus()
}
this.previousFocus = null
},

applyState () {
const isDesktop = this.isDesktop()
const sidebarVisible = isDesktop ? this.desktopOpen : this.mobileOpen

// Sidebar position. The SSR classes -translate-x-full lg:translate-x-0
// compile to the CSS `translate` property in Tailwind v4, so we must
// write to the same property to win over them.
this.sidebar.style.translate = sidebarVisible ? '0' : '-100%'

// Remove off-canvas sidebar from tab order and accessibility tree
this.sidebar.toggleAttribute('inert', !sidebarVisible)

// Main content margin (desktop only, uses CSS variable)
const showMargin = isDesktop && this.desktopOpen
this.main.style.marginLeft = showMargin ? 'var(--sidebar-width, 16rem)' : '0'

// 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())

// Mobile drawer behaves as a modal dialog; desktop is inline chrome.
if (!isDesktop && this.mobileOpen) {
this.sidebar.setAttribute('role', 'dialog')
this.sidebar.setAttribute('aria-modal', 'true')
} else {
this.sidebar.removeAttribute('role')
this.sidebar.removeAttribute('aria-modal')
}
},
Comment thread
Flo0807 marked this conversation as resolved.

// 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')
toggle.setAttribute('aria-expanded', 'false')
content.style.display = 'none'
} else {
toggle.setAttribute('aria-expanded', 'true')
}

section.classList.remove('hidden')

const previous = this._sectionHandlers.get(toggle)
if (previous) toggle.removeEventListener('click', previous)
const handler = (e) => this.handleSectionToggle(e)
this._sectionHandlers.set(toggle, handler)
toggle.addEventListener('click', 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')
toggle.setAttribute('aria-expanded', isNowOpen.toString())
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 &);
Loading