Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
138 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
c9c5488
Add user preference system for UI states
Flo0807 Jan 8, 2026
3f1d6b8
Fix tests
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
c4852ee
Merge branch 'feature/collapsible-sidebar' into feature/user-preferen…
Flo0807 Apr 17, 2026
df0fd36
Route preferences through a pluggable adapter architecture
Flo0807 Apr 17, 2026
94f203f
Rewrite user preferences guide for adapter architecture
Flo0807 Apr 17, 2026
cd78ea9
Drop module-ref backticks for Backpex.LiveResource.Index in guide
Flo0807 Apr 17, 2026
19a669e
Rebuild static JS assets for endpointPath rename
Flo0807 Apr 17, 2026
67bf334
Refactor user preferences
Flo0807 Apr 21, 2026
8f71c26
Route filter persistence through intent-driven handlers
Flo0807 Apr 21, 2026
f0c6fe1
Disambiguate clear-filter selector in preferences persistence test
Flo0807 Apr 21, 2026
a0ae584
Preserve sidebar section state across live_redirect
Flo0807 Apr 21, 2026
08eecbe
Preserve sidebar open state across live_redirect
Flo0807 Apr 21, 2026
4e7376a
Reword sidebar test comments to reflect WIP status
Flo0807 Apr 21, 2026
3d84348
Respect persisted empty filters over :default filters
Flo0807 Apr 21, 2026
9608b56
Thread socket.assigns through InitAssigns reads via Context
Flo0807 Apr 21, 2026
e7ba94d
Add session-preservation helper, document fetch/3, tighten default-vs…
Flo0807 Apr 21, 2026
f1d27f9
Add match_fun route pattern to Backpex.Preferences.Router
Flo0807 Apr 21, 2026
19040f6
Lift sessionStorage mirroring into BackpexPreferences.get/set
Flo0807 Apr 21, 2026
a30c30e
Collapse reject+filter into single filter in best_prefix_match
Flo0807 Apr 21, 2026
9694aa5
Rebuild static JS assets for sessionStorage mirror helpers
Flo0807 Apr 21, 2026
0b0e4d8
Validate preference keys with opt-in dispatcher guard
Flo0807 Apr 21, 2026
d2fda97
Broadcast preference changes on Phoenix.PubSub (opt-in)
Flo0807 Apr 21, 2026
6c808ae
Add Backpex.Test helpers for LiveResource tests
Flo0807 Apr 21, 2026
b6d0a05
Drop module-ref backticks to hidden Backpex.LiveResource.Index
Flo0807 Apr 21, 2026
af2e10a
Delete docs directory
Flo0807 Apr 22, 2026
f7b5555
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 May 15, 2026
2cd6b31
Document breakpoint shift and translation renames in v0.19 upgrade guide
Flo0807 May 15, 2026
09850e9
Merge branch 'feature/collapsible-sidebar' into feature/user-preferen…
Flo0807 May 15, 2026
4f5c74e
Run `mix format`
Flo0807 May 15, 2026
a504949
Delete pr-1808-analysis.md
Flo0807 May 15, 2026
0ec782b
Delete pr-description.md
Flo0807 May 15, 2026
0023fc3
Pass socket and sidebar_open to app_shell in installer template
Flo0807 May 15, 2026
b4c90e8
Fix async-test race in Backpex.Test helpers test
Flo0807 May 15, 2026
4606af3
Reduce duplicated setup boilerplate in preference tests
Flo0807 May 15, 2026
19fdf07
Validate keys in put_batch to honor validate_keys strategy
Flo0807 May 15, 2026
07be23e
Rename misleading test artifacts in preference suite
Flo0807 May 15, 2026
5bbc18b
Tighten weak assertions across preference tests
Flo0807 May 15, 2026
9a160e8
Convert adapter exceptions in put_batch into error tuples
Flo0807 May 15, 2026
9fc160c
Add direct coverage for apply_effects_on_conn, Context.coerce, and Ro…
Flo0807 May 15, 2026
939f612
Skip PubSub broadcast and subscribe for unidentified callers
Flo0807 May 15, 2026
70e5681
Mark main content inert while mobile sidebar drawer is open
Flo0807 May 15, 2026
dd985c3
Document Router.cookie_path/1 rename in v0.19 upgrade guide
Flo0807 May 15, 2026
ce0a6a9
Update installation guide route name to backpex_preferences
Flo0807 May 15, 2026
dc82c1a
Add @moduledoc false to fake test modules to unblock Quokka
Flo0807 May 15, 2026
c96b517
Mount theme selector hook on the form instead of the dropdown wrapper
Flo0807 May 15, 2026
a2a1e24
Skip autolink for removed Backpex.Router.cookie_path/1
Flo0807 May 15, 2026
0023e86
Name match-wildcard variable to satisfy Credo consistency
Flo0807 May 15, 2026
5e91365
Accept typed values in Backpex.Filters.Range parsing
Flo0807 May 15, 2026
020a9fe
Return 200 for single-write :unidentified preference writes
Flo0807 May 15, 2026
02c2f96
Format
Flo0807 May 15, 2026
5d48fd4
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 Jun 23, 2026
e992460
Add sidebar_menu component to encapsulate the menu wrapper
Flo0807 Jun 23, 2026
5e7ec60
Split section expand/collapse into its own hook
Flo0807 Jun 23, 2026
f720d22
Drive sidebar visual state from CSS data attributes
Flo0807 Jun 23, 2026
ba2e244
Tidy app_shell sidebar checks and guard section setup
Flo0807 Jun 23, 2026
0e3b299
Build assets
Flo0807 Jun 23, 2026
d99592f
Merge feature/collapsible-sidebar into feature/user-preference-system
Flo0807 Jun 23, 2026
9ce2590
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 Jul 14, 2026
6c26d6d
Fix persisted column and metric visibility reverting on live navigation
Flo0807 Jul 14, 2026
bcceae7
Fix mobile drawer focus trap when a sidebar section is collapsed
Flo0807 Jul 14, 2026
b7a150a
Handle malformed :identity config with a warning instead of a crash
Flo0807 Jul 14, 2026
3d285df
Guard get_map/3 against raising preference adapters
Flo0807 Jul 14, 2026
cf572d6
Warn when session-stored preferences approach the cookie size limit
Flo0807 Jul 14, 2026
4080094
Do not offset app_shell main content without a sidebar slot
Flo0807 Jul 14, 2026
769dc9d
Server-render inert on the closed sidebar to keep it out of the pre-h…
Flo0807 Jul 14, 2026
9968a41
Group Backpex.Preferences modules in ExDoc
Flo0807 Jul 14, 2026
7c11103
Wrap installation guide sidebar examples in sidebar_menu
Flo0807 Jul 14, 2026
4450264
Document BackpexPreferencesHook in the v0.20 hook registration advice
Flo0807 Jul 14, 2026
354e371
Correct guide: put_batch/3 validates preference keys
Flo0807 Jul 14, 2026
bc0baba
Enumerate renamed translation msgids in the v0.20 upgrade guide
Flo0807 Jul 14, 2026
a1f997f
Add accessibility assertions to the sidebar browser test
Flo0807 Jul 14, 2026
73e4053
Extract column sync mapping into a helper
Flo0807 Jul 14, 2026
60534c6
Enable preference persistence on all demo resources
Flo0807 Jul 14, 2026
b87920c
Make metrics visibility an opt-in persist key
Flo0807 Jul 14, 2026
2b05e3e
Deliver preferences in the connect params so the first render is correct
Flo0807 Jul 14, 2026
4bd0aff
Render unacknowledged preference writes on the dead render
Flo0807 Jul 14, 2026
08056cf
Scope pending preference writes to the identity that made them
Flo0807 Jul 14, 2026
9457afa
Delete Backpex.Preferences.Session
Flo0807 Jul 14, 2026
0ee4deb
Drop the unused cookieName and connect_param accessors
Flo0807 Jul 14, 2026
7f24d5b
Drop Backpex.Test in favor of Phoenix's follow_redirect/2
Flo0807 Jul 14, 2026
df73828
Remove the preferences cross-tab PubSub emitter
Flo0807 Jul 14, 2026
1c0ce2f
Remove the opt-in preference key validation gate
Flo0807 Jul 14, 2026
eba165c
Remove the unused fetch/3 and parse_key/1 preference reads
Flo0807 Jul 14, 2026
65b96b4
Return a single side effect from the preferences adapter put callback
Flo0807 Jul 14, 2026
f9f5d67
Collapse preference route patterns to one segmentation rule
Flo0807 Jul 14, 2026
6c02842
Route LiveResource preference writes through the adapter
Flo0807 Jul 14, 2026
4e1d540
Wrap the sidebar slot in the ul its children require
Flo0807 Jul 14, 2026
c9a86ca
Run the Preferences.Context doctests and pin the client trust boundary
Flo0807 Jul 14, 2026
e5fd21e
Apply the init_order default instead of dropping it
Flo0807 Jul 14, 2026
a809271
Mirror persisted index state into the live session
Flo0807 Jul 15, 2026
4d0c1de
Merge preference subtree reads across adapters
Flo0807 Jul 15, 2026
2f64af4
Validate persisted resource ordering
Flo0807 Jul 15, 2026
72fbbb3
Remove added preference specs
Flo0807 Jul 15, 2026
c29a664
Remove unused preferences adapter alias
Flo0807 Jul 15, 2026
9112b49
Derive default order from primary key
Flo0807 Jul 15, 2026
c69da2b
Simplify preference route matching
Flo0807 Jul 15, 2026
f2bb81a
Reuse preference dispatch helpers
Flo0807 Jul 15, 2026
30c7a28
Fix preferences feature documentation
Flo0807 Jul 15, 2026
eb324a9
Rebuild JavaScript assets
Flo0807 Jul 15, 2026
3b79dd1
Merge branch 'develop' into feature/collapsible-sidebar
Flo0807 Jul 17, 2026
c01abe2
Refuse oversized session writes instead of overflowing the cookie
Flo0807 Jul 17, 2026
f96c349
Refuse preference values a built-in reader cannot consume
Flo0807 Jul 17, 2026
0de3810
Persist the order preference only once the user picks one
Flo0807 Jul 17, 2026
254a44f
Extract preferences_root so custom layouts can persist preferences
Flo0807 Jul 17, 2026
e2a34b9
Snap the restored sidebar state instead of animating it
Flo0807 Jul 17, 2026
adb1a85
Release the sidebar transition guard after LiveView patches
Flo0807 Jul 17, 2026
9222713
Cover the sidebar transition guard against live_patch
Flo0807 Jul 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
1 change: 1 addition & 0 deletions assets/js/backpex.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Hooks from './hooks'

