diff --git a/assets/js/hooks/_sidebar.js b/assets/js/hooks/_sidebar.js new file mode 100644 index 000000000..06682cd91 --- /dev/null +++ b/assets/js/hooks/_sidebar.js @@ -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() + }, + + updated () { + if (!this.sidebar || !this.toggleBtn) return + this.applyState() + this.initializeSections() + }, + + 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') + } + }, + + // 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) + } +} diff --git a/assets/js/hooks/_sidebar_sections.js b/assets/js/hooks/_sidebar_sections.js deleted file mode 100644 index 687dc37cd..000000000 --- a/assets/js/hooks/_sidebar_sections.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Handles the initial state of all sidebar sections and saves the state to localstorage on toggle. - */ -export default { - mounted () { - this.initializeSections() - }, - updated () { - this.initializeSections() - }, - destroyed () { - const sections = this.el.querySelectorAll('[data-section-id]') - - sections.forEach(section => { - const toggle = section.querySelector('[data-menu-dropdown-toggle]') - toggle.removeEventListener('click', this.handleToggle.bind(this)) - }) - }, - 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 - }, - 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.addEventListener('click', this.handleToggle.bind(this)) - }) - }, - handleToggle (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..128db490e 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -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' diff --git a/demo/assets/css/app.css b/demo/assets/css/app.css index 21af9c537..322aa1727 100644 --- a/demo/assets/css/app.css +++ b/demo/assets/css/app.css @@ -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 &); diff --git a/demo/lib/demo_web/components/layouts/admin.html.heex b/demo/lib/demo_web/components/layouts/admin.html.heex index 38e8851e3..f92e69bcf 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> - - +
<:sidebar> - - Users - - - Addresses - - - Products - - - Invoices - - - Film Reviews - - - Short Links - - - <:label>Blog - - Posts - - - Categories - - - Tags - - + + {render_slot(@inner_block)} diff --git a/guides/get_started/installation.md b/guides/get_started/installation.md index 2e4f3520f..e46044577 100644 --- a/guides/get_started/installation.md +++ b/guides/get_started/installation.md @@ -140,7 +140,7 @@ To get you started quickly, we provide a layout component you can copy & paste i ```heex <:topbar> - +
<:label> @@ -157,7 +157,12 @@ To get you started quickly, we provide a layout component you can copy & paste i <:sidebar> - + + {render_slot(@inner_block)} @@ -573,7 +578,7 @@ You can add a theme selector to your layout component to allow users to change t ```heex <:topbar> - +
<:sidebar> - + + <%= @inner_content %> diff --git a/guides/upgrading/v0.19.md b/guides/upgrading/v0.19.md new file mode 100644 index 000000000..f2183cda1 --- /dev/null +++ b/guides/upgrading/v0.19.md @@ -0,0 +1,84 @@ +# Upgrading to v0.19 + +## Bump Your Deps + +Update Backpex to the latest version: + +```elixir +defp deps do + [ + {:backpex, "~> 0.19.0"} + ] +end +``` + +## Collapsible Sidebar + +The `app_shell` component now ships with a unified collapsible sidebar that replaces the previous split between a mobile drawer and a desktop-only sidebar. **This introduces breaking changes to the sidebar API:** + +### `topbar_branding` renamed to `sidebar_branding` and moved into `<:sidebar>` + +The branding component has been renamed and belongs inside the sidebar slot, not the topbar. + +**Before:** + +```heex + + <:topbar> + + + + <:sidebar> + + + +``` + +**After:** + +```heex + + <:topbar> +
+ + + <:sidebar> + + + +
+``` + +### The `<:sidebar>` slot no longer auto-wraps in `
    ` + +Previously `app_shell` wrapped sidebar slot content in a `
      `. It no longer does. `sidebar_item` and `sidebar_section` emit `
    • ` elements, so consumers must now provide their own `
        ` wrapper (as shown above). Without the wrapper you will produce invalid HTML and lose list semantics for screen readers. The outer `app_shell` sidebar is now a `