diff --git a/assets/js/backpex.js b/assets/js/backpex.js index a38ca5529..c1d764f3e 100644 --- a/assets/js/backpex.js +++ b/assets/js/backpex.js @@ -1,3 +1,4 @@ import * as Hooks from './hooks' export { Hooks } +export { BackpexPreferences } from './hooks/_preferences' diff --git a/assets/js/hooks/_preferences.js b/assets/js/hooks/_preferences.js new file mode 100644 index 000000000..bc7dde3df --- /dev/null +++ b/assets/js/hooks/_preferences.js @@ -0,0 +1,175 @@ +/** + * BackpexPreferences - Unified preference persistence + * + * Handles all preference writes to the server. Supports: + * - Direct calls from JS hooks: BackpexPreferences.set(key, value) + * - LiveView push_events: push_event("backpex:set_preference", %{key, value}) + * + * Features: + * - Immediate persistence with keepalive (survives page navigation) + * - Non-blocking async operation + * - Optional sessionStorage mirroring for hooks whose UI chrome is re-rendered + * from a session snapshot that LiveView freezes at websocket-connect time. + * See the `mirror: 'session'` option on `set/3` and the matching `get/2` + * below — and the "Writing a JS hook that persists preferences" section + * of the user-preferences guide for the full rationale. + */ + +// All mirrored values share this prefix so a devtools inspection of +// sessionStorage is legible and one call site can clear everything if needed. +const SESSION_PREFIX = 'backpex.prefs.' + +function sessionKey (key) { + return SESSION_PREFIX + key +} + +// Best-effort read. Returns the raw string, or null if sessionStorage is +// unavailable (private mode, disabled) or the key is absent. +function readSession (key) { + try { + return sessionStorage.getItem(sessionKey(key)) + } catch { + return null + } +} + +// Best-effort write. Silently drops writes if sessionStorage is unavailable +// or quota-exceeded — the HTTP POST is still fired and remains authoritative +// on the next fresh connect. +function writeSession (key, value) { + try { + sessionStorage.setItem(sessionKey(key), value) + } catch { + // sessionStorage may be unavailable (private mode, quota); best effort only + } +} + +const BackpexPreferences = { + endpointPath: null, + csrfToken: null, + + /** + * Initialize the preference manager. + * Called by the LiveView hook on mount. + */ + init (endpointPath) { + this.endpointPath = endpointPath + this.csrfToken = document.querySelector("meta[name='csrf-token']")?.content + }, + + /** + * Read a preference, preferring the sessionStorage mirror over the + * caller-provided fallback. Only meaningful for keys that were written + * with `{ mirror: 'session' }` — keys persisted on the server alone will + * always return `fallback` here. + * + * Booleans and numbers deserialize from their `String(value)` form; + * strings pass through; everything else round-trips through JSON. + * + * The fallback's runtime type drives deserialization, so callers always + * get a value of the same shape they passed in. + * + * @param {string} key - Dot-notation key (e.g., "global.sidebar_open") + * @param {boolean|number|string|object|null|undefined} fallback - Value to + * return when the mirror is absent or sessionStorage is unavailable. + * @returns {*} The stored value or `fallback`. + */ + get (key, fallback) { + const raw = readSession(key) + if (raw === null) return fallback + + if (typeof fallback === 'boolean') return raw === 'true' + if (typeof fallback === 'number') { + const n = Number(raw) + return Number.isNaN(n) ? fallback : n + } + if (typeof fallback === 'string') return raw + + // Objects, arrays, null, undefined fallbacks → treat the mirror as JSON. + try { + return JSON.parse(raw) + } catch { + return fallback + } + }, + + /** + * Set a preference value and persist immediately. + * Called directly by JS hooks or via LiveView push_event. + * + * When `opts.mirror === 'session'` the value is written to sessionStorage + * *before* the HTTP POST, so the client-authoritative state survives the + * hook re-mount that LiveView performs on `live_redirect` between + * LiveViews (the server reads its session snapshot from the websocket + * handshake, which is frozen at connect time and doesn't see writes the + * HTTP endpoint just committed to the cookie). + * + * `opts.mirror === false` (or omitting `opts` entirely) keeps the legacy + * behavior: HTTP POST only, no local mirror. This is the right choice + * whenever the server is the authoritative source on every render + * (e.g. a DB-backed preference read fresh from Ecto). + * + * @param {string} key - Dot-notation key (e.g., "global.theme") + * @param {any} value - Value to store + * @param {{ mirror?: 'session' | false }} [opts] + */ + set (key, value, opts = {}) { + if (opts.mirror === 'session') { + const serialized = (typeof value === 'string') + ? value + : (typeof value === 'boolean' || typeof value === 'number') + ? String(value) + : JSON.stringify(value) + writeSession(key, serialized) + } + + this.persist(key, value) + }, + + /** + * Persist a preference to the server immediately. + * Uses keepalive to ensure request completes even during page navigation. + */ + persist (key, value) { + if (!this.endpointPath) { + console.warn('BackpexPreferences: endpointPath not initialized') + return + } + if (!this.csrfToken) { + console.warn('BackpexPreferences: CSRF token not found') + return + } + + // Use keepalive to ensure request survives page navigation + fetch(this.endpointPath, { + method: 'POST', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': this.csrfToken + }, + body: JSON.stringify({ key, value }) + }).catch(error => { + console.error('BackpexPreferences: failed to persist', error) + }) + } +} + +/** + * LiveView hook that initializes BackpexPreferences + * and listens for push_events from the server. + * + * Mount this hook on an element with data-preferences-path attribute. + */ +const BackpexPreferencesHook = { + mounted () { + BackpexPreferences.init(this.el.dataset.preferencesPath) + + this.handleEvent('backpex:set_preference', ({ key, value }) => { + BackpexPreferences.set(key, value) + }) + } +} + +export default BackpexPreferencesHook +export { BackpexPreferences } diff --git a/assets/js/hooks/_sidebar.js b/assets/js/hooks/_sidebar.js index 06682cd91..058500d9f 100644 --- a/assets/js/hooks/_sidebar.js +++ b/assets/js/hooks/_sidebar.js @@ -1,3 +1,18 @@ +import { BackpexPreferences } from './_preferences' + +// Sidebar state is persisted both to the cookie (for fresh connects) and to +// sessionStorage (for live_redirects). LiveView freezes the session at +// websocket-connect time, so a re-mount after `live_redirect` reads a stale +// cookie and the server re-renders the shell from its default. The +// sessionStorage mirror keeps the user's client-side choices authoritative +// until the next fresh connect re-seeds from the cookie. +// +// The mirror is handled by BackpexPreferences.get/set with +// `mirror: 'session'` — 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 state for mobile and desktop and handles sidebar section expand/collapse. * @@ -5,7 +20,6 @@ * 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"])', @@ -18,13 +32,26 @@ export default { // 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) + // 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 below. this.mobileOpen = false - this.desktopOpen = this.loadDesktopState() + this.desktopOpen = BackpexPreferences.get( + 'global.sidebar_open', + this.el.dataset.sidebarOpen === 'true' + ) // 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() + // Client-authoritative section state. Populated per-section from the + // sessionStorage mirror in initializeSections(); unknown sections fall + // back to the server-rendered data-section-open there. Seeding from + // sessionStorage is what lets section state survive the hook re-mount + // LiveView performs on live_redirect between LiveViews (the + // websocket-frozen session the server re-renders from is stale, see + // the top-of-file comment). + this._sectionStates = {} // 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. @@ -57,14 +84,18 @@ export default { document.addEventListener('keydown', this._onKeydown) - // Initialize sidebar sections + // Initialize sidebar sections, then re-assert stored state over whatever + // the server just rendered (which may have been rendered from a stale + // session snapshot during a live_redirect). this.initializeSections() + this.applySectionStates() }, updated () { if (!this.sidebar || !this.toggleBtn) return this.applyState() this.initializeSections() + this.applySectionStates() }, destroyed () { @@ -73,6 +104,10 @@ export default { 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') + const sections = this.el.querySelectorAll('[data-section-id]') sections.forEach((section) => { const toggle = section.querySelector('[data-menu-dropdown-toggle]') @@ -91,7 +126,9 @@ export default { handleToggle () { if (this.isDesktop()) { this.desktopOpen = !this.desktopOpen - this.saveDesktopState() + // mirror: 'session' writes sessionStorage first, then POSTs to the + // cookie for the next fresh connect. + BackpexPreferences.set('global.sidebar_open', this.desktopOpen, { mirror: 'session' }) } else { if (!this.mobileOpen) this.previousFocus = document.activeElement this.mobileOpen = !this.mobileOpen @@ -100,16 +137,6 @@ export default { 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 @@ -202,6 +229,10 @@ export default { 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) }, // Sidebar Sections @@ -210,27 +241,30 @@ export default { 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]') + // Hide sections without content if (!this.hasContent(content)) { - content.style.display = 'none' + section.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') + // Prefer the sessionStorage mirror over the server-rendered attribute + // the first time we see a section: on a fresh websocket connect the + // cookie is authoritative (and the mirror matches), but on a re-mount + // after live_redirect the server re-rendered from a stale session + // snapshot and the mirror is the only source of the user's intent. + const id = section.dataset.sectionId + if (!(id in this._sectionStates)) { + this._sectionStates[id] = BackpexPreferences.get( + `global.sidebar_section.${id}`, + section.dataset.sectionOpen === 'true' + ) + } + const previous = this._sectionHandlers.get(toggle) if (previous) toggle.removeEventListener('click', previous) const handler = (e) => this.handleSectionToggle(e) @@ -239,6 +273,23 @@ export default { }) }, + // Re-apply the authoritative client-side open/closed state to the DOM. + // Called from updated() to overwrite whatever the server just rendered from + // a potentially-stale session snapshot after a live_redirect. + applySectionStates () { + for (const [id, open] of Object.entries(this._sectionStates)) { + const section = this.el.querySelector(`[data-section-id="${id}"]`) + if (!section) continue + const toggle = section.querySelector('[data-menu-dropdown-toggle]') + const content = section.querySelector('[data-menu-dropdown-content]') + if (!toggle || !content) continue + toggle.classList.toggle('menu-dropdown-show', open) + toggle.setAttribute('aria-expanded', String(open)) + content.style.display = open ? '' : 'none' + section.dataset.sectionOpen = String(open) + } + }, + hasContent (element) { if (!element || element.children.length === 0) return false for (const child of element.children) { @@ -263,6 +314,18 @@ export default { const isNowOpen = toggle.classList.contains('menu-dropdown-show') toggle.setAttribute('aria-expanded', isNowOpen.toString()) - localStorage.setItem(`sidebar-section-${sectionId}`, isNowOpen) + // Keep the data attribute in sync so future reconciliations read back + // the current user-intended state. + section.dataset.sectionOpen = String(isNowOpen) + this._sectionStates[sectionId] = isNowOpen + // Mirror the per-section boolean to sessionStorage (for live_redirect + // re-mounts) and POST it to the cookie (for the next fresh connect). + // The per-section key matches the flat form the server stores so + // Backpex.Preferences.get_map/3 can reconstruct the nested map. + BackpexPreferences.set( + `global.sidebar_section.${sectionId}`, + isNowOpen, + { mirror: 'session' } + ) } } diff --git a/assets/js/hooks/_theme_selector.js b/assets/js/hooks/_theme_selector.js index 94eb0fb95..455b342c1 100644 --- a/assets/js/hooks/_theme_selector.js +++ b/assets/js/hooks/_theme_selector.js @@ -1,56 +1,52 @@ +import { BackpexPreferences } from './_preferences' + /** * Hook for selecting a theme. + * + * Mounted on the inner `