export { Hooks }
export { BackpexPreferences, backpexParams } from './hooks/_preferences'
624 changes: 624 additions & 0 deletions assets/js/hooks/_preferences.js

Large diffs are not rendered by default.

269 changes: 269 additions & 0 deletions assets/js/hooks/_sidebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { BackpexPreferences } from './_preferences'

// Sidebar state is persisted through BackpexPreferences with
// `mirror: 'session'`, which covers all three ways this shell gets rendered:
//
// - Fresh connect: the server reads the preference adapter and renders
// `data-sidebar-open`.
// - live_redirect: no HTTP request happens and LiveView freezes the session at
// websocket-connect time, so the server re-renders the shell from a stale
// snapshot. The sessionStorage mirror, handed back in the connect params,
// keeps the user's choice authoritative.
// - Reload right after a toggle: the POST has not landed yet, so the session
// cookie the document GET carries is one write behind. The short-lived
// `backpex_prefs` cookie carries the unacknowledged write to the dead render.
//
// The server therefore only ever has NEW information for this hook when the
// write it is rendering has been acknowledged — which is exactly the condition
// `updated()` adopts on. See assets/js/hooks/_preferences.js and the "Writing a
// JS hook that persists preferences" section of the user preferences guide. If
// you add another JS-driven UI-chrome preference, follow the same pattern
// instead of rolling your own sessionStorage layer.

