-
Notifications
You must be signed in to change notification settings - Fork 72
Add collapsible sidebar and redesign app layout #1748
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Flo0807
wants to merge
31
commits into
develop
Choose a base branch
from
feature/collapsible-sidebar
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
25041e2
Implement collapsible sidebar
Flo0807 7fed7c7
Delete BackpexSidebar hook
Flo0807 81bbf2c
Update build
Flo0807 f784fc0
Save sidebar state in local storage
Flo0807 dae6800
Format
Flo0807 f618d27
Update translations
Flo0807 b8a52a3
Update build
Flo0807 c720cb1
Call applyState in updated hook
Flo0807 b99de96
Rename topbar_branding into sidebar_branding
Flo0807 eb1792c
Use CSS variable for sidebar width
Flo0807 44b0e79
Build assets
Flo0807 6b244a9
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 ae41454
Fix stale topbar_branding references in template and docs
Flo0807 871c339
Add v0.19 upgrade guide for collapsible sidebar changes
Flo0807 1eb98c8
Add 16rem fallback for --sidebar-width CSS variable
Flo0807 4f77f90
Make sidebar section toggle a keyboard-accessible button
Flo0807 156bd12
Trap focus and restore it for the mobile sidebar drawer
Flo0807 00b45bc
Mark collapsed sidebar as inert
Flo0807 d05bb1d
Render sidebar with responsive defaults to reduce first-paint flash
Flo0807 116517c
Clean up sidebar hook listeners on destroy
Flo0807 d5017f4
Guard sidebar hook when no sidebar slot is rendered
Flo0807 3869b2a
Use single nav landmark for sidebar
Flo0807 edc71b6
Write CSS translate (not transform) to toggle sidebar
Flo0807 979eb4e
Gate sidebar transitions behind motion-safe
Flo0807 a8770d8
Raise sidebar breakpoint from md to lg
Flo0807 6c7ac92
Use daisyUI neutral for sidebar overlay
Flo0807 5ecfabe
Narrow sidebar main transition to margin-left
Flo0807 b0b3ccf
Require explicit id on sidebar_section
Flo0807 0781dad
Allow requestAnimationFrame in standard lint config
Flo0807 b5a7eb0
Track sidebar section handlers in a WeakMap
Flo0807 b37b5d6
Read sidebar breakpoint from --breakpoint-lg
Flo0807 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }, | ||
|
|
||
| updated () { | ||
| if (!this.sidebar || !this.toggleBtn) return | ||
| this.applyState() | ||
| this.initializeSections() | ||
| }, | ||
|
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') | ||
| } | ||
| }, | ||
|
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) | ||
| } | ||
| } | ||
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.