Skip to main content

max / balanced_breakfast

4.4 KB · 143 lines History Blame Raw
1 /**
2 * Balanced Breakfast — URL Query State Helper (F3 cross-cutting rule)
3 *
4 * Mirrors filter, sort, and view-mode state into `location.search` so reload
5 * and deep-link restore the user's view. Filter state must not live only in
6 * the DOM or in module-level JS.
7 *
8 * Usage:
9 *
10 * // Read
11 * const tag = BB.queryState.get('tag'); // string|null
12 * const tags = BB.queryState.getAll('tag'); // string[]
13 *
14 * // Write (replaces by default; pass {push:true} to add a history entry)
15 * BB.queryState.set('tag', 'rust');
16 * BB.queryState.set('tag', null); // remove
17 * BB.queryState.setMany({tag:'rust', sort:'newest'});
18 *
19 * // Subscribe to changes (popstate, back/forward, programmatic set)
20 * const off = BB.queryState.subscribe('tag', value => { ... });
21 *
22 * // Bootstrap a surface
23 * BB.queryState.init({tag: 'all', sort: 'newest'});
24 *
25 * The helper is purely a URL-layer primitive — it does NOT trigger renders.
26 * Per-surface code subscribes to the keys it cares about and triggers its
27 * own render in the subscriber.
28 */
29 (function() {
30 'use strict';
31
32 /** Map of key → Set<callback> for change subscriptions. */
33 const subscribers = new Map();
34
35 /** Last-known value per key, used to compute deltas on popstate. */
36 const lastSeen = new Map();
37
38 function readParams() {
39 return new URLSearchParams(window.location.search);
40 }
41
42 function writeParams(params, opts) {
43 const url = new URL(window.location.href);
44 url.search = params.toString();
45 const target = url.pathname + (url.search ? url.search : '') + url.hash;
46 if (opts && opts.push) {
47 window.history.pushState({}, '', target);
48 } else {
49 window.history.replaceState({}, '', target);
50 }
51 }
52
53 function get(key) {
54 return readParams().get(key);
55 }
56
57 function getAll(key) {
58 return readParams().getAll(key);
59 }
60
61 function set(key, value, opts) {
62 const params = readParams();
63 if (value == null || value === '') {
64 params.delete(key);
65 } else if (Array.isArray(value)) {
66 params.delete(key);
67 for (const v of value) params.append(key, v);
68 } else {
69 params.set(key, value);
70 }
71 writeParams(params, opts);
72 notify(key);
73 }
74
75 function setMany(map, opts) {
76 const params = readParams();
77 for (const [k, v] of Object.entries(map)) {
78 if (v == null || v === '') params.delete(k);
79 else if (Array.isArray(v)) {
80 params.delete(k);
81 for (const item of v) params.append(k, item);
82 } else params.set(k, v);
83 }
84 writeParams(params, opts);
85 for (const k of Object.keys(map)) notify(k);
86 }
87
88 function subscribe(key, fn) {
89 if (!subscribers.has(key)) subscribers.set(key, new Set());
90 subscribers.get(key).add(fn);
91 // Seed lastSeen so popstate diffs work.
92 lastSeen.set(key, get(key));
93 return () => {
94 const set = subscribers.get(key);
95 if (set) set.delete(fn);
96 };
97 }
98
99 function notify(key) {
100 const value = get(key);
101 lastSeen.set(key, value);
102 const subs = subscribers.get(key);
103 if (subs) for (const fn of subs) {
104 try { fn(value); }
105 catch (e) { console.error('[queryState] subscriber error for ' + key + ':', e); }
106 }
107 }
108
109 /**
110 * Initialise a surface's defaults. If the URL has no value for a key,
111 * the default is written without pushing a history entry. Existing
112 * URL values are left alone (the user navigated here for a reason).
113 */
114 function init(defaults) {
115 const params = readParams();
116 let changed = false;
117 for (const [k, v] of Object.entries(defaults || {})) {
118 if (!params.has(k) && v != null && v !== '') {
119 params.set(k, v);
120 changed = true;
121 }
122 }
123 if (changed) writeParams(params, { push: false });
124 }
125
126 // popstate fires on back/forward — re-notify every key whose value changed.
127 window.addEventListener('popstate', () => {
128 for (const key of subscribers.keys()) {
129 const next = get(key);
130 if (next !== lastSeen.get(key)) notify(key);
131 }
132 });
133
134 BB.queryState = {
135 get,
136 getAll,
137 set,
138 setMany,
139 subscribe,
140 init,
141 };
142 })();
143