/**
* Manages sidebar open/close drawer state for mobile and desktop.
*
* Desktop: sidebar visible by default, content shifts when closed
* Mobile: sidebar hidden by default, overlays content when opened
*
* Section expand/collapse lives in the separate BackpexSidebarSections hook.
*/
export default {
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 prefers the
// sessionStorage mirror over the server-rendered data attribute — same
// live_redirect staleness reason as the section states. By the time hooks
// mount, connectParams() has primed the mirror, so the two agree unless
// this tab holds an unacknowledged write — in which case the mirror is
// right and the attribute may not be.
this.mobileOpen = false
this.desktopOpen = BackpexPreferences.get(
'global.sidebar_open',
this.el.dataset.sidebarOpen === 'true'
)
// Last server-rendered value, so updated() can tell a *changed* attribute
// (new information) from the server merely re-rendering what it had.
this.serverOpen = this.el.dataset.sidebarOpen === 'true'
// Element focused before the mobile drawer was opened, for focus restore.
this.previousFocus = null

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

this.releaseTransitions()

// 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)
},

updated () {
if (!this.sidebar || !this.toggleBtn) return

// Edge-triggered: `desktopOpen` drives higher-specificity `data-[state]`
// classes, so re-asserting a stale cached value on every render would
// permanently override the server. Adopt the attribute only when it
// *changed* — and only when this tab has no write the server has yet to
// acknowledge, since such a render was necessarily produced without it.
const serverOpen = this.el.dataset.sidebarOpen === 'true'
if (serverOpen !== this.serverOpen) {
this.serverOpen = serverOpen
if (!BackpexPreferences.isPending('global.sidebar_open')) this.desktopOpen = serverOpen
}

this.applyState()

// `data-suppress-transition` is static markup, so any patch that re-renders
// the shell — a live_patch from sorting or filtering, say — morphs it back
// onto these elements. Nothing else takes it off again, which left the
// sidebar unable to animate for the rest of the page load.
this.releaseTransitions()
},

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

