@benqoder/beam 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1574 -0
- package/dist/DrawerFrame.d.ts +16 -0
- package/dist/DrawerFrame.d.ts.map +1 -0
- package/dist/DrawerFrame.js +12 -0
- package/dist/ModalFrame.d.ts +12 -0
- package/dist/ModalFrame.d.ts.map +1 -0
- package/dist/ModalFrame.js +8 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1847 -0
- package/dist/collect.d.ts +68 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/collect.js +90 -0
- package/dist/createBeam.d.ts +104 -0
- package/dist/createBeam.d.ts.map +1 -0
- package/dist/createBeam.js +421 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +7 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/vite.d.ts +72 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +79 -0
- package/package.json +62 -0
- package/src/beam.css +288 -0
- package/src/virtual-beam.d.ts +4 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1847 @@
|
|
|
1
|
+
import { Idiomorph } from 'idiomorph';
|
|
2
|
+
import { newWebSocketRpcSession } from 'capnweb';
|
|
3
|
+
// ============ BEAM - capnweb RPC Client ============
|
|
4
|
+
//
|
|
5
|
+
// Uses capnweb for:
|
|
6
|
+
// - Promise pipelining (multiple calls in one round-trip)
|
|
7
|
+
// - Bidirectional RPC (server can call client callbacks)
|
|
8
|
+
// - Automatic reconnection
|
|
9
|
+
// - Type-safe method calls
|
|
10
|
+
// Get endpoint from meta tag or default to /beam
|
|
11
|
+
// Usage: <meta name="beam-endpoint" content="/custom-endpoint">
|
|
12
|
+
function getEndpoint() {
|
|
13
|
+
const meta = document.querySelector('meta[name="beam-endpoint"]');
|
|
14
|
+
return meta?.getAttribute('content') ?? '/beam';
|
|
15
|
+
}
|
|
16
|
+
let isOnline = navigator.onLine;
|
|
17
|
+
let rpcSession = null;
|
|
18
|
+
let connectingPromise = null;
|
|
19
|
+
// Client callback handler for server-initiated updates
|
|
20
|
+
function handleServerEvent(event, data) {
|
|
21
|
+
// Dispatch custom event for app to handle
|
|
22
|
+
window.dispatchEvent(new CustomEvent('beam:server-event', { detail: { event, data } }));
|
|
23
|
+
// Built-in handlers
|
|
24
|
+
if (event === 'toast') {
|
|
25
|
+
const { message, type } = data;
|
|
26
|
+
showToast(message, type || 'success');
|
|
27
|
+
}
|
|
28
|
+
else if (event === 'refresh') {
|
|
29
|
+
const { selector } = data;
|
|
30
|
+
// Could trigger a refresh of specific elements
|
|
31
|
+
window.dispatchEvent(new CustomEvent('beam:refresh', { detail: { selector } }));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function connect() {
|
|
35
|
+
if (connectingPromise) {
|
|
36
|
+
return connectingPromise;
|
|
37
|
+
}
|
|
38
|
+
if (rpcSession) {
|
|
39
|
+
return Promise.resolve(rpcSession);
|
|
40
|
+
}
|
|
41
|
+
connectingPromise = new Promise((resolve, reject) => {
|
|
42
|
+
try {
|
|
43
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
44
|
+
const endpoint = getEndpoint();
|
|
45
|
+
const url = `${protocol}//${location.host}${endpoint}`;
|
|
46
|
+
// Create capnweb RPC session with BeamServer type
|
|
47
|
+
const session = newWebSocketRpcSession(url);
|
|
48
|
+
// Register client callback for bidirectional communication
|
|
49
|
+
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
50
|
+
session.registerCallback?.(handleServerEvent)?.catch?.(() => {
|
|
51
|
+
// Server may not support callbacks, that's ok
|
|
52
|
+
});
|
|
53
|
+
rpcSession = session;
|
|
54
|
+
connectingPromise = null;
|
|
55
|
+
resolve(session);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
connectingPromise = null;
|
|
59
|
+
reject(err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return connectingPromise;
|
|
63
|
+
}
|
|
64
|
+
async function ensureConnected() {
|
|
65
|
+
if (rpcSession) {
|
|
66
|
+
return rpcSession;
|
|
67
|
+
}
|
|
68
|
+
return connect();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Execute a script string safely
|
|
72
|
+
*/
|
|
73
|
+
function executeScript(code) {
|
|
74
|
+
try {
|
|
75
|
+
new Function(code)();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
console.error('[beam] Script execution error:', err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// API wrapper that ensures connection before calls
|
|
82
|
+
const api = {
|
|
83
|
+
async call(action, data = {}) {
|
|
84
|
+
const session = await ensureConnected();
|
|
85
|
+
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
86
|
+
return session.call(action, data);
|
|
87
|
+
},
|
|
88
|
+
async modal(modalId, params = {}) {
|
|
89
|
+
const session = await ensureConnected();
|
|
90
|
+
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
91
|
+
return session.modal(modalId, params);
|
|
92
|
+
},
|
|
93
|
+
async drawer(drawerId, params = {}) {
|
|
94
|
+
const session = await ensureConnected();
|
|
95
|
+
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
96
|
+
return session.drawer(drawerId, params);
|
|
97
|
+
},
|
|
98
|
+
// Direct access to RPC session for advanced usage (promise pipelining, etc.)
|
|
99
|
+
async getSession() {
|
|
100
|
+
return ensureConnected();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
// ============ DOM HELPERS ============
|
|
104
|
+
let activeModal = null;
|
|
105
|
+
let activeDrawer = null;
|
|
106
|
+
function $(selector) {
|
|
107
|
+
return document.querySelector(selector);
|
|
108
|
+
}
|
|
109
|
+
function $$(selector) {
|
|
110
|
+
return document.querySelectorAll(selector);
|
|
111
|
+
}
|
|
112
|
+
function morph(target, html, options) {
|
|
113
|
+
// Handle beam-keep elements
|
|
114
|
+
const keepSelectors = options?.keepElements || [];
|
|
115
|
+
const keptElements = new Map();
|
|
116
|
+
// Preserve elements marked with beam-keep
|
|
117
|
+
target.querySelectorAll('[beam-keep]').forEach((el) => {
|
|
118
|
+
const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
|
|
119
|
+
if (!el.id)
|
|
120
|
+
el.id = id;
|
|
121
|
+
const placeholder = document.createComment(`beam-keep:${id}`);
|
|
122
|
+
el.parentNode?.insertBefore(placeholder, el);
|
|
123
|
+
keptElements.set(id, { el, placeholder });
|
|
124
|
+
el.remove();
|
|
125
|
+
});
|
|
126
|
+
// Also handle explicitly specified keep selectors
|
|
127
|
+
keepSelectors.forEach((selector) => {
|
|
128
|
+
target.querySelectorAll(selector).forEach((el) => {
|
|
129
|
+
const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
|
|
130
|
+
if (!el.id)
|
|
131
|
+
el.id = id;
|
|
132
|
+
if (!keptElements.has(id)) {
|
|
133
|
+
const placeholder = document.createComment(`beam-keep:${id}`);
|
|
134
|
+
el.parentNode?.insertBefore(placeholder, el);
|
|
135
|
+
keptElements.set(id, { el, placeholder });
|
|
136
|
+
el.remove();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
// @ts-ignore - idiomorph types
|
|
141
|
+
Idiomorph.morph(target, html, { morphStyle: 'innerHTML' });
|
|
142
|
+
// Restore kept elements
|
|
143
|
+
keptElements.forEach(({ el, placeholder }, id) => {
|
|
144
|
+
// Find the placeholder or element with same ID in new content
|
|
145
|
+
const walker = document.createTreeWalker(target, NodeFilter.SHOW_COMMENT);
|
|
146
|
+
let node;
|
|
147
|
+
while ((node = walker.nextNode())) {
|
|
148
|
+
if (node.textContent === `beam-keep:${id}`) {
|
|
149
|
+
node.parentNode?.replaceChild(el, node);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// If no placeholder, look for element with same ID to replace
|
|
154
|
+
const newEl = target.querySelector(`#${id}`);
|
|
155
|
+
if (newEl) {
|
|
156
|
+
newEl.parentNode?.replaceChild(el, newEl);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function getParams(el) {
|
|
161
|
+
// Start with beam-params JSON if present
|
|
162
|
+
const params = JSON.parse(el.getAttribute('beam-params') || '{}');
|
|
163
|
+
// Collect beam-data-* attributes
|
|
164
|
+
for (const attr of el.attributes) {
|
|
165
|
+
if (attr.name.startsWith('beam-data-')) {
|
|
166
|
+
const key = attr.name.slice(10); // remove 'beam-data-'
|
|
167
|
+
// Try to parse as JSON for numbers/booleans, fallback to string
|
|
168
|
+
try {
|
|
169
|
+
params[key] = JSON.parse(attr.value);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
params[key] = attr.value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return params;
|
|
177
|
+
}
|
|
178
|
+
// ============ CONFIRMATION DIALOGS ============
|
|
179
|
+
// Usage: <button beam-action="delete" beam-confirm="Are you sure?">Delete</button>
|
|
180
|
+
// Usage: <button beam-action="delete" beam-confirm.prompt="Type DELETE to confirm|DELETE">Delete</button>
|
|
181
|
+
function checkConfirm(el) {
|
|
182
|
+
const confirmMsg = el.getAttribute('beam-confirm');
|
|
183
|
+
if (!confirmMsg)
|
|
184
|
+
return true;
|
|
185
|
+
// Check for .prompt modifier (e.g., beam-confirm.prompt="message|expected")
|
|
186
|
+
if (el.hasAttribute('beam-confirm-prompt')) {
|
|
187
|
+
const [message, expected] = (el.getAttribute('beam-confirm-prompt') || '').split('|');
|
|
188
|
+
const input = prompt(message);
|
|
189
|
+
return input === expected;
|
|
190
|
+
}
|
|
191
|
+
return confirm(confirmMsg);
|
|
192
|
+
}
|
|
193
|
+
// ============ LOADING INDICATORS ============
|
|
194
|
+
// Store active actions with their params: Map<action, Set<paramsJSON>>
|
|
195
|
+
const activeActions = new Map();
|
|
196
|
+
// Store disabled elements during request
|
|
197
|
+
const disabledElements = new Map();
|
|
198
|
+
function setLoading(el, loading, action, params) {
|
|
199
|
+
// Loading state on trigger element
|
|
200
|
+
el.toggleAttribute('beam-loading', loading);
|
|
201
|
+
// Handle beam-disable
|
|
202
|
+
if (loading && el.hasAttribute('beam-disable')) {
|
|
203
|
+
const disableSelector = el.getAttribute('beam-disable');
|
|
204
|
+
let elementsToDisable;
|
|
205
|
+
if (!disableSelector || disableSelector === '' || disableSelector === 'true') {
|
|
206
|
+
// Disable the element itself and its children
|
|
207
|
+
elementsToDisable = [el, ...Array.from(el.querySelectorAll('button, input, select, textarea'))];
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Disable specific elements by selector
|
|
211
|
+
elementsToDisable = Array.from(document.querySelectorAll(disableSelector));
|
|
212
|
+
}
|
|
213
|
+
const originalStates = elementsToDisable.map((e) => e.disabled || false);
|
|
214
|
+
elementsToDisable.forEach((e) => (e.disabled = true));
|
|
215
|
+
disabledElements.set(el, { elements: elementsToDisable, originalStates });
|
|
216
|
+
}
|
|
217
|
+
else if (!loading && disabledElements.has(el)) {
|
|
218
|
+
// Restore disabled state
|
|
219
|
+
const { elements, originalStates } = disabledElements.get(el);
|
|
220
|
+
elements.forEach((e, i) => (e.disabled = originalStates[i]));
|
|
221
|
+
disabledElements.delete(el);
|
|
222
|
+
}
|
|
223
|
+
// Legacy: disable buttons inside if no beam-disable specified
|
|
224
|
+
if (!el.hasAttribute('beam-disable')) {
|
|
225
|
+
el.querySelectorAll('button, input[type="submit"]').forEach((child) => {
|
|
226
|
+
child.disabled = loading;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Set .beam-active class on element during loading
|
|
230
|
+
el.classList.toggle('beam-active', loading);
|
|
231
|
+
// Broadcast to loading indicators
|
|
232
|
+
if (action) {
|
|
233
|
+
const paramsKey = JSON.stringify(params || {});
|
|
234
|
+
if (loading) {
|
|
235
|
+
if (!activeActions.has(action)) {
|
|
236
|
+
activeActions.set(action, new Set());
|
|
237
|
+
}
|
|
238
|
+
activeActions.get(action).add(paramsKey);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
activeActions.get(action)?.delete(paramsKey);
|
|
242
|
+
if (activeActions.get(action)?.size === 0) {
|
|
243
|
+
activeActions.delete(action);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
updateLoadingIndicators();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function getLoadingParams(el) {
|
|
250
|
+
// Start with beam-loading-params JSON if present
|
|
251
|
+
const params = JSON.parse(el.getAttribute('beam-loading-params') || '{}');
|
|
252
|
+
// Collect beam-loading-data-* attributes (override JSON params)
|
|
253
|
+
for (const attr of el.attributes) {
|
|
254
|
+
if (attr.name.startsWith('beam-loading-data-')) {
|
|
255
|
+
const key = attr.name.slice(18); // remove 'beam-loading-data-'
|
|
256
|
+
try {
|
|
257
|
+
params[key] = JSON.parse(attr.value);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
params[key] = attr.value;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return params;
|
|
265
|
+
}
|
|
266
|
+
function matchesParams(required, activeParamsSet) {
|
|
267
|
+
const requiredKeys = Object.keys(required);
|
|
268
|
+
if (requiredKeys.length === 0)
|
|
269
|
+
return true; // No params required, match any
|
|
270
|
+
for (const paramsJson of activeParamsSet) {
|
|
271
|
+
const params = JSON.parse(paramsJson);
|
|
272
|
+
const matches = requiredKeys.every((key) => String(params[key]) === String(required[key]));
|
|
273
|
+
if (matches)
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
function updateLoadingIndicators() {
|
|
279
|
+
document.querySelectorAll('[beam-loading-for]').forEach((el) => {
|
|
280
|
+
const targets = el
|
|
281
|
+
.getAttribute('beam-loading-for')
|
|
282
|
+
.split(',')
|
|
283
|
+
.map((s) => s.trim());
|
|
284
|
+
const requiredParams = getLoadingParams(el);
|
|
285
|
+
let isActive = false;
|
|
286
|
+
if (targets.includes('*')) {
|
|
287
|
+
// Match any action
|
|
288
|
+
isActive = activeActions.size > 0;
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// Match specific action(s) with optional params
|
|
292
|
+
isActive = targets.some((action) => {
|
|
293
|
+
const actionParams = activeActions.get(action);
|
|
294
|
+
return actionParams && matchesParams(requiredParams, actionParams);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Show/hide
|
|
298
|
+
if (el.hasAttribute('beam-loading-remove')) {
|
|
299
|
+
el.style.display = isActive ? 'none' : '';
|
|
300
|
+
}
|
|
301
|
+
else if (!el.hasAttribute('beam-loading-class')) {
|
|
302
|
+
el.style.display = isActive ? '' : 'none';
|
|
303
|
+
}
|
|
304
|
+
// Add/remove class
|
|
305
|
+
const loadingClass = el.getAttribute('beam-loading-class');
|
|
306
|
+
if (loadingClass) {
|
|
307
|
+
el.classList.toggle(loadingClass, isActive);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// Hide loading indicators by default on page load
|
|
312
|
+
document.querySelectorAll('[beam-loading-for]:not([beam-loading-remove]):not([beam-loading-class])').forEach((el) => {
|
|
313
|
+
el.style.display = 'none';
|
|
314
|
+
});
|
|
315
|
+
function optimistic(el) {
|
|
316
|
+
const template = el.getAttribute('beam-optimistic');
|
|
317
|
+
const targetSelector = el.getAttribute('beam-target');
|
|
318
|
+
let snapshot = null;
|
|
319
|
+
if (template && targetSelector) {
|
|
320
|
+
const targetEl = $(targetSelector);
|
|
321
|
+
if (targetEl) {
|
|
322
|
+
snapshot = targetEl.innerHTML;
|
|
323
|
+
const params = getParams(el);
|
|
324
|
+
const html = template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(params[key] ?? ''));
|
|
325
|
+
morph(targetEl, html);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
rollback() {
|
|
330
|
+
if (snapshot && targetSelector) {
|
|
331
|
+
const targetEl = $(targetSelector);
|
|
332
|
+
if (targetEl)
|
|
333
|
+
morph(targetEl, snapshot);
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function showPlaceholder(el) {
|
|
339
|
+
const placeholder = el.getAttribute('beam-placeholder');
|
|
340
|
+
const targetSelector = el.getAttribute('beam-target');
|
|
341
|
+
let snapshot = null;
|
|
342
|
+
if (placeholder && targetSelector) {
|
|
343
|
+
const targetEl = $(targetSelector);
|
|
344
|
+
if (targetEl) {
|
|
345
|
+
snapshot = targetEl.innerHTML;
|
|
346
|
+
// Check if placeholder is a selector (starts with # or .)
|
|
347
|
+
if (placeholder.startsWith('#') || placeholder.startsWith('.')) {
|
|
348
|
+
const tpl = document.querySelector(placeholder);
|
|
349
|
+
if (tpl instanceof HTMLTemplateElement) {
|
|
350
|
+
targetEl.innerHTML = tpl.innerHTML;
|
|
351
|
+
}
|
|
352
|
+
else if (tpl) {
|
|
353
|
+
targetEl.innerHTML = tpl.innerHTML;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
targetEl.innerHTML = placeholder;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
restore() {
|
|
363
|
+
if (snapshot && targetSelector) {
|
|
364
|
+
const targetEl = $(targetSelector);
|
|
365
|
+
if (targetEl)
|
|
366
|
+
targetEl.innerHTML = snapshot;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// ============ SWAP STRATEGIES ============
|
|
372
|
+
/**
|
|
373
|
+
* Deduplicate items by beam-item-id before inserting.
|
|
374
|
+
* - Updates existing items with fresh data (morphs in place)
|
|
375
|
+
* - Removes duplicates from incoming HTML (so they don't double-insert)
|
|
376
|
+
*/
|
|
377
|
+
function dedupeItems(target, html) {
|
|
378
|
+
const temp = document.createElement('div');
|
|
379
|
+
temp.innerHTML = html;
|
|
380
|
+
// Collect existing item IDs
|
|
381
|
+
const existingIds = new Set();
|
|
382
|
+
target.querySelectorAll('[beam-item-id]').forEach((el) => {
|
|
383
|
+
const id = el.getAttribute('beam-item-id');
|
|
384
|
+
if (id)
|
|
385
|
+
existingIds.add(id);
|
|
386
|
+
});
|
|
387
|
+
// Process incoming items
|
|
388
|
+
temp.querySelectorAll('[beam-item-id]').forEach((el) => {
|
|
389
|
+
const id = el.getAttribute('beam-item-id');
|
|
390
|
+
if (id && existingIds.has(id)) {
|
|
391
|
+
// Morph existing item with fresh data
|
|
392
|
+
const existing = target.querySelector(`[beam-item-id="${id}"]`);
|
|
393
|
+
if (existing) {
|
|
394
|
+
morph(existing, el.outerHTML);
|
|
395
|
+
}
|
|
396
|
+
// Remove from incoming HTML (already updated in place)
|
|
397
|
+
el.remove();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
return temp.innerHTML;
|
|
401
|
+
}
|
|
402
|
+
function swap(target, html, mode, trigger) {
|
|
403
|
+
const { main, oob } = parseOobSwaps(html);
|
|
404
|
+
switch (mode) {
|
|
405
|
+
case 'append':
|
|
406
|
+
trigger?.remove();
|
|
407
|
+
target.insertAdjacentHTML('beforeend', dedupeItems(target, main));
|
|
408
|
+
break;
|
|
409
|
+
case 'prepend':
|
|
410
|
+
trigger?.remove();
|
|
411
|
+
target.insertAdjacentHTML('afterbegin', dedupeItems(target, main));
|
|
412
|
+
break;
|
|
413
|
+
case 'replace':
|
|
414
|
+
target.innerHTML = main;
|
|
415
|
+
break;
|
|
416
|
+
case 'delete':
|
|
417
|
+
target.remove();
|
|
418
|
+
break;
|
|
419
|
+
case 'morph':
|
|
420
|
+
default:
|
|
421
|
+
morph(target, main);
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
// Out-of-band swaps
|
|
425
|
+
for (const { selector, content, swapMode } of oob) {
|
|
426
|
+
const oobTarget = $(selector);
|
|
427
|
+
if (oobTarget) {
|
|
428
|
+
if (swapMode === 'morph' || !swapMode) {
|
|
429
|
+
morph(oobTarget, content);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
swap(oobTarget, content, swapMode);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Process hungry elements - auto-update elements that match IDs in response
|
|
437
|
+
processHungryElements(html);
|
|
438
|
+
}
|
|
439
|
+
function parseOobSwaps(html) {
|
|
440
|
+
const temp = document.createElement('div');
|
|
441
|
+
temp.innerHTML = html;
|
|
442
|
+
const oob = [];
|
|
443
|
+
temp.querySelectorAll('template[beam-touch]').forEach((tpl) => {
|
|
444
|
+
const selector = tpl.getAttribute('beam-touch');
|
|
445
|
+
const swapMode = tpl.getAttribute('beam-swap') || 'morph';
|
|
446
|
+
if (selector) {
|
|
447
|
+
oob.push({ selector, content: tpl.innerHTML, swapMode });
|
|
448
|
+
}
|
|
449
|
+
tpl.remove();
|
|
450
|
+
});
|
|
451
|
+
return { main: temp.innerHTML, oob };
|
|
452
|
+
}
|
|
453
|
+
// ============ RPC WRAPPER ============
|
|
454
|
+
async function rpc(action, data, el) {
|
|
455
|
+
const targetSelector = el.getAttribute('beam-target');
|
|
456
|
+
const swapMode = el.getAttribute('beam-swap') || 'morph';
|
|
457
|
+
const opt = optimistic(el);
|
|
458
|
+
const placeholder = showPlaceholder(el);
|
|
459
|
+
setLoading(el, true, action, data);
|
|
460
|
+
try {
|
|
461
|
+
const response = await api.call(action, data);
|
|
462
|
+
// Handle redirect (if present) - takes priority
|
|
463
|
+
if (response.redirect) {
|
|
464
|
+
location.href = response.redirect;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// Handle HTML (if present)
|
|
468
|
+
if (response.html && targetSelector) {
|
|
469
|
+
const target = $(targetSelector);
|
|
470
|
+
if (target) {
|
|
471
|
+
swap(target, response.html, swapMode, el);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Execute script (if present)
|
|
475
|
+
if (response.script) {
|
|
476
|
+
executeScript(response.script);
|
|
477
|
+
}
|
|
478
|
+
// Handle history
|
|
479
|
+
handleHistory(el);
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
opt.rollback();
|
|
483
|
+
placeholder.restore();
|
|
484
|
+
showToast('Something went wrong. Please try again.', 'error');
|
|
485
|
+
console.error('RPC error:', err);
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
setLoading(el, false, action, data);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// ============ HISTORY MANAGEMENT ============
|
|
492
|
+
// Usage: <a beam-action="load" beam-push="/new-url">Link</a>
|
|
493
|
+
// Usage: <button beam-action="filter" beam-replace="?sort=name">Filter</button>
|
|
494
|
+
function handleHistory(el) {
|
|
495
|
+
const pushUrl = el.getAttribute('beam-push');
|
|
496
|
+
const replaceUrl = el.getAttribute('beam-replace');
|
|
497
|
+
if (pushUrl) {
|
|
498
|
+
history.pushState({ beam: true }, '', pushUrl);
|
|
499
|
+
}
|
|
500
|
+
else if (replaceUrl) {
|
|
501
|
+
history.replaceState({ beam: true }, '', replaceUrl);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Handle back/forward navigation
|
|
505
|
+
window.addEventListener('popstate', (e) => {
|
|
506
|
+
// Reload page on back/forward for now
|
|
507
|
+
// Could be enhanced to restore content from cache
|
|
508
|
+
if (e.state?.beam) {
|
|
509
|
+
location.reload();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
// ============ BUTTON HANDLING ============
|
|
513
|
+
// Instant click - trigger on mousedown for faster response
|
|
514
|
+
document.addEventListener('mousedown', async (e) => {
|
|
515
|
+
const target = e.target;
|
|
516
|
+
if (!target?.closest)
|
|
517
|
+
return;
|
|
518
|
+
const btn = target.closest('[beam-action][beam-instant]:not(form):not([beam-load-more]):not([beam-infinite])');
|
|
519
|
+
if (!btn || btn.tagName === 'FORM')
|
|
520
|
+
return;
|
|
521
|
+
// Skip if submit button inside a beam form
|
|
522
|
+
if (btn.closest('form[beam-action]') && btn.getAttribute('type') === 'submit')
|
|
523
|
+
return;
|
|
524
|
+
e.preventDefault();
|
|
525
|
+
// Check confirmation
|
|
526
|
+
if (!checkConfirm(btn))
|
|
527
|
+
return;
|
|
528
|
+
const action = btn.getAttribute('beam-action');
|
|
529
|
+
if (!action)
|
|
530
|
+
return;
|
|
531
|
+
const params = getParams(btn);
|
|
532
|
+
await rpc(action, params, btn);
|
|
533
|
+
if (btn.hasAttribute('beam-close')) {
|
|
534
|
+
closeModal();
|
|
535
|
+
closeDrawer();
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
// Regular click handling
|
|
539
|
+
document.addEventListener('click', async (e) => {
|
|
540
|
+
const target = e.target;
|
|
541
|
+
if (!target?.closest)
|
|
542
|
+
return;
|
|
543
|
+
const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite])');
|
|
544
|
+
if (!btn || btn.tagName === 'FORM')
|
|
545
|
+
return;
|
|
546
|
+
// Skip if submit button inside a beam form
|
|
547
|
+
if (btn.closest('form[beam-action]') && btn.getAttribute('type') === 'submit')
|
|
548
|
+
return;
|
|
549
|
+
e.preventDefault();
|
|
550
|
+
// Check confirmation
|
|
551
|
+
if (!checkConfirm(btn))
|
|
552
|
+
return;
|
|
553
|
+
const action = btn.getAttribute('beam-action');
|
|
554
|
+
if (!action)
|
|
555
|
+
return;
|
|
556
|
+
const params = getParams(btn);
|
|
557
|
+
await rpc(action, params, btn);
|
|
558
|
+
if (btn.hasAttribute('beam-close')) {
|
|
559
|
+
closeModal();
|
|
560
|
+
closeDrawer();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
// ============ MODALS ============
|
|
564
|
+
document.addEventListener('click', (e) => {
|
|
565
|
+
const target = e.target;
|
|
566
|
+
if (!target?.closest)
|
|
567
|
+
return;
|
|
568
|
+
const trigger = target.closest('[beam-modal]');
|
|
569
|
+
if (trigger) {
|
|
570
|
+
e.preventDefault();
|
|
571
|
+
// Check confirmation
|
|
572
|
+
if (!checkConfirm(trigger))
|
|
573
|
+
return;
|
|
574
|
+
const modalId = trigger.getAttribute('beam-modal');
|
|
575
|
+
const params = getParams(trigger);
|
|
576
|
+
if (modalId) {
|
|
577
|
+
openModal(modalId, params);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Close on backdrop click
|
|
581
|
+
if (target.matches?.('#modal-backdrop')) {
|
|
582
|
+
closeModal();
|
|
583
|
+
}
|
|
584
|
+
// Close button (handles both modal and drawer)
|
|
585
|
+
const closeBtn = target.closest('[beam-close]');
|
|
586
|
+
if (closeBtn && !closeBtn.hasAttribute('beam-action')) {
|
|
587
|
+
if (activeDrawer) {
|
|
588
|
+
closeDrawer();
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
closeModal();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
// Drawer triggers
|
|
596
|
+
document.addEventListener('click', (e) => {
|
|
597
|
+
const target = e.target;
|
|
598
|
+
if (!target?.closest)
|
|
599
|
+
return;
|
|
600
|
+
const trigger = target.closest('[beam-drawer]');
|
|
601
|
+
if (trigger) {
|
|
602
|
+
e.preventDefault();
|
|
603
|
+
// Check confirmation
|
|
604
|
+
if (!checkConfirm(trigger))
|
|
605
|
+
return;
|
|
606
|
+
const drawerId = trigger.getAttribute('beam-drawer');
|
|
607
|
+
const position = trigger.getAttribute('beam-position') || 'right';
|
|
608
|
+
const size = trigger.getAttribute('beam-size') || 'medium';
|
|
609
|
+
const params = getParams(trigger);
|
|
610
|
+
if (drawerId) {
|
|
611
|
+
openDrawer(drawerId, params, { position, size });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Close on backdrop click
|
|
615
|
+
if (target.matches?.('#drawer-backdrop')) {
|
|
616
|
+
closeDrawer();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
document.addEventListener('keydown', (e) => {
|
|
620
|
+
if (e.key === 'Escape') {
|
|
621
|
+
if (activeDrawer) {
|
|
622
|
+
closeDrawer();
|
|
623
|
+
}
|
|
624
|
+
else if (activeModal) {
|
|
625
|
+
closeModal();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
async function openModal(id, params = {}) {
|
|
630
|
+
try {
|
|
631
|
+
const html = await api.modal(id, params);
|
|
632
|
+
let backdrop = $('#modal-backdrop');
|
|
633
|
+
if (!backdrop) {
|
|
634
|
+
backdrop = document.createElement('div');
|
|
635
|
+
backdrop.id = 'modal-backdrop';
|
|
636
|
+
document.body.appendChild(backdrop);
|
|
637
|
+
}
|
|
638
|
+
backdrop.innerHTML = `
|
|
639
|
+
<div id="modal-content" role="dialog" aria-modal="true">
|
|
640
|
+
${html}
|
|
641
|
+
</div>
|
|
642
|
+
`;
|
|
643
|
+
backdrop.offsetHeight;
|
|
644
|
+
backdrop.classList.add('open');
|
|
645
|
+
document.body.classList.add('modal-open');
|
|
646
|
+
activeModal = $('#modal-content');
|
|
647
|
+
const autoFocus = activeModal?.querySelector('[autofocus]');
|
|
648
|
+
const firstInput = activeModal?.querySelector('input, button, textarea, select');
|
|
649
|
+
(autoFocus || firstInput)?.focus();
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
showToast('Failed to open modal.', 'error');
|
|
653
|
+
console.error('Modal error:', err);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function closeModal() {
|
|
657
|
+
const backdrop = $('#modal-backdrop');
|
|
658
|
+
if (backdrop) {
|
|
659
|
+
backdrop.classList.remove('open');
|
|
660
|
+
setTimeout(() => {
|
|
661
|
+
backdrop.innerHTML = '';
|
|
662
|
+
}, 200);
|
|
663
|
+
}
|
|
664
|
+
document.body.classList.remove('modal-open');
|
|
665
|
+
activeModal = null;
|
|
666
|
+
}
|
|
667
|
+
async function openDrawer(id, params = {}, options) {
|
|
668
|
+
try {
|
|
669
|
+
const html = await api.drawer(id, params);
|
|
670
|
+
let backdrop = $('#drawer-backdrop');
|
|
671
|
+
if (!backdrop) {
|
|
672
|
+
backdrop = document.createElement('div');
|
|
673
|
+
backdrop.id = 'drawer-backdrop';
|
|
674
|
+
document.body.appendChild(backdrop);
|
|
675
|
+
}
|
|
676
|
+
// Set position and size as data attributes for CSS styling
|
|
677
|
+
const { position, size } = options;
|
|
678
|
+
backdrop.innerHTML = `
|
|
679
|
+
<div id="drawer-content" role="dialog" aria-modal="true" data-position="${position}" data-size="${size}">
|
|
680
|
+
${html}
|
|
681
|
+
</div>
|
|
682
|
+
`;
|
|
683
|
+
backdrop.offsetHeight; // Force reflow
|
|
684
|
+
backdrop.classList.add('open');
|
|
685
|
+
document.body.classList.add('drawer-open');
|
|
686
|
+
activeDrawer = $('#drawer-content');
|
|
687
|
+
const autoFocus = activeDrawer?.querySelector('[autofocus]');
|
|
688
|
+
const firstInput = activeDrawer?.querySelector('input, button, textarea, select');
|
|
689
|
+
(autoFocus || firstInput)?.focus();
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
showToast('Failed to open drawer.', 'error');
|
|
693
|
+
console.error('Drawer error:', err);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
function closeDrawer() {
|
|
697
|
+
const backdrop = $('#drawer-backdrop');
|
|
698
|
+
if (backdrop) {
|
|
699
|
+
backdrop.classList.remove('open');
|
|
700
|
+
setTimeout(() => {
|
|
701
|
+
backdrop.innerHTML = '';
|
|
702
|
+
}, 200);
|
|
703
|
+
}
|
|
704
|
+
document.body.classList.remove('drawer-open');
|
|
705
|
+
activeDrawer = null;
|
|
706
|
+
}
|
|
707
|
+
// ============ TOAST NOTIFICATIONS ============
|
|
708
|
+
function showToast(message, type = 'success') {
|
|
709
|
+
let container = $('#toast-container');
|
|
710
|
+
if (!container) {
|
|
711
|
+
container = document.createElement('div');
|
|
712
|
+
container.id = 'toast-container';
|
|
713
|
+
document.body.appendChild(container);
|
|
714
|
+
}
|
|
715
|
+
const toast = document.createElement('div');
|
|
716
|
+
toast.className = `toast toast-${type}`;
|
|
717
|
+
toast.textContent = message;
|
|
718
|
+
toast.setAttribute('role', 'alert');
|
|
719
|
+
container.appendChild(toast);
|
|
720
|
+
requestAnimationFrame(() => {
|
|
721
|
+
toast.classList.add('show');
|
|
722
|
+
});
|
|
723
|
+
setTimeout(() => {
|
|
724
|
+
toast.classList.remove('show');
|
|
725
|
+
setTimeout(() => toast.remove(), 300);
|
|
726
|
+
}, 3000);
|
|
727
|
+
}
|
|
728
|
+
// ============ OFFLINE DETECTION ============
|
|
729
|
+
// Usage: <div beam-offline>You are offline</div>
|
|
730
|
+
// Usage: <button beam-action="save" beam-offline-disable>Save</button>
|
|
731
|
+
function updateOfflineState() {
|
|
732
|
+
isOnline = navigator.onLine;
|
|
733
|
+
// Show/hide offline indicators
|
|
734
|
+
document.querySelectorAll('[beam-offline]').forEach((el) => {
|
|
735
|
+
const showClass = el.getAttribute('beam-offline-class');
|
|
736
|
+
if (showClass) {
|
|
737
|
+
el.classList.toggle(showClass, !isOnline);
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
el.style.display = isOnline ? 'none' : '';
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
// Disable/enable elements when offline
|
|
744
|
+
document.querySelectorAll('[beam-offline-disable]').forEach((el) => {
|
|
745
|
+
;
|
|
746
|
+
el.disabled = !isOnline;
|
|
747
|
+
});
|
|
748
|
+
// Add/remove body class
|
|
749
|
+
document.body.classList.toggle('beam-offline', !isOnline);
|
|
750
|
+
}
|
|
751
|
+
window.addEventListener('online', updateOfflineState);
|
|
752
|
+
window.addEventListener('offline', updateOfflineState);
|
|
753
|
+
// Initialize offline state
|
|
754
|
+
updateOfflineState();
|
|
755
|
+
// ============ NAVIGATION FEEDBACK ============
|
|
756
|
+
// Usage: <nav beam-nav><a href="/home">Home</a></nav>
|
|
757
|
+
// Links get .beam-current when they match current URL
|
|
758
|
+
function updateNavigation() {
|
|
759
|
+
const currentPath = location.pathname;
|
|
760
|
+
const currentUrl = location.href;
|
|
761
|
+
document.querySelectorAll('[beam-nav] a, a[beam-nav]').forEach((link) => {
|
|
762
|
+
const href = link.getAttribute('href');
|
|
763
|
+
if (!href)
|
|
764
|
+
return;
|
|
765
|
+
// Check if link matches current path
|
|
766
|
+
const linkUrl = new URL(href, location.origin);
|
|
767
|
+
const isExact = linkUrl.pathname === currentPath;
|
|
768
|
+
const isPartial = currentPath.startsWith(linkUrl.pathname) && linkUrl.pathname !== '/';
|
|
769
|
+
// Exact match or partial match (for section highlighting)
|
|
770
|
+
const isCurrent = link.hasAttribute('beam-nav-exact') ? isExact : isExact || isPartial;
|
|
771
|
+
link.classList.toggle('beam-current', isCurrent);
|
|
772
|
+
if (isCurrent) {
|
|
773
|
+
link.setAttribute('aria-current', 'page');
|
|
774
|
+
}
|
|
775
|
+
else {
|
|
776
|
+
link.removeAttribute('aria-current');
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
// Update navigation on page load and history changes
|
|
781
|
+
updateNavigation();
|
|
782
|
+
window.addEventListener('popstate', updateNavigation);
|
|
783
|
+
// ============ CONDITIONAL SHOW/HIDE (beam-switch) ============
|
|
784
|
+
// Usage: <select name="type" beam-switch=".type-options">
|
|
785
|
+
// <option value="a">A</option>
|
|
786
|
+
// <option value="b">B</option>
|
|
787
|
+
// </select>
|
|
788
|
+
// <div class="type-options" beam-show-for="a">Options for A</div>
|
|
789
|
+
// <div class="type-options" beam-show-for="b">Options for B</div>
|
|
790
|
+
function setupSwitch(el) {
|
|
791
|
+
const targetSelector = el.getAttribute('beam-switch');
|
|
792
|
+
const event = el.getAttribute('beam-switch-event') || 'input';
|
|
793
|
+
const updateTargets = () => {
|
|
794
|
+
const value = el.value;
|
|
795
|
+
// Find targets within the switch region or document
|
|
796
|
+
const region = el.closest('[beam-switch-region]') || el.closest('form') || document;
|
|
797
|
+
region.querySelectorAll(targetSelector).forEach((target) => {
|
|
798
|
+
const showFor = target.getAttribute('beam-show-for');
|
|
799
|
+
const hideFor = target.getAttribute('beam-hide-for');
|
|
800
|
+
const enableFor = target.getAttribute('beam-enable-for');
|
|
801
|
+
const disableFor = target.getAttribute('beam-disable-for');
|
|
802
|
+
// Handle show/hide
|
|
803
|
+
if (showFor !== null) {
|
|
804
|
+
const values = showFor.split(',').map((v) => v.trim());
|
|
805
|
+
const shouldShow = values.includes(value) || (showFor === '' && value !== '');
|
|
806
|
+
target.style.display = shouldShow ? '' : 'none';
|
|
807
|
+
}
|
|
808
|
+
if (hideFor !== null) {
|
|
809
|
+
const values = hideFor.split(',').map((v) => v.trim());
|
|
810
|
+
const shouldHide = values.includes(value);
|
|
811
|
+
target.style.display = shouldHide ? 'none' : '';
|
|
812
|
+
}
|
|
813
|
+
// Handle enable/disable
|
|
814
|
+
if (enableFor !== null) {
|
|
815
|
+
const values = enableFor.split(',').map((v) => v.trim());
|
|
816
|
+
const shouldEnable = values.includes(value);
|
|
817
|
+
target.disabled = !shouldEnable;
|
|
818
|
+
}
|
|
819
|
+
if (disableFor !== null) {
|
|
820
|
+
const values = disableFor.split(',').map((v) => v.trim());
|
|
821
|
+
const shouldDisable = values.includes(value);
|
|
822
|
+
target.disabled = shouldDisable;
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
el.addEventListener(event, updateTargets);
|
|
827
|
+
// Initial state
|
|
828
|
+
updateTargets();
|
|
829
|
+
}
|
|
830
|
+
// Observe switch elements
|
|
831
|
+
const switchObserver = new MutationObserver(() => {
|
|
832
|
+
document.querySelectorAll('[beam-switch]:not([beam-switch-observed])').forEach((el) => {
|
|
833
|
+
el.setAttribute('beam-switch-observed', '');
|
|
834
|
+
setupSwitch(el);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
switchObserver.observe(document.body, { childList: true, subtree: true });
|
|
838
|
+
// Initialize existing switch elements
|
|
839
|
+
document.querySelectorAll('[beam-switch]').forEach((el) => {
|
|
840
|
+
el.setAttribute('beam-switch-observed', '');
|
|
841
|
+
setupSwitch(el);
|
|
842
|
+
});
|
|
843
|
+
// ============ AUTO-SUBMIT FORMS ============
|
|
844
|
+
// Usage: <form beam-action="filter" beam-autosubmit beam-debounce="300">
|
|
845
|
+
function setupAutosubmit(form) {
|
|
846
|
+
const debounce = parseInt(form.getAttribute('beam-debounce') || '300', 10);
|
|
847
|
+
const event = form.getAttribute('beam-autosubmit-event') || 'input';
|
|
848
|
+
let timeout;
|
|
849
|
+
const submitForm = () => {
|
|
850
|
+
clearTimeout(timeout);
|
|
851
|
+
timeout = setTimeout(() => {
|
|
852
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
853
|
+
}, debounce);
|
|
854
|
+
};
|
|
855
|
+
form.querySelectorAll('input, select, textarea').forEach((input) => {
|
|
856
|
+
input.addEventListener(event, submitForm);
|
|
857
|
+
// Also listen to change for selects and checkboxes
|
|
858
|
+
if (input.tagName === 'SELECT' || input.type === 'checkbox' || input.type === 'radio') {
|
|
859
|
+
input.addEventListener('change', submitForm);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
// Observe autosubmit forms
|
|
864
|
+
const autosubmitObserver = new MutationObserver(() => {
|
|
865
|
+
document.querySelectorAll('form[beam-autosubmit]:not([beam-autosubmit-observed])').forEach((form) => {
|
|
866
|
+
form.setAttribute('beam-autosubmit-observed', '');
|
|
867
|
+
setupAutosubmit(form);
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
autosubmitObserver.observe(document.body, { childList: true, subtree: true });
|
|
871
|
+
// Initialize existing autosubmit forms
|
|
872
|
+
document.querySelectorAll('form[beam-autosubmit]').forEach((form) => {
|
|
873
|
+
form.setAttribute('beam-autosubmit-observed', '');
|
|
874
|
+
setupAutosubmit(form);
|
|
875
|
+
});
|
|
876
|
+
// ============ BOOST LINKS ============
|
|
877
|
+
// Usage: <main beam-boost>...all links become AJAX...</main>
|
|
878
|
+
// Usage: <a href="/page" beam-boost>Single boosted link</a>
|
|
879
|
+
document.addEventListener('click', async (e) => {
|
|
880
|
+
const target = e.target;
|
|
881
|
+
if (!target?.closest)
|
|
882
|
+
return;
|
|
883
|
+
// Check if click is on a link within a boosted container or a boosted link itself
|
|
884
|
+
const link = target.closest('a[href]');
|
|
885
|
+
if (!link)
|
|
886
|
+
return;
|
|
887
|
+
const isBoosted = link.hasAttribute('beam-boost') || link.closest('[beam-boost]');
|
|
888
|
+
if (!isBoosted)
|
|
889
|
+
return;
|
|
890
|
+
// Skip if explicitly not boosted
|
|
891
|
+
if (link.hasAttribute('beam-boost-off'))
|
|
892
|
+
return;
|
|
893
|
+
// Skip external links
|
|
894
|
+
if (link.host !== location.host)
|
|
895
|
+
return;
|
|
896
|
+
// Skip if modifier keys or non-left click
|
|
897
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
|
|
898
|
+
return;
|
|
899
|
+
// Skip if target="_blank"
|
|
900
|
+
if (link.target === '_blank')
|
|
901
|
+
return;
|
|
902
|
+
// Skip if download link
|
|
903
|
+
if (link.hasAttribute('download'))
|
|
904
|
+
return;
|
|
905
|
+
e.preventDefault();
|
|
906
|
+
// Check confirmation
|
|
907
|
+
if (!checkConfirm(link))
|
|
908
|
+
return;
|
|
909
|
+
const href = link.href;
|
|
910
|
+
const targetSelector = link.getAttribute('beam-target') || 'body';
|
|
911
|
+
const swapMode = link.getAttribute('beam-swap') || 'morph';
|
|
912
|
+
// Show placeholder if specified
|
|
913
|
+
const placeholder = showPlaceholder(link);
|
|
914
|
+
link.classList.add('beam-active');
|
|
915
|
+
try {
|
|
916
|
+
// Fetch the page
|
|
917
|
+
const response = await fetch(href, {
|
|
918
|
+
headers: { 'X-Beam-Boost': 'true' },
|
|
919
|
+
});
|
|
920
|
+
const html = await response.text();
|
|
921
|
+
// Parse response and extract target content
|
|
922
|
+
const parser = new DOMParser();
|
|
923
|
+
const doc = parser.parseFromString(html, 'text/html');
|
|
924
|
+
// Get content from target selector
|
|
925
|
+
const sourceEl = doc.querySelector(targetSelector);
|
|
926
|
+
if (sourceEl) {
|
|
927
|
+
const target = $(targetSelector);
|
|
928
|
+
if (target) {
|
|
929
|
+
swap(target, sourceEl.innerHTML, swapMode);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// Update title
|
|
933
|
+
const title = doc.querySelector('title');
|
|
934
|
+
if (title) {
|
|
935
|
+
document.title = title.textContent || '';
|
|
936
|
+
}
|
|
937
|
+
// Push to history
|
|
938
|
+
if (!link.hasAttribute('beam-replace')) {
|
|
939
|
+
history.pushState({ beam: true, url: href }, '', href);
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
history.replaceState({ beam: true, url: href }, '', href);
|
|
943
|
+
}
|
|
944
|
+
// Update navigation state
|
|
945
|
+
updateNavigation();
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
placeholder.restore();
|
|
949
|
+
// Fallback to normal navigation
|
|
950
|
+
console.error('Boost error, falling back to navigation:', err);
|
|
951
|
+
location.href = href;
|
|
952
|
+
}
|
|
953
|
+
finally {
|
|
954
|
+
link.classList.remove('beam-active');
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
const SCROLL_STATE_KEY_PREFIX = 'beam_scroll_';
|
|
958
|
+
const SCROLL_STATE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
959
|
+
function getScrollStateKey(action) {
|
|
960
|
+
return SCROLL_STATE_KEY_PREFIX + location.pathname + location.search + '_' + action;
|
|
961
|
+
}
|
|
962
|
+
function saveScrollState(targetSelector, action) {
|
|
963
|
+
const target = $(targetSelector);
|
|
964
|
+
if (!target)
|
|
965
|
+
return;
|
|
966
|
+
const state = {
|
|
967
|
+
html: target.innerHTML,
|
|
968
|
+
scrollY: window.scrollY,
|
|
969
|
+
timestamp: Date.now(),
|
|
970
|
+
};
|
|
971
|
+
try {
|
|
972
|
+
sessionStorage.setItem(getScrollStateKey(action), JSON.stringify(state));
|
|
973
|
+
}
|
|
974
|
+
catch (e) {
|
|
975
|
+
// sessionStorage might be full or disabled
|
|
976
|
+
console.warn('[beam] Could not save scroll state:', e);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function restoreScrollState() {
|
|
980
|
+
// Find infinite scroll or load more container
|
|
981
|
+
const sentinel = document.querySelector('[beam-infinite], [beam-load-more]');
|
|
982
|
+
if (!sentinel)
|
|
983
|
+
return false;
|
|
984
|
+
const action = sentinel.getAttribute('beam-action');
|
|
985
|
+
const targetSelector = sentinel.getAttribute('beam-target');
|
|
986
|
+
if (!action || !targetSelector)
|
|
987
|
+
return false;
|
|
988
|
+
const key = getScrollStateKey(action);
|
|
989
|
+
const stored = sessionStorage.getItem(key);
|
|
990
|
+
if (!stored)
|
|
991
|
+
return false;
|
|
992
|
+
try {
|
|
993
|
+
const state = JSON.parse(stored);
|
|
994
|
+
// Check if state is expired
|
|
995
|
+
if (Date.now() - state.timestamp > SCROLL_STATE_TTL) {
|
|
996
|
+
sessionStorage.removeItem(key);
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
const target = $(targetSelector);
|
|
1000
|
+
if (!target)
|
|
1001
|
+
return false;
|
|
1002
|
+
// Disable browser's automatic scroll restoration
|
|
1003
|
+
if ('scrollRestoration' in history) {
|
|
1004
|
+
history.scrollRestoration = 'manual';
|
|
1005
|
+
}
|
|
1006
|
+
// Capture fresh server-rendered content before replacing
|
|
1007
|
+
const freshHtml = target.innerHTML;
|
|
1008
|
+
const freshContainer = document.createElement('div');
|
|
1009
|
+
freshContainer.innerHTML = freshHtml;
|
|
1010
|
+
// Hide content before restoring to prevent jump
|
|
1011
|
+
target.style.opacity = '0';
|
|
1012
|
+
target.style.transition = 'opacity 0.15s ease-out';
|
|
1013
|
+
// Restore cached content (has all pages)
|
|
1014
|
+
target.innerHTML = state.html;
|
|
1015
|
+
// Morph fresh server data over cached data (server takes precedence)
|
|
1016
|
+
// Match elements by beam-item-id attribute
|
|
1017
|
+
freshContainer.querySelectorAll('[beam-item-id]').forEach((freshEl) => {
|
|
1018
|
+
const itemId = freshEl.getAttribute('beam-item-id');
|
|
1019
|
+
const cachedEl = target.querySelector(`[beam-item-id="${itemId}"]`);
|
|
1020
|
+
if (cachedEl) {
|
|
1021
|
+
morph(cachedEl, freshEl.outerHTML);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
// Also match by id attribute as fallback
|
|
1025
|
+
freshContainer.querySelectorAll('[id]').forEach((freshEl) => {
|
|
1026
|
+
const cachedEl = target.querySelector(`#${freshEl.id}`);
|
|
1027
|
+
if (cachedEl && !freshEl.hasAttribute('beam-item-id')) {
|
|
1028
|
+
morph(cachedEl, freshEl.outerHTML);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
// Restore scroll position and fade in
|
|
1032
|
+
requestAnimationFrame(() => {
|
|
1033
|
+
window.scrollTo(0, state.scrollY);
|
|
1034
|
+
requestAnimationFrame(() => {
|
|
1035
|
+
target.style.opacity = '1';
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
1038
|
+
// Re-observe any new sentinels in restored content
|
|
1039
|
+
target.querySelectorAll('[beam-infinite]:not([beam-observed])').forEach((el) => {
|
|
1040
|
+
el.setAttribute('beam-observed', '');
|
|
1041
|
+
infiniteObserver.observe(el);
|
|
1042
|
+
});
|
|
1043
|
+
// Don't clear state here - it persists until refresh or new content is loaded
|
|
1044
|
+
// State is cleared in tryRestoreScrollState() when navType is not 'back_forward'
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
catch (e) {
|
|
1048
|
+
console.warn('[beam] Could not restore scroll state:', e);
|
|
1049
|
+
sessionStorage.removeItem(key);
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
// Save scroll position when navigating away (for back button restoration)
|
|
1054
|
+
window.addEventListener('pagehide', () => {
|
|
1055
|
+
// Find any infinite scroll or load more element to get the target and action
|
|
1056
|
+
const sentinel = document.querySelector('[beam-infinite], [beam-load-more]');
|
|
1057
|
+
if (!sentinel)
|
|
1058
|
+
return;
|
|
1059
|
+
const action = sentinel.getAttribute('beam-action');
|
|
1060
|
+
const targetSelector = sentinel.getAttribute('beam-target');
|
|
1061
|
+
if (!action || !targetSelector)
|
|
1062
|
+
return;
|
|
1063
|
+
// Update the saved state with current scroll position
|
|
1064
|
+
const key = getScrollStateKey(action);
|
|
1065
|
+
const stored = sessionStorage.getItem(key);
|
|
1066
|
+
if (!stored)
|
|
1067
|
+
return;
|
|
1068
|
+
try {
|
|
1069
|
+
const state = JSON.parse(stored);
|
|
1070
|
+
state.scrollY = window.scrollY;
|
|
1071
|
+
state.timestamp = Date.now();
|
|
1072
|
+
sessionStorage.setItem(key, JSON.stringify(state));
|
|
1073
|
+
}
|
|
1074
|
+
catch (e) {
|
|
1075
|
+
// Ignore errors
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
// Track the target selector for saving state
|
|
1079
|
+
let infiniteScrollTarget = null;
|
|
1080
|
+
const infiniteObserver = new IntersectionObserver(async (entries) => {
|
|
1081
|
+
for (const entry of entries) {
|
|
1082
|
+
if (!entry.isIntersecting)
|
|
1083
|
+
continue;
|
|
1084
|
+
const sentinel = entry.target;
|
|
1085
|
+
if (sentinel.hasAttribute('beam-loading'))
|
|
1086
|
+
continue;
|
|
1087
|
+
const action = sentinel.getAttribute('beam-action');
|
|
1088
|
+
const targetSelector = sentinel.getAttribute('beam-target');
|
|
1089
|
+
const swapMode = sentinel.getAttribute('beam-swap') || 'append';
|
|
1090
|
+
if (!action || !targetSelector)
|
|
1091
|
+
continue;
|
|
1092
|
+
// Track target for state saving
|
|
1093
|
+
infiniteScrollTarget = targetSelector;
|
|
1094
|
+
// Check confirmation
|
|
1095
|
+
if (!checkConfirm(sentinel))
|
|
1096
|
+
continue;
|
|
1097
|
+
const params = getParams(sentinel);
|
|
1098
|
+
sentinel.setAttribute('beam-loading', '');
|
|
1099
|
+
sentinel.classList.add('loading');
|
|
1100
|
+
setLoading(sentinel, true, action, params);
|
|
1101
|
+
try {
|
|
1102
|
+
const response = await api.call(action, params);
|
|
1103
|
+
const target = $(targetSelector);
|
|
1104
|
+
if (target && response.html) {
|
|
1105
|
+
swap(target, response.html, swapMode, sentinel);
|
|
1106
|
+
// Save scroll state after content is loaded
|
|
1107
|
+
requestAnimationFrame(() => {
|
|
1108
|
+
saveScrollState(targetSelector, action);
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
// Execute script if present
|
|
1112
|
+
if (response.script) {
|
|
1113
|
+
executeScript(response.script);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
catch (err) {
|
|
1117
|
+
console.error('Infinite scroll error:', err);
|
|
1118
|
+
sentinel.removeAttribute('beam-loading');
|
|
1119
|
+
sentinel.classList.remove('loading');
|
|
1120
|
+
sentinel.classList.add('error');
|
|
1121
|
+
}
|
|
1122
|
+
finally {
|
|
1123
|
+
setLoading(sentinel, false, action, params);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}, { rootMargin: '200px' });
|
|
1127
|
+
// Observe sentinels (now and future)
|
|
1128
|
+
new MutationObserver(() => {
|
|
1129
|
+
document.querySelectorAll('[beam-infinite]:not([beam-observed])').forEach((el) => {
|
|
1130
|
+
el.setAttribute('beam-observed', '');
|
|
1131
|
+
infiniteObserver.observe(el);
|
|
1132
|
+
});
|
|
1133
|
+
}).observe(document.body, { childList: true, subtree: true });
|
|
1134
|
+
document.querySelectorAll('[beam-infinite]').forEach((el) => {
|
|
1135
|
+
el.setAttribute('beam-observed', '');
|
|
1136
|
+
infiniteObserver.observe(el);
|
|
1137
|
+
});
|
|
1138
|
+
// ============ LOAD MORE (Click-based) ============
|
|
1139
|
+
// Usage: <button beam-load-more beam-action="loadMore" beam-params='{"page":2}' beam-target="#list">Load More</button>
|
|
1140
|
+
document.addEventListener('click', async (e) => {
|
|
1141
|
+
const target = e.target;
|
|
1142
|
+
if (!target?.closest)
|
|
1143
|
+
return;
|
|
1144
|
+
const trigger = target.closest('[beam-load-more]');
|
|
1145
|
+
if (!trigger)
|
|
1146
|
+
return;
|
|
1147
|
+
e.preventDefault();
|
|
1148
|
+
if (trigger.hasAttribute('beam-loading'))
|
|
1149
|
+
return;
|
|
1150
|
+
const action = trigger.getAttribute('beam-action');
|
|
1151
|
+
const targetSelector = trigger.getAttribute('beam-target');
|
|
1152
|
+
const swapMode = trigger.getAttribute('beam-swap') || 'append';
|
|
1153
|
+
if (!action || !targetSelector)
|
|
1154
|
+
return;
|
|
1155
|
+
// Check confirmation
|
|
1156
|
+
if (!checkConfirm(trigger))
|
|
1157
|
+
return;
|
|
1158
|
+
const params = getParams(trigger);
|
|
1159
|
+
trigger.setAttribute('beam-loading', '');
|
|
1160
|
+
trigger.classList.add('loading');
|
|
1161
|
+
setLoading(trigger, true, action, params);
|
|
1162
|
+
try {
|
|
1163
|
+
const response = await api.call(action, params);
|
|
1164
|
+
const targetEl = $(targetSelector);
|
|
1165
|
+
if (targetEl && response.html) {
|
|
1166
|
+
swap(targetEl, response.html, swapMode, trigger);
|
|
1167
|
+
// Save scroll state after content is loaded
|
|
1168
|
+
requestAnimationFrame(() => {
|
|
1169
|
+
saveScrollState(targetSelector, action);
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
// Execute script if present
|
|
1173
|
+
if (response.script) {
|
|
1174
|
+
executeScript(response.script);
|
|
1175
|
+
}
|
|
1176
|
+
// Handle history
|
|
1177
|
+
handleHistory(trigger);
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
console.error('Load more error:', err);
|
|
1181
|
+
trigger.removeAttribute('beam-loading');
|
|
1182
|
+
trigger.classList.remove('loading');
|
|
1183
|
+
trigger.classList.add('error');
|
|
1184
|
+
showToast('Failed to load more. Please try again.', 'error');
|
|
1185
|
+
}
|
|
1186
|
+
finally {
|
|
1187
|
+
setLoading(trigger, false, action, params);
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
// Restore scroll state on page load (for back navigation only, not refresh)
|
|
1191
|
+
function tryRestoreScrollState() {
|
|
1192
|
+
// Only restore on back/forward navigation, not on refresh or direct navigation
|
|
1193
|
+
const navEntry = performance.getEntriesByType('navigation')[0];
|
|
1194
|
+
const navType = navEntry?.type;
|
|
1195
|
+
// 'back_forward' = back/forward button, 'reload' = refresh, 'navigate' = direct navigation
|
|
1196
|
+
if (navType !== 'back_forward') {
|
|
1197
|
+
// Clear scroll state on refresh or direct navigation
|
|
1198
|
+
clearScrollState();
|
|
1199
|
+
// Disable browser's automatic scroll restoration and scroll to top
|
|
1200
|
+
if ('scrollRestoration' in history) {
|
|
1201
|
+
history.scrollRestoration = 'manual';
|
|
1202
|
+
}
|
|
1203
|
+
window.scrollTo(0, 0);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (document.querySelector('[beam-infinite], [beam-load-more]')) {
|
|
1207
|
+
restoreScrollState();
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// Restore scroll state when DOM is ready
|
|
1211
|
+
if (document.readyState === 'loading') {
|
|
1212
|
+
document.addEventListener('DOMContentLoaded', tryRestoreScrollState);
|
|
1213
|
+
}
|
|
1214
|
+
else {
|
|
1215
|
+
tryRestoreScrollState();
|
|
1216
|
+
}
|
|
1217
|
+
const cache = new Map();
|
|
1218
|
+
const preloading = new Set();
|
|
1219
|
+
function getCacheKey(action, params) {
|
|
1220
|
+
return `${action}:${JSON.stringify(params)}`;
|
|
1221
|
+
}
|
|
1222
|
+
function parseCacheDuration(duration) {
|
|
1223
|
+
const match = duration.match(/^(\d+)(s|m|h)?$/);
|
|
1224
|
+
if (!match)
|
|
1225
|
+
return 0;
|
|
1226
|
+
const value = parseInt(match[1], 10);
|
|
1227
|
+
const unit = match[2] || 's';
|
|
1228
|
+
switch (unit) {
|
|
1229
|
+
case 'm':
|
|
1230
|
+
return value * 60 * 1000;
|
|
1231
|
+
case 'h':
|
|
1232
|
+
return value * 60 * 60 * 1000;
|
|
1233
|
+
default:
|
|
1234
|
+
return value * 1000;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
async function fetchWithCache(action, params, cacheDuration) {
|
|
1238
|
+
const key = getCacheKey(action, params);
|
|
1239
|
+
// Check cache
|
|
1240
|
+
const cached = cache.get(key);
|
|
1241
|
+
if (cached && cached.expires > Date.now()) {
|
|
1242
|
+
return cached.response;
|
|
1243
|
+
}
|
|
1244
|
+
// Fetch fresh
|
|
1245
|
+
const response = await api.call(action, params);
|
|
1246
|
+
// Store in cache if duration specified
|
|
1247
|
+
if (cacheDuration) {
|
|
1248
|
+
const duration = parseCacheDuration(cacheDuration);
|
|
1249
|
+
if (duration > 0) {
|
|
1250
|
+
cache.set(key, { response, expires: Date.now() + duration });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return response;
|
|
1254
|
+
}
|
|
1255
|
+
async function preload(el) {
|
|
1256
|
+
const action = el.getAttribute('beam-action');
|
|
1257
|
+
if (!action)
|
|
1258
|
+
return;
|
|
1259
|
+
const params = getParams(el);
|
|
1260
|
+
const key = getCacheKey(action, params);
|
|
1261
|
+
// Skip if already cached or preloading
|
|
1262
|
+
if (cache.has(key) || preloading.has(key))
|
|
1263
|
+
return;
|
|
1264
|
+
preloading.add(key);
|
|
1265
|
+
try {
|
|
1266
|
+
const response = await api.call(action, params);
|
|
1267
|
+
// Cache for 30 seconds by default for preloaded content
|
|
1268
|
+
cache.set(key, { response, expires: Date.now() + 30000 });
|
|
1269
|
+
}
|
|
1270
|
+
catch {
|
|
1271
|
+
// Silently fail preload
|
|
1272
|
+
}
|
|
1273
|
+
finally {
|
|
1274
|
+
preloading.delete(key);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
// Preload on hover
|
|
1278
|
+
document.addEventListener('mouseenter', (e) => {
|
|
1279
|
+
const target = e.target;
|
|
1280
|
+
if (!target?.closest)
|
|
1281
|
+
return;
|
|
1282
|
+
const el = target.closest('[beam-preload][beam-action]');
|
|
1283
|
+
if (el) {
|
|
1284
|
+
preload(el);
|
|
1285
|
+
}
|
|
1286
|
+
}, true);
|
|
1287
|
+
// Preload on touchstart for mobile
|
|
1288
|
+
document.addEventListener('touchstart', (e) => {
|
|
1289
|
+
const target = e.target;
|
|
1290
|
+
if (!target?.closest)
|
|
1291
|
+
return;
|
|
1292
|
+
const el = target.closest('[beam-preload][beam-action]');
|
|
1293
|
+
if (el) {
|
|
1294
|
+
preload(el);
|
|
1295
|
+
}
|
|
1296
|
+
}, { passive: true });
|
|
1297
|
+
// Clear cache utility
|
|
1298
|
+
function clearCache(action) {
|
|
1299
|
+
if (action) {
|
|
1300
|
+
for (const key of cache.keys()) {
|
|
1301
|
+
if (key.startsWith(action + ':')) {
|
|
1302
|
+
cache.delete(key);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
else {
|
|
1307
|
+
cache.clear();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// ============ PROGRESSIVE ENHANCEMENT ============
|
|
1311
|
+
// Links with href fallback to full page navigation if JS fails
|
|
1312
|
+
// Usage: <a href="/products/1" beam-action="getProduct" beam-target="#main">View</a>
|
|
1313
|
+
document.addEventListener('click', async (e) => {
|
|
1314
|
+
const target = e.target;
|
|
1315
|
+
if (!target?.closest)
|
|
1316
|
+
return;
|
|
1317
|
+
const link = target.closest('a[beam-action][href]:not([beam-instant])');
|
|
1318
|
+
if (!link)
|
|
1319
|
+
return;
|
|
1320
|
+
// Let normal navigation happen if:
|
|
1321
|
+
// - Meta/Ctrl key held (new tab)
|
|
1322
|
+
// - Middle click
|
|
1323
|
+
// - Link has target="_blank"
|
|
1324
|
+
if (e.metaKey || e.ctrlKey || e.button !== 0 || link.target === '_blank')
|
|
1325
|
+
return;
|
|
1326
|
+
e.preventDefault();
|
|
1327
|
+
// Check confirmation
|
|
1328
|
+
if (!checkConfirm(link))
|
|
1329
|
+
return;
|
|
1330
|
+
const action = link.getAttribute('beam-action');
|
|
1331
|
+
if (!action)
|
|
1332
|
+
return;
|
|
1333
|
+
const params = getParams(link);
|
|
1334
|
+
const cacheDuration = link.getAttribute('beam-cache');
|
|
1335
|
+
// Use cached result if available
|
|
1336
|
+
const key = getCacheKey(action, params);
|
|
1337
|
+
const cached = cache.get(key);
|
|
1338
|
+
const targetSelector = link.getAttribute('beam-target');
|
|
1339
|
+
const swapMode = link.getAttribute('beam-swap') || 'morph';
|
|
1340
|
+
// Show placeholder
|
|
1341
|
+
const placeholder = showPlaceholder(link);
|
|
1342
|
+
setLoading(link, true, action, params);
|
|
1343
|
+
try {
|
|
1344
|
+
const response = cached && cached.expires > Date.now() ? cached.response : await fetchWithCache(action, params, cacheDuration || undefined);
|
|
1345
|
+
// Handle redirect (if present) - takes priority
|
|
1346
|
+
if (response.redirect) {
|
|
1347
|
+
location.href = response.redirect;
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
// Handle HTML (if present)
|
|
1351
|
+
if (response.html && targetSelector) {
|
|
1352
|
+
const target = $(targetSelector);
|
|
1353
|
+
if (target) {
|
|
1354
|
+
swap(target, response.html, swapMode, link);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// Execute script (if present)
|
|
1358
|
+
if (response.script) {
|
|
1359
|
+
executeScript(response.script);
|
|
1360
|
+
}
|
|
1361
|
+
// Handle history
|
|
1362
|
+
handleHistory(link);
|
|
1363
|
+
// Update navigation
|
|
1364
|
+
updateNavigation();
|
|
1365
|
+
}
|
|
1366
|
+
catch (err) {
|
|
1367
|
+
placeholder.restore();
|
|
1368
|
+
// Fallback to normal navigation on error
|
|
1369
|
+
console.error('Beam link error, falling back to navigation:', err);
|
|
1370
|
+
location.href = link.href;
|
|
1371
|
+
}
|
|
1372
|
+
finally {
|
|
1373
|
+
setLoading(link, false, action, params);
|
|
1374
|
+
}
|
|
1375
|
+
}, true);
|
|
1376
|
+
// ============ FORM HANDLING ============
|
|
1377
|
+
// Pure RPC forms - no traditional POST
|
|
1378
|
+
// Usage: <form beam-action="createProduct" beam-target="#result">
|
|
1379
|
+
document.addEventListener('submit', async (e) => {
|
|
1380
|
+
const target = e.target;
|
|
1381
|
+
if (!target?.closest)
|
|
1382
|
+
return;
|
|
1383
|
+
const form = target.closest('form[beam-action]');
|
|
1384
|
+
if (!form)
|
|
1385
|
+
return;
|
|
1386
|
+
e.preventDefault();
|
|
1387
|
+
// Check confirmation
|
|
1388
|
+
if (!checkConfirm(form))
|
|
1389
|
+
return;
|
|
1390
|
+
const action = form.getAttribute('beam-action');
|
|
1391
|
+
if (!action)
|
|
1392
|
+
return;
|
|
1393
|
+
const data = Object.fromEntries(new FormData(form));
|
|
1394
|
+
const targetSelector = form.getAttribute('beam-target');
|
|
1395
|
+
const swapMode = form.getAttribute('beam-swap') || 'morph';
|
|
1396
|
+
// Show placeholder
|
|
1397
|
+
const placeholder = showPlaceholder(form);
|
|
1398
|
+
setLoading(form, true, action, data);
|
|
1399
|
+
try {
|
|
1400
|
+
const response = await api.call(action, data);
|
|
1401
|
+
// Handle redirect (if present) - takes priority
|
|
1402
|
+
if (response.redirect) {
|
|
1403
|
+
location.href = response.redirect;
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
// Handle HTML (if present)
|
|
1407
|
+
if (response.html && targetSelector) {
|
|
1408
|
+
const target = $(targetSelector);
|
|
1409
|
+
if (target) {
|
|
1410
|
+
swap(target, response.html, swapMode);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
// Execute script (if present)
|
|
1414
|
+
if (response.script) {
|
|
1415
|
+
executeScript(response.script);
|
|
1416
|
+
}
|
|
1417
|
+
if (form.hasAttribute('beam-reset')) {
|
|
1418
|
+
form.reset();
|
|
1419
|
+
}
|
|
1420
|
+
if (form.hasAttribute('beam-close')) {
|
|
1421
|
+
closeModal();
|
|
1422
|
+
}
|
|
1423
|
+
// Handle history
|
|
1424
|
+
handleHistory(form);
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
placeholder.restore();
|
|
1428
|
+
console.error('Beam form error:', err);
|
|
1429
|
+
showToast('Something went wrong. Please try again.', 'error');
|
|
1430
|
+
}
|
|
1431
|
+
finally {
|
|
1432
|
+
setLoading(form, false, action, data);
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
// ============ REAL-TIME VALIDATION ============
|
|
1436
|
+
// Usage: <input name="email" beam-validate="#email-errors" beam-watch="input" beam-debounce="300">
|
|
1437
|
+
function setupValidation(el) {
|
|
1438
|
+
const event = el.getAttribute('beam-watch') || 'change';
|
|
1439
|
+
const debounce = parseInt(el.getAttribute('beam-debounce') || '300', 10);
|
|
1440
|
+
const targetSelector = el.getAttribute('beam-validate');
|
|
1441
|
+
let timeout;
|
|
1442
|
+
el.addEventListener(event, () => {
|
|
1443
|
+
clearTimeout(timeout);
|
|
1444
|
+
timeout = setTimeout(async () => {
|
|
1445
|
+
const form = el.closest('form');
|
|
1446
|
+
if (!form)
|
|
1447
|
+
return;
|
|
1448
|
+
const action = form.getAttribute('beam-action');
|
|
1449
|
+
if (!action)
|
|
1450
|
+
return;
|
|
1451
|
+
const fieldName = el.getAttribute('name');
|
|
1452
|
+
if (!fieldName)
|
|
1453
|
+
return;
|
|
1454
|
+
const formData = Object.fromEntries(new FormData(form));
|
|
1455
|
+
const data = { ...formData, _validate: fieldName };
|
|
1456
|
+
try {
|
|
1457
|
+
const response = await api.call(action, data);
|
|
1458
|
+
if (response.html) {
|
|
1459
|
+
const target = $(targetSelector);
|
|
1460
|
+
if (target) {
|
|
1461
|
+
morph(target, response.html);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
// Execute script (if present)
|
|
1465
|
+
if (response.script) {
|
|
1466
|
+
executeScript(response.script);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
catch (err) {
|
|
1470
|
+
console.error('Validation error:', err);
|
|
1471
|
+
}
|
|
1472
|
+
}, debounce);
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
// Observe validation elements (current and future)
|
|
1476
|
+
const validationObserver = new MutationObserver(() => {
|
|
1477
|
+
document.querySelectorAll('[beam-validate]:not([beam-validation-observed])').forEach((el) => {
|
|
1478
|
+
el.setAttribute('beam-validation-observed', '');
|
|
1479
|
+
setupValidation(el);
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
validationObserver.observe(document.body, { childList: true, subtree: true });
|
|
1483
|
+
// Initialize existing validation elements
|
|
1484
|
+
document.querySelectorAll('[beam-validate]').forEach((el) => {
|
|
1485
|
+
el.setAttribute('beam-validation-observed', '');
|
|
1486
|
+
setupValidation(el);
|
|
1487
|
+
});
|
|
1488
|
+
// ============ DEFERRED LOADING ============
|
|
1489
|
+
// Usage: <div beam-defer beam-action="loadComments" beam-target="#comments">Loading...</div>
|
|
1490
|
+
const deferObserver = new IntersectionObserver(async (entries) => {
|
|
1491
|
+
for (const entry of entries) {
|
|
1492
|
+
if (!entry.isIntersecting)
|
|
1493
|
+
continue;
|
|
1494
|
+
const el = entry.target;
|
|
1495
|
+
if (el.hasAttribute('beam-defer-loaded'))
|
|
1496
|
+
continue;
|
|
1497
|
+
el.setAttribute('beam-defer-loaded', '');
|
|
1498
|
+
deferObserver.unobserve(el);
|
|
1499
|
+
const action = el.getAttribute('beam-action');
|
|
1500
|
+
if (!action)
|
|
1501
|
+
continue;
|
|
1502
|
+
const params = getParams(el);
|
|
1503
|
+
const targetSelector = el.getAttribute('beam-target');
|
|
1504
|
+
const swapMode = el.getAttribute('beam-swap') || 'morph';
|
|
1505
|
+
setLoading(el, true, action, params);
|
|
1506
|
+
try {
|
|
1507
|
+
const response = await api.call(action, params);
|
|
1508
|
+
if (response.html) {
|
|
1509
|
+
const target = targetSelector ? $(targetSelector) : el;
|
|
1510
|
+
if (target) {
|
|
1511
|
+
swap(target, response.html, swapMode);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// Execute script (if present)
|
|
1515
|
+
if (response.script) {
|
|
1516
|
+
executeScript(response.script);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
catch (err) {
|
|
1520
|
+
console.error('Defer error:', err);
|
|
1521
|
+
}
|
|
1522
|
+
finally {
|
|
1523
|
+
setLoading(el, false, action, params);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}, { rootMargin: '100px' });
|
|
1527
|
+
// Observe defer elements (current and future)
|
|
1528
|
+
const deferMutationObserver = new MutationObserver(() => {
|
|
1529
|
+
document.querySelectorAll('[beam-defer]:not([beam-defer-observed])').forEach((el) => {
|
|
1530
|
+
el.setAttribute('beam-defer-observed', '');
|
|
1531
|
+
deferObserver.observe(el);
|
|
1532
|
+
});
|
|
1533
|
+
});
|
|
1534
|
+
deferMutationObserver.observe(document.body, { childList: true, subtree: true });
|
|
1535
|
+
// Initialize existing defer elements
|
|
1536
|
+
document.querySelectorAll('[beam-defer]').forEach((el) => {
|
|
1537
|
+
el.setAttribute('beam-defer-observed', '');
|
|
1538
|
+
deferObserver.observe(el);
|
|
1539
|
+
});
|
|
1540
|
+
// ============ POLLING ============
|
|
1541
|
+
// Usage: <div beam-poll beam-interval="5000" beam-action="getStatus" beam-target="#status">...</div>
|
|
1542
|
+
const pollingElements = new Map();
|
|
1543
|
+
function startPolling(el) {
|
|
1544
|
+
if (pollingElements.has(el))
|
|
1545
|
+
return;
|
|
1546
|
+
const interval = parseInt(el.getAttribute('beam-interval') || '5000', 10);
|
|
1547
|
+
const action = el.getAttribute('beam-action');
|
|
1548
|
+
if (!action)
|
|
1549
|
+
return;
|
|
1550
|
+
const poll = async () => {
|
|
1551
|
+
// Stop if element is no longer in DOM
|
|
1552
|
+
if (!document.body.contains(el)) {
|
|
1553
|
+
stopPolling(el);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
// Skip if offline
|
|
1557
|
+
if (!isOnline)
|
|
1558
|
+
return;
|
|
1559
|
+
const params = getParams(el);
|
|
1560
|
+
const targetSelector = el.getAttribute('beam-target');
|
|
1561
|
+
const swapMode = el.getAttribute('beam-swap') || 'morph';
|
|
1562
|
+
try {
|
|
1563
|
+
const response = await api.call(action, params);
|
|
1564
|
+
if (response.html) {
|
|
1565
|
+
const target = targetSelector ? $(targetSelector) : el;
|
|
1566
|
+
if (target) {
|
|
1567
|
+
swap(target, response.html, swapMode);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Execute script (if present)
|
|
1571
|
+
if (response.script) {
|
|
1572
|
+
executeScript(response.script);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
catch (err) {
|
|
1576
|
+
console.error('Poll error:', err);
|
|
1577
|
+
}
|
|
1578
|
+
};
|
|
1579
|
+
const timerId = setInterval(poll, interval);
|
|
1580
|
+
pollingElements.set(el, timerId);
|
|
1581
|
+
// Initial poll immediately (unless beam-poll-delay is set)
|
|
1582
|
+
if (!el.hasAttribute('beam-poll-delay')) {
|
|
1583
|
+
poll();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
function stopPolling(el) {
|
|
1587
|
+
const timerId = pollingElements.get(el);
|
|
1588
|
+
if (timerId) {
|
|
1589
|
+
clearInterval(timerId);
|
|
1590
|
+
pollingElements.delete(el);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
// Observe polling elements (current and future)
|
|
1594
|
+
const pollMutationObserver = new MutationObserver(() => {
|
|
1595
|
+
document.querySelectorAll('[beam-poll]:not([beam-poll-observed])').forEach((el) => {
|
|
1596
|
+
el.setAttribute('beam-poll-observed', '');
|
|
1597
|
+
startPolling(el);
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
pollMutationObserver.observe(document.body, { childList: true, subtree: true });
|
|
1601
|
+
// Initialize existing polling elements
|
|
1602
|
+
document.querySelectorAll('[beam-poll]').forEach((el) => {
|
|
1603
|
+
el.setAttribute('beam-poll-observed', '');
|
|
1604
|
+
startPolling(el);
|
|
1605
|
+
});
|
|
1606
|
+
// ============ HUNGRY AUTO-REFRESH ============
|
|
1607
|
+
// Usage: <span id="cart-count" beam-hungry>0</span>
|
|
1608
|
+
// When any RPC response contains an element with id="cart-count", it auto-updates
|
|
1609
|
+
function processHungryElements(html) {
|
|
1610
|
+
const temp = document.createElement('div');
|
|
1611
|
+
temp.innerHTML = html;
|
|
1612
|
+
// Find hungry elements on the page
|
|
1613
|
+
document.querySelectorAll('[beam-hungry]').forEach((hungry) => {
|
|
1614
|
+
const id = hungry.id;
|
|
1615
|
+
if (!id)
|
|
1616
|
+
return;
|
|
1617
|
+
// Look for matching element in response
|
|
1618
|
+
const fresh = temp.querySelector(`#${id}`);
|
|
1619
|
+
if (fresh) {
|
|
1620
|
+
morph(hungry, fresh.innerHTML);
|
|
1621
|
+
}
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
// ============ CLIENT-SIDE UI STATE (Alpine.js Replacement) ============
|
|
1625
|
+
// Toggle, dropdown, collapse utilities that don't require server round-trips
|
|
1626
|
+
// === TOGGLE ===
|
|
1627
|
+
// Usage: <button beam-toggle="#menu">Menu</button>
|
|
1628
|
+
// <div id="menu" beam-hidden>Content</div>
|
|
1629
|
+
document.addEventListener('click', (e) => {
|
|
1630
|
+
const target = e.target;
|
|
1631
|
+
if (!target?.closest)
|
|
1632
|
+
return;
|
|
1633
|
+
const trigger = target.closest('[beam-toggle]');
|
|
1634
|
+
if (trigger) {
|
|
1635
|
+
e.preventDefault();
|
|
1636
|
+
const selector = trigger.getAttribute('beam-toggle');
|
|
1637
|
+
const targetEl = document.querySelector(selector);
|
|
1638
|
+
if (targetEl) {
|
|
1639
|
+
const isHidden = targetEl.hasAttribute('beam-hidden');
|
|
1640
|
+
if (isHidden) {
|
|
1641
|
+
targetEl.removeAttribute('beam-hidden');
|
|
1642
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
1643
|
+
// Handle transition
|
|
1644
|
+
if (targetEl.hasAttribute('beam-transition')) {
|
|
1645
|
+
targetEl.style.display = '';
|
|
1646
|
+
// Force reflow for transition
|
|
1647
|
+
targetEl.offsetHeight;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
else {
|
|
1651
|
+
targetEl.setAttribute('beam-hidden', '');
|
|
1652
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
// === DROPDOWN (with outside-click auto-close) ===
|
|
1658
|
+
// Usage: <div beam-dropdown>
|
|
1659
|
+
// <button beam-dropdown-trigger>Account ▼</button>
|
|
1660
|
+
// <div beam-dropdown-content beam-hidden>
|
|
1661
|
+
// <a href="/profile">Profile</a>
|
|
1662
|
+
// </div>
|
|
1663
|
+
// </div>
|
|
1664
|
+
document.addEventListener('click', (e) => {
|
|
1665
|
+
const target = e.target;
|
|
1666
|
+
if (!target?.closest)
|
|
1667
|
+
return;
|
|
1668
|
+
const trigger = target.closest('[beam-dropdown-trigger]');
|
|
1669
|
+
if (trigger) {
|
|
1670
|
+
e.preventDefault();
|
|
1671
|
+
e.stopPropagation();
|
|
1672
|
+
const dropdown = trigger.closest('[beam-dropdown]');
|
|
1673
|
+
const content = dropdown?.querySelector('[beam-dropdown-content]');
|
|
1674
|
+
if (content) {
|
|
1675
|
+
const isHidden = content.hasAttribute('beam-hidden');
|
|
1676
|
+
// Close all other dropdowns first
|
|
1677
|
+
document.querySelectorAll('[beam-dropdown-content]:not([beam-hidden])').forEach((el) => {
|
|
1678
|
+
if (el !== content) {
|
|
1679
|
+
el.setAttribute('beam-hidden', '');
|
|
1680
|
+
el.closest('[beam-dropdown]')?.querySelector('[beam-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
// Toggle this dropdown
|
|
1684
|
+
if (isHidden) {
|
|
1685
|
+
content.removeAttribute('beam-hidden');
|
|
1686
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
1687
|
+
}
|
|
1688
|
+
else {
|
|
1689
|
+
content.setAttribute('beam-hidden', '');
|
|
1690
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
// Close all dropdowns on outside click (if click is not inside a dropdown content)
|
|
1696
|
+
if (!target.closest('[beam-dropdown-content]')) {
|
|
1697
|
+
document.querySelectorAll('[beam-dropdown-content]:not([beam-hidden])').forEach((el) => {
|
|
1698
|
+
el.setAttribute('beam-hidden', '');
|
|
1699
|
+
el.closest('[beam-dropdown]')?.querySelector('[beam-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
// Close dropdowns on Escape key
|
|
1704
|
+
document.addEventListener('keydown', (e) => {
|
|
1705
|
+
if (e.key === 'Escape') {
|
|
1706
|
+
document.querySelectorAll('[beam-dropdown-content]:not([beam-hidden])').forEach((el) => {
|
|
1707
|
+
el.setAttribute('beam-hidden', '');
|
|
1708
|
+
el.closest('[beam-dropdown]')?.querySelector('[beam-dropdown-trigger]')?.setAttribute('aria-expanded', 'false');
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
// === COLLAPSE with text swap ===
|
|
1713
|
+
// Usage: <button beam-collapse="#details" beam-collapse-text="Show less">Show more</button>
|
|
1714
|
+
// <div id="details" beam-collapsed>Expanded content...</div>
|
|
1715
|
+
document.addEventListener('click', (e) => {
|
|
1716
|
+
const target = e.target;
|
|
1717
|
+
if (!target?.closest)
|
|
1718
|
+
return;
|
|
1719
|
+
const trigger = target.closest('[beam-collapse]');
|
|
1720
|
+
if (trigger) {
|
|
1721
|
+
e.preventDefault();
|
|
1722
|
+
const selector = trigger.getAttribute('beam-collapse');
|
|
1723
|
+
const targetEl = document.querySelector(selector);
|
|
1724
|
+
if (targetEl) {
|
|
1725
|
+
const isCollapsed = targetEl.hasAttribute('beam-collapsed');
|
|
1726
|
+
if (isCollapsed) {
|
|
1727
|
+
targetEl.removeAttribute('beam-collapsed');
|
|
1728
|
+
trigger.setAttribute('aria-expanded', 'true');
|
|
1729
|
+
}
|
|
1730
|
+
else {
|
|
1731
|
+
targetEl.setAttribute('beam-collapsed', '');
|
|
1732
|
+
trigger.setAttribute('aria-expanded', 'false');
|
|
1733
|
+
}
|
|
1734
|
+
// Swap button text if beam-collapse-text is specified
|
|
1735
|
+
const altText = trigger.getAttribute('beam-collapse-text');
|
|
1736
|
+
if (altText) {
|
|
1737
|
+
const currentText = trigger.textContent || '';
|
|
1738
|
+
trigger.textContent = altText;
|
|
1739
|
+
trigger.setAttribute('beam-collapse-text', currentText);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
// === CLASS TOGGLE ===
|
|
1745
|
+
// Usage: <button beam-class-toggle="active" beam-class-target="#sidebar">Toggle</button>
|
|
1746
|
+
// Or toggle on self: <button beam-class-toggle="active">Toggle</button>
|
|
1747
|
+
document.addEventListener('click', (e) => {
|
|
1748
|
+
const target = e.target;
|
|
1749
|
+
if (!target?.closest)
|
|
1750
|
+
return;
|
|
1751
|
+
const trigger = target.closest('[beam-class-toggle]');
|
|
1752
|
+
if (trigger) {
|
|
1753
|
+
const className = trigger.getAttribute('beam-class-toggle');
|
|
1754
|
+
const targetSelector = trigger.getAttribute('beam-class-target');
|
|
1755
|
+
const targetEl = targetSelector ? document.querySelector(targetSelector) : trigger;
|
|
1756
|
+
if (targetEl && className) {
|
|
1757
|
+
targetEl.classList.toggle(className);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
// Clear scroll state for current page or all pages
|
|
1762
|
+
// Usage: clearScrollState() - clear all for current URL
|
|
1763
|
+
// clearScrollState('loadMore') - clear specific action
|
|
1764
|
+
// clearScrollState(true) - clear all scroll states
|
|
1765
|
+
function clearScrollState(actionOrAll) {
|
|
1766
|
+
if (actionOrAll === true) {
|
|
1767
|
+
// Clear all scroll states
|
|
1768
|
+
const keysToRemove = [];
|
|
1769
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
1770
|
+
const key = sessionStorage.key(i);
|
|
1771
|
+
if (key?.startsWith(SCROLL_STATE_KEY_PREFIX)) {
|
|
1772
|
+
keysToRemove.push(key);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
keysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
|
1776
|
+
}
|
|
1777
|
+
else if (typeof actionOrAll === 'string') {
|
|
1778
|
+
// Clear specific action's scroll state
|
|
1779
|
+
sessionStorage.removeItem(getScrollStateKey(actionOrAll));
|
|
1780
|
+
}
|
|
1781
|
+
else {
|
|
1782
|
+
// Clear all scroll states for current URL (any action)
|
|
1783
|
+
const prefix = SCROLL_STATE_KEY_PREFIX + location.pathname + location.search;
|
|
1784
|
+
const keysToRemove = [];
|
|
1785
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
1786
|
+
const key = sessionStorage.key(i);
|
|
1787
|
+
if (key?.startsWith(prefix)) {
|
|
1788
|
+
keysToRemove.push(key);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
keysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
// Base utilities that are always available on window.beam
|
|
1795
|
+
const beamUtils = {
|
|
1796
|
+
showToast,
|
|
1797
|
+
closeModal,
|
|
1798
|
+
closeDrawer,
|
|
1799
|
+
clearCache,
|
|
1800
|
+
clearScrollState,
|
|
1801
|
+
isOnline: () => isOnline,
|
|
1802
|
+
getSession: api.getSession,
|
|
1803
|
+
};
|
|
1804
|
+
// Create a Proxy that handles both utility methods and dynamic action calls
|
|
1805
|
+
window.beam = new Proxy(beamUtils, {
|
|
1806
|
+
get(target, prop) {
|
|
1807
|
+
// Return existing utility methods
|
|
1808
|
+
if (prop in target) {
|
|
1809
|
+
return target[prop];
|
|
1810
|
+
}
|
|
1811
|
+
// Return a dynamic action caller for any other property
|
|
1812
|
+
return async (data = {}, options) => {
|
|
1813
|
+
const rawResponse = await api.call(prop, data);
|
|
1814
|
+
// Normalize response: string -> {html: string}, object -> as-is
|
|
1815
|
+
const response = typeof rawResponse === 'string'
|
|
1816
|
+
? { html: rawResponse }
|
|
1817
|
+
: rawResponse;
|
|
1818
|
+
// Handle redirect (takes priority)
|
|
1819
|
+
if (response.redirect) {
|
|
1820
|
+
location.href = response.redirect;
|
|
1821
|
+
return response;
|
|
1822
|
+
}
|
|
1823
|
+
// Normalize options: string is shorthand for { target: string }
|
|
1824
|
+
const opts = typeof options === 'string'
|
|
1825
|
+
? { target: options }
|
|
1826
|
+
: (options || {});
|
|
1827
|
+
// Handle HTML swap if target provided
|
|
1828
|
+
if (response.html && opts.target) {
|
|
1829
|
+
const targetEl = document.querySelector(opts.target);
|
|
1830
|
+
if (targetEl) {
|
|
1831
|
+
swap(targetEl, response.html, opts.swap || 'morph');
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
// Execute script if present
|
|
1835
|
+
if (response.script) {
|
|
1836
|
+
executeScript(response.script);
|
|
1837
|
+
}
|
|
1838
|
+
return response;
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
});
|
|
1842
|
+
window.showToast = showToast;
|
|
1843
|
+
window.closeModal = closeModal;
|
|
1844
|
+
window.closeDrawer = closeDrawer;
|
|
1845
|
+
window.clearCache = clearCache;
|
|
1846
|
+
// Initialize capnweb RPC connection
|
|
1847
|
+
connect().catch(console.error);
|