/** * Balanced Breakfast — URL Query State Helper (F3 cross-cutting rule) * * Mirrors filter, sort, and view-mode state into `location.search` so reload * and deep-link restore the user's view. Filter state must not live only in * the DOM or in module-level JS. * * Usage: * * // Read * const tag = BB.queryState.get('tag'); // string|null * const tags = BB.queryState.getAll('tag'); // string[] * * // Write (replaces by default; pass {push:true} to add a history entry) * BB.queryState.set('tag', 'rust'); * BB.queryState.set('tag', null); // remove * BB.queryState.setMany({tag:'rust', sort:'newest'}); * * // Subscribe to changes (popstate, back/forward, programmatic set) * const off = BB.queryState.subscribe('tag', value => { ... }); * * // Bootstrap a surface * BB.queryState.init({tag: 'all', sort: 'newest'}); * * The helper is purely a URL-layer primitive — it does NOT trigger renders. * Per-surface code subscribes to the keys it cares about and triggers its * own render in the subscriber. */ (function() { 'use strict'; /** Map of key → Set for change subscriptions. */ const subscribers = new Map(); /** Last-known value per key, used to compute deltas on popstate. */ const lastSeen = new Map(); function readParams() { return new URLSearchParams(window.location.search); } function writeParams(params, opts) { const url = new URL(window.location.href); url.search = params.toString(); const target = url.pathname + (url.search ? url.search : '') + url.hash; if (opts && opts.push) { window.history.pushState({}, '', target); } else { window.history.replaceState({}, '', target); } } function get(key) { return readParams().get(key); } function getAll(key) { return readParams().getAll(key); } function set(key, value, opts) { const params = readParams(); if (value == null || value === '') { params.delete(key); } else if (Array.isArray(value)) { params.delete(key); for (const v of value) params.append(key, v); } else { params.set(key, value); } writeParams(params, opts); notify(key); } function setMany(map, opts) { const params = readParams(); for (const [k, v] of Object.entries(map)) { if (v == null || v === '') params.delete(k); else if (Array.isArray(v)) { params.delete(k); for (const item of v) params.append(k, item); } else params.set(k, v); } writeParams(params, opts); for (const k of Object.keys(map)) notify(k); } function subscribe(key, fn) { if (!subscribers.has(key)) subscribers.set(key, new Set()); subscribers.get(key).add(fn); // Seed lastSeen so popstate diffs work. lastSeen.set(key, get(key)); return () => { const set = subscribers.get(key); if (set) set.delete(fn); }; } function notify(key) { const value = get(key); lastSeen.set(key, value); const subs = subscribers.get(key); if (subs) for (const fn of subs) { try { fn(value); } catch (e) { console.error('[queryState] subscriber error for ' + key + ':', e); } } } /** * Initialise a surface's defaults. If the URL has no value for a key, * the default is written without pushing a history entry. Existing * URL values are left alone (the user navigated here for a reason). */ function init(defaults) { const params = readParams(); let changed = false; for (const [k, v] of Object.entries(defaults || {})) { if (!params.has(k) && v != null && v !== '') { params.set(k, v); changed = true; } } if (changed) writeParams(params, { push: false }); } // popstate fires on back/forward — re-notify every key whose value changed. window.addEventListener('popstate', () => { for (const key of subscribers.keys()) { const next = get(key); if (next !== lastSeen.get(key)) notify(key); } }); BB.queryState = { get, getAll, set, setMany, subscribe, init, }; })();