// Drop inert in case the hook is torn down while the mobile drawer is open
// so the main content doesn't stay unreachable across live_redirects.
this.main?.removeAttribute('inert')
},

// `data-suppress-transition` is server-rendered so the correction applyState()
// may have just made — the mirror disagreeing with a dead render one write
// behind — snaps instead of animating. Releasing it in the same style-change
// event that applies that correction defeats it: a transition starts from the
// *after-change* style, so the suppressed before-change style buys nothing and
// the sidebar slides for 300ms instead. Force the corrected state through a
// style recalculation while still suppressed, so it becomes the before-change
// style, then release against an unchanged value.
//
// Deliberately synchronous rather than requestAnimationFrame: rAF never fires
// in a background tab, which would strand the guard and leave the sidebar
// unable to animate for the rest of the page load.
releaseTransitions () {
if (
!this.sidebar.hasAttribute('data-suppress-transition') &&
!this.main.hasAttribute('data-suppress-transition')
) return

// Reading layout flushes the pending style change.
this.sidebar.getBoundingClientRect()
this.main.getBoundingClientRect()

this.sidebar.removeAttribute('data-suppress-transition')
this.main.removeAttribute('data-suppress-transition')
},
Comment thread
Flo0807 marked this conversation as resolved.

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

handleToggle () {
if (this.isDesktop()) {
this.desktopOpen = !this.desktopOpen
// Writes the sessionStorage mirror and marks the key pending in the
// `backpex_prefs` cookie, both synchronously, before POSTing. Until that
// POST responds this value beats anything the server renders.
BackpexPreferences.set('global.sidebar_open', this.desktopOpen, { mirror: 'session' })
} else {
if (!this.mobileOpen) this.previousFocus = document.activeElement
this.mobileOpen = !this.mobileOpen
}
this.applyState()
if (!this.isDesktop() && this.mobileOpen) this.focusFirstInSidebar()
},

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.visibleFocusable()
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()
}
},

// Focusable descendants that are actually rendered. Links inside a
// collapsed section are display:none (offsetParent === null); leaving
// them in would anchor the trap's first/last on an unfocusable element
// and let Tab escape the modal drawer. No focusable inside the sidebar
// is position:fixed, so a null offsetParent reliably means hidden here.
visibleFocusable () {
return Array.from(this.sidebar.querySelectorAll(this.FOCUSABLE_SELECTOR))
.filter((el) => el.offsetParent !== null)
},

focusFirstInSidebar () {
const focusable = this.visibleFocusable()[0]
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

// Declarative state: write data attributes and let CSS map them to the
// translate / margin / overlay styles (see the app_shell classes). One
// source of truth, and no inline styles fighting the SSR `lg:` defaults.
this.sidebar.dataset.state = sidebarVisible ? 'open' : 'closed'

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

// Main content shifts only when the desktop sidebar is open.
this.main.dataset.shift = isDesktop && this.desktopOpen ? 'on' : 'off'

// Overlay is shown only for the open mobile drawer.
this.overlay.dataset.visible = !isDesktop && this.mobileOpen ? 'on' : 'off'

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

// aria-modal needs a matching inert region; the topbar and main content
// live inside #backpex-main, so inerting that element covers both.
this.main.toggleAttribute('inert', !isDesktop && this.mobileOpen)
}
}
Loading