@benqoder/beam 0.1.3 → 0.3.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/README.md +520 -90
- package/dist/client.d.ts +12 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +837 -165
- package/dist/collect.d.ts +1 -47
- package/dist/collect.d.ts.map +1 -1
- package/dist/collect.js +0 -64
- package/dist/createBeam.d.ts +5 -19
- package/dist/createBeam.d.ts.map +1 -1
- package/dist/createBeam.js +74 -54
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/types.d.ts +48 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/vite.d.ts +0 -12
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +4 -10
- package/package.json +1 -1
- package/src/beam.css +2 -0
- package/dist/DrawerFrame.d.ts +0 -16
- package/dist/DrawerFrame.d.ts.map +0 -1
- package/dist/DrawerFrame.js +0 -12
- package/dist/ModalFrame.d.ts +0 -12
- package/dist/ModalFrame.d.ts.map +0 -1
- package/dist/ModalFrame.js +0 -8
package/dist/client.js
CHANGED
|
@@ -105,16 +105,6 @@ const api = {
|
|
|
105
105
|
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
106
106
|
return session.call(action, data);
|
|
107
107
|
},
|
|
108
|
-
async modal(modalId, params = {}) {
|
|
109
|
-
const session = await ensureConnected();
|
|
110
|
-
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
111
|
-
return session.modal(modalId, params);
|
|
112
|
-
},
|
|
113
|
-
async drawer(drawerId, params = {}) {
|
|
114
|
-
const session = await ensureConnected();
|
|
115
|
-
// @ts-ignore - capnweb stub methods are dynamically typed
|
|
116
|
-
return session.drawer(drawerId, params);
|
|
117
|
-
},
|
|
118
108
|
// Direct access to RPC session for advanced usage (promise pipelining, etc.)
|
|
119
109
|
async getSession() {
|
|
120
110
|
return ensureConnected();
|
|
@@ -130,51 +120,42 @@ function $$(selector) {
|
|
|
130
120
|
return document.querySelectorAll(selector);
|
|
131
121
|
}
|
|
132
122
|
function morph(target, html, options) {
|
|
133
|
-
// Handle beam-keep elements
|
|
134
123
|
const keepSelectors = options?.keepElements || [];
|
|
135
|
-
const keptElements = new Map();
|
|
136
|
-
// Preserve elements marked with beam-keep
|
|
137
|
-
target.querySelectorAll('[beam-keep]').forEach((el) => {
|
|
138
|
-
const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
|
|
139
|
-
if (!el.id)
|
|
140
|
-
el.id = id;
|
|
141
|
-
const placeholder = document.createComment(`beam-keep:${id}`);
|
|
142
|
-
el.parentNode?.insertBefore(placeholder, el);
|
|
143
|
-
keptElements.set(id, { el, placeholder });
|
|
144
|
-
el.remove();
|
|
145
|
-
});
|
|
146
|
-
// Also handle explicitly specified keep selectors
|
|
147
|
-
keepSelectors.forEach((selector) => {
|
|
148
|
-
target.querySelectorAll(selector).forEach((el) => {
|
|
149
|
-
const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
|
|
150
|
-
if (!el.id)
|
|
151
|
-
el.id = id;
|
|
152
|
-
if (!keptElements.has(id)) {
|
|
153
|
-
const placeholder = document.createComment(`beam-keep:${id}`);
|
|
154
|
-
el.parentNode?.insertBefore(placeholder, el);
|
|
155
|
-
keptElements.set(id, { el, placeholder });
|
|
156
|
-
el.remove();
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
124
|
// @ts-ignore - idiomorph types
|
|
161
|
-
Idiomorph.morph(target, html, {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
125
|
+
Idiomorph.morph(target, html, {
|
|
126
|
+
morphStyle: 'innerHTML',
|
|
127
|
+
callbacks: {
|
|
128
|
+
// Skip morphing elements marked with beam-keep
|
|
129
|
+
beforeNodeMorphed: (fromEl, toEl) => {
|
|
130
|
+
// Only handle Element nodes
|
|
131
|
+
if (!(fromEl instanceof Element))
|
|
132
|
+
return true;
|
|
133
|
+
// Check if element has beam-keep attribute
|
|
134
|
+
if (fromEl.hasAttribute('beam-keep')) {
|
|
135
|
+
// Don't morph this element - keep it as is
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// Check if element matches any keep selectors
|
|
139
|
+
for (const selector of keepSelectors) {
|
|
140
|
+
try {
|
|
141
|
+
if (fromEl.matches(selector)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Invalid selector, ignore
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
// Prevent removal of beam-keep elements
|
|
152
|
+
beforeNodeRemoved: (node) => {
|
|
153
|
+
if (node instanceof Element && node.hasAttribute('beam-keep')) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
171
157
|
}
|
|
172
158
|
}
|
|
173
|
-
// If no placeholder, look for element with same ID to replace
|
|
174
|
-
const newEl = target.querySelector(`#${id}`);
|
|
175
|
-
if (newEl) {
|
|
176
|
-
newEl.parentNode?.replaceChild(el, newEl);
|
|
177
|
-
}
|
|
178
159
|
});
|
|
179
160
|
}
|
|
180
161
|
function getParams(el) {
|
|
@@ -456,6 +437,72 @@ function swap(target, html, mode, trigger) {
|
|
|
456
437
|
// Process hungry elements - auto-update elements that match IDs in response
|
|
457
438
|
processHungryElements(html);
|
|
458
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Handle HTML response - supports both single string and array of HTML strings.
|
|
442
|
+
* Target resolution order (server wins, frontend is fallback):
|
|
443
|
+
* 1. Server target from comma-separated list (by index)
|
|
444
|
+
* - Use "!selector" to exclude that selector (blocks frontend fallback too)
|
|
445
|
+
* 2. Frontend target (beam-target) as fallback for remaining items
|
|
446
|
+
* 3. ID from HTML fragment's root element
|
|
447
|
+
* 4. Skip if none found
|
|
448
|
+
*/
|
|
449
|
+
function handleHtmlResponse(response, frontendTarget, frontendSwap, trigger) {
|
|
450
|
+
if (!response.html)
|
|
451
|
+
return;
|
|
452
|
+
const htmlArray = Array.isArray(response.html) ? response.html : [response.html];
|
|
453
|
+
// Server targets take priority, collect exclusions
|
|
454
|
+
const serverTargets = response.target ? response.target.split(',').map(s => s.trim()) : [];
|
|
455
|
+
const excluded = new Set(serverTargets.filter(t => t.startsWith('!')).map(t => t.slice(1)));
|
|
456
|
+
const swapMode = response.swap || frontendSwap;
|
|
457
|
+
htmlArray.forEach((htmlItem, index) => {
|
|
458
|
+
const serverTarget = serverTargets[index];
|
|
459
|
+
// Skip if this is an exclusion marker
|
|
460
|
+
if (serverTarget?.startsWith('!')) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Priority 1: Server target (by index)
|
|
464
|
+
let explicitTarget = serverTarget;
|
|
465
|
+
// Priority 2: Frontend target as fallback (only if no server target and not excluded)
|
|
466
|
+
if (!explicitTarget && frontendTarget && !excluded.has(frontendTarget)) {
|
|
467
|
+
explicitTarget = frontendTarget;
|
|
468
|
+
}
|
|
469
|
+
if (explicitTarget) {
|
|
470
|
+
// Explicit target provided - use normal swap
|
|
471
|
+
const target = $(explicitTarget);
|
|
472
|
+
if (target) {
|
|
473
|
+
swap(target, htmlItem, swapMode, trigger);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
console.warn(`[beam] Target "${explicitTarget}" not found on page, skipping`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
// Priority 3: id, beam-id, or beam-item-id on root element
|
|
481
|
+
const temp = document.createElement('div');
|
|
482
|
+
temp.innerHTML = htmlItem.trim();
|
|
483
|
+
const rootEl = temp.firstElementChild;
|
|
484
|
+
// Check id first, then beam-id, then beam-item-id
|
|
485
|
+
const id = rootEl?.id;
|
|
486
|
+
const beamId = rootEl?.getAttribute('beam-id');
|
|
487
|
+
const beamItemId = rootEl?.getAttribute('beam-item-id');
|
|
488
|
+
const selector = id ? `#${id}`
|
|
489
|
+
: beamId ? `[beam-id="${beamId}"]`
|
|
490
|
+
: beamItemId ? `[beam-item-id="${beamItemId}"]`
|
|
491
|
+
: null;
|
|
492
|
+
if (selector && !excluded.has(selector)) {
|
|
493
|
+
const target = $(selector);
|
|
494
|
+
if (target) {
|
|
495
|
+
// Replace entire element using outerHTML (preserves styles/classes)
|
|
496
|
+
target.outerHTML = htmlItem.trim();
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
console.warn(`[beam] Target "${selector}" (from HTML) not found on page, skipping`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// If no id/beam-id/beam-item-id found or excluded, skip silently
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
459
506
|
function parseOobSwaps(html) {
|
|
460
507
|
const temp = document.createElement('div');
|
|
461
508
|
temp.innerHTML = html;
|
|
@@ -484,16 +531,20 @@ async function rpc(action, data, el) {
|
|
|
484
531
|
location.href = response.redirect;
|
|
485
532
|
return;
|
|
486
533
|
}
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
const target = $(targetSelector);
|
|
493
|
-
if (target) {
|
|
494
|
-
swap(target, response.html, swapMode, el);
|
|
495
|
-
}
|
|
534
|
+
// Handle modal (if present)
|
|
535
|
+
if (response.modal) {
|
|
536
|
+
const modalData = typeof response.modal === 'string'
|
|
537
|
+
? { html: response.modal } : response.modal;
|
|
538
|
+
openModal(modalData.html, modalData.size || 'medium', modalData.spacing);
|
|
496
539
|
}
|
|
540
|
+
// Handle drawer (if present)
|
|
541
|
+
if (response.drawer) {
|
|
542
|
+
const drawerData = typeof response.drawer === 'string'
|
|
543
|
+
? { html: response.drawer } : response.drawer;
|
|
544
|
+
openDrawer(drawerData.html, drawerData.position || 'right', drawerData.size || 'medium', drawerData.spacing);
|
|
545
|
+
}
|
|
546
|
+
// Handle HTML (if present) - supports single string or array
|
|
547
|
+
handleHtmlResponse(response, frontendTarget, frontendSwap, el);
|
|
497
548
|
// Execute script (if present)
|
|
498
549
|
if (response.script) {
|
|
499
550
|
executeScript(response.script);
|
|
@@ -563,7 +614,7 @@ document.addEventListener('click', async (e) => {
|
|
|
563
614
|
const target = e.target;
|
|
564
615
|
if (!target?.closest)
|
|
565
616
|
return;
|
|
566
|
-
const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite])');
|
|
617
|
+
const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite]):not([beam-watch])');
|
|
567
618
|
if (!btn || btn.tagName === 'FORM')
|
|
568
619
|
return;
|
|
569
620
|
// Skip if submit button inside a beam form
|
|
@@ -583,8 +634,9 @@ document.addEventListener('click', async (e) => {
|
|
|
583
634
|
closeDrawer();
|
|
584
635
|
}
|
|
585
636
|
});
|
|
586
|
-
// ============ MODALS ============
|
|
587
|
-
|
|
637
|
+
// ============ MODALS & DRAWERS ============
|
|
638
|
+
// beam-modal trigger - calls action and opens result in modal
|
|
639
|
+
document.addEventListener('click', async (e) => {
|
|
588
640
|
const target = e.target;
|
|
589
641
|
if (!target?.closest)
|
|
590
642
|
return;
|
|
@@ -594,30 +646,48 @@ document.addEventListener('click', (e) => {
|
|
|
594
646
|
// Check confirmation
|
|
595
647
|
if (!checkConfirm(trigger))
|
|
596
648
|
return;
|
|
597
|
-
const
|
|
649
|
+
const action = trigger.getAttribute('beam-modal');
|
|
650
|
+
if (!action)
|
|
651
|
+
return;
|
|
598
652
|
const size = trigger.getAttribute('beam-size') || 'medium';
|
|
599
653
|
const params = getParams(trigger);
|
|
600
|
-
|
|
601
|
-
|
|
654
|
+
const placeholder = trigger.getAttribute('beam-placeholder');
|
|
655
|
+
// Show placeholder modal while loading
|
|
656
|
+
if (placeholder) {
|
|
657
|
+
openModal(placeholder, size);
|
|
602
658
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
659
|
+
setLoading(trigger, true, action, params);
|
|
660
|
+
try {
|
|
661
|
+
const response = await api.call(action, params);
|
|
662
|
+
// Handle the response - if it returns modal, use that, otherwise use html
|
|
663
|
+
if (response.modal) {
|
|
664
|
+
const modalData = typeof response.modal === 'string'
|
|
665
|
+
? { html: response.modal } : response.modal;
|
|
666
|
+
openModal(modalData.html, modalData.size || size, modalData.spacing);
|
|
667
|
+
}
|
|
668
|
+
else if (response.html) {
|
|
669
|
+
// For modals, use first item if array, otherwise use as-is
|
|
670
|
+
const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
|
|
671
|
+
if (htmlStr)
|
|
672
|
+
openModal(htmlStr, size);
|
|
673
|
+
}
|
|
674
|
+
// Execute script if present
|
|
675
|
+
if (response.script) {
|
|
676
|
+
executeScript(response.script);
|
|
677
|
+
}
|
|
613
678
|
}
|
|
614
|
-
|
|
679
|
+
catch (err) {
|
|
615
680
|
closeModal();
|
|
681
|
+
showToast('Failed to open modal.', 'error');
|
|
682
|
+
console.error('Modal error:', err);
|
|
683
|
+
}
|
|
684
|
+
finally {
|
|
685
|
+
setLoading(trigger, false, action, params);
|
|
616
686
|
}
|
|
617
687
|
}
|
|
618
688
|
});
|
|
619
|
-
//
|
|
620
|
-
document.addEventListener('click', (e) => {
|
|
689
|
+
// beam-drawer trigger - calls action and opens result in drawer
|
|
690
|
+
document.addEventListener('click', async (e) => {
|
|
621
691
|
const target = e.target;
|
|
622
692
|
if (!target?.closest)
|
|
623
693
|
return;
|
|
@@ -627,18 +697,69 @@ document.addEventListener('click', (e) => {
|
|
|
627
697
|
// Check confirmation
|
|
628
698
|
if (!checkConfirm(trigger))
|
|
629
699
|
return;
|
|
630
|
-
const
|
|
700
|
+
const action = trigger.getAttribute('beam-drawer');
|
|
701
|
+
if (!action)
|
|
702
|
+
return;
|
|
631
703
|
const position = trigger.getAttribute('beam-position') || 'right';
|
|
632
704
|
const size = trigger.getAttribute('beam-size') || 'medium';
|
|
633
705
|
const params = getParams(trigger);
|
|
634
|
-
|
|
635
|
-
|
|
706
|
+
const placeholder = trigger.getAttribute('beam-placeholder');
|
|
707
|
+
// Show placeholder drawer while loading
|
|
708
|
+
if (placeholder) {
|
|
709
|
+
openDrawer(placeholder, position, size);
|
|
710
|
+
}
|
|
711
|
+
setLoading(trigger, true, action, params);
|
|
712
|
+
try {
|
|
713
|
+
const response = await api.call(action, params);
|
|
714
|
+
// Handle the response - if it returns drawer, use that, otherwise use html
|
|
715
|
+
if (response.drawer) {
|
|
716
|
+
const drawerData = typeof response.drawer === 'string'
|
|
717
|
+
? { html: response.drawer } : response.drawer;
|
|
718
|
+
openDrawer(drawerData.html, drawerData.position || position, drawerData.size || size, drawerData.spacing);
|
|
719
|
+
}
|
|
720
|
+
else if (response.html) {
|
|
721
|
+
// For drawers, use first item if array, otherwise use as-is
|
|
722
|
+
const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
|
|
723
|
+
if (htmlStr)
|
|
724
|
+
openDrawer(htmlStr, position, size);
|
|
725
|
+
}
|
|
726
|
+
// Execute script if present
|
|
727
|
+
if (response.script) {
|
|
728
|
+
executeScript(response.script);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
closeDrawer();
|
|
733
|
+
showToast('Failed to open drawer.', 'error');
|
|
734
|
+
console.error('Drawer error:', err);
|
|
735
|
+
}
|
|
736
|
+
finally {
|
|
737
|
+
setLoading(trigger, false, action, params);
|
|
636
738
|
}
|
|
637
739
|
}
|
|
740
|
+
});
|
|
741
|
+
// Close handlers
|
|
742
|
+
document.addEventListener('click', (e) => {
|
|
743
|
+
const target = e.target;
|
|
744
|
+
if (!target?.closest)
|
|
745
|
+
return;
|
|
638
746
|
// Close on backdrop click
|
|
747
|
+
if (target.matches?.('#modal-backdrop')) {
|
|
748
|
+
closeModal();
|
|
749
|
+
}
|
|
639
750
|
if (target.matches?.('#drawer-backdrop')) {
|
|
640
751
|
closeDrawer();
|
|
641
752
|
}
|
|
753
|
+
// Close button (handles both modal and drawer)
|
|
754
|
+
const closeBtn = target.closest('[beam-close]');
|
|
755
|
+
if (closeBtn && !closeBtn.hasAttribute('beam-action')) {
|
|
756
|
+
if (activeDrawer) {
|
|
757
|
+
closeDrawer();
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
closeModal();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
642
763
|
});
|
|
643
764
|
document.addEventListener('keydown', (e) => {
|
|
644
765
|
if (e.key === 'Escape') {
|
|
@@ -650,33 +771,30 @@ document.addEventListener('keydown', (e) => {
|
|
|
650
771
|
}
|
|
651
772
|
}
|
|
652
773
|
});
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
if (!backdrop) {
|
|
658
|
-
backdrop = document.createElement('div');
|
|
659
|
-
backdrop.id = 'modal-backdrop';
|
|
660
|
-
document.body.appendChild(backdrop);
|
|
661
|
-
}
|
|
662
|
-
const { size } = options;
|
|
663
|
-
backdrop.innerHTML = `
|
|
664
|
-
<div id="modal-content" role="dialog" aria-modal="true" data-size="${size}">
|
|
665
|
-
${html}
|
|
666
|
-
</div>
|
|
667
|
-
`;
|
|
668
|
-
backdrop.offsetHeight;
|
|
669
|
-
backdrop.classList.add('open');
|
|
670
|
-
document.body.classList.add('modal-open');
|
|
671
|
-
activeModal = $('#modal-content');
|
|
672
|
-
const autoFocus = activeModal?.querySelector('[autofocus]');
|
|
673
|
-
const firstInput = activeModal?.querySelector('input, button, textarea, select');
|
|
674
|
-
(autoFocus || firstInput)?.focus();
|
|
675
|
-
}
|
|
676
|
-
catch (err) {
|
|
677
|
-
showToast('Failed to open modal.', 'error');
|
|
678
|
-
console.error('Modal error:', err);
|
|
774
|
+
function openModal(html, size = 'medium', spacing) {
|
|
775
|
+
// Close any existing drawer first (modal takes priority)
|
|
776
|
+
if (activeDrawer) {
|
|
777
|
+
closeDrawer();
|
|
679
778
|
}
|
|
779
|
+
let backdrop = $('#modal-backdrop');
|
|
780
|
+
if (!backdrop) {
|
|
781
|
+
backdrop = document.createElement('div');
|
|
782
|
+
backdrop.id = 'modal-backdrop';
|
|
783
|
+
document.body.appendChild(backdrop);
|
|
784
|
+
}
|
|
785
|
+
const style = spacing !== undefined ? `padding: ${spacing}px;` : '';
|
|
786
|
+
backdrop.innerHTML = `
|
|
787
|
+
<div id="modal-content" role="dialog" aria-modal="true" data-size="${size}" style="${style}">
|
|
788
|
+
${html}
|
|
789
|
+
</div>
|
|
790
|
+
`;
|
|
791
|
+
backdrop.offsetHeight;
|
|
792
|
+
backdrop.classList.add('open');
|
|
793
|
+
document.body.classList.add('modal-open');
|
|
794
|
+
activeModal = $('#modal-content');
|
|
795
|
+
const autoFocus = activeModal?.querySelector('[autofocus]');
|
|
796
|
+
const firstInput = activeModal?.querySelector('input, button, textarea, select');
|
|
797
|
+
(autoFocus || firstInput)?.focus();
|
|
680
798
|
}
|
|
681
799
|
function closeModal() {
|
|
682
800
|
const backdrop = $('#modal-backdrop');
|
|
@@ -689,34 +807,31 @@ function closeModal() {
|
|
|
689
807
|
document.body.classList.remove('modal-open');
|
|
690
808
|
activeModal = null;
|
|
691
809
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
backdrop = document.createElement('div');
|
|
698
|
-
backdrop.id = 'drawer-backdrop';
|
|
699
|
-
document.body.appendChild(backdrop);
|
|
700
|
-
}
|
|
701
|
-
// Set position and size as data attributes for CSS styling
|
|
702
|
-
const { position, size } = options;
|
|
703
|
-
backdrop.innerHTML = `
|
|
704
|
-
<div id="drawer-content" role="dialog" aria-modal="true" data-position="${position}" data-size="${size}">
|
|
705
|
-
${html}
|
|
706
|
-
</div>
|
|
707
|
-
`;
|
|
708
|
-
backdrop.offsetHeight; // Force reflow
|
|
709
|
-
backdrop.classList.add('open');
|
|
710
|
-
document.body.classList.add('drawer-open');
|
|
711
|
-
activeDrawer = $('#drawer-content');
|
|
712
|
-
const autoFocus = activeDrawer?.querySelector('[autofocus]');
|
|
713
|
-
const firstInput = activeDrawer?.querySelector('input, button, textarea, select');
|
|
714
|
-
(autoFocus || firstInput)?.focus();
|
|
715
|
-
}
|
|
716
|
-
catch (err) {
|
|
717
|
-
showToast('Failed to open drawer.', 'error');
|
|
718
|
-
console.error('Drawer error:', err);
|
|
810
|
+
// ============ DRAWERS ============
|
|
811
|
+
function openDrawer(html, position = 'right', size = 'medium', spacing) {
|
|
812
|
+
// Close any existing modal first (drawer takes priority)
|
|
813
|
+
if (activeModal) {
|
|
814
|
+
closeModal();
|
|
719
815
|
}
|
|
816
|
+
let backdrop = $('#drawer-backdrop');
|
|
817
|
+
if (!backdrop) {
|
|
818
|
+
backdrop = document.createElement('div');
|
|
819
|
+
backdrop.id = 'drawer-backdrop';
|
|
820
|
+
document.body.appendChild(backdrop);
|
|
821
|
+
}
|
|
822
|
+
const style = spacing !== undefined ? `padding: ${spacing}px;` : '';
|
|
823
|
+
backdrop.innerHTML = `
|
|
824
|
+
<div id="drawer-content" role="dialog" aria-modal="true" data-position="${position}" data-size="${size}" style="${style}">
|
|
825
|
+
${html}
|
|
826
|
+
</div>
|
|
827
|
+
`;
|
|
828
|
+
backdrop.offsetHeight; // Force reflow
|
|
829
|
+
backdrop.classList.add('open');
|
|
830
|
+
document.body.classList.add('drawer-open');
|
|
831
|
+
activeDrawer = $('#drawer-content');
|
|
832
|
+
const autoFocus = activeDrawer?.querySelector('[autofocus]');
|
|
833
|
+
const firstInput = activeDrawer?.querySelector('input, button, textarea, select');
|
|
834
|
+
(autoFocus || firstInput)?.focus();
|
|
720
835
|
}
|
|
721
836
|
function closeDrawer() {
|
|
722
837
|
const backdrop = $('#drawer-backdrop');
|
|
@@ -1125,9 +1240,14 @@ const infiniteObserver = new IntersectionObserver(async (entries) => {
|
|
|
1125
1240
|
setLoading(sentinel, true, action, params);
|
|
1126
1241
|
try {
|
|
1127
1242
|
const response = await api.call(action, params);
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1243
|
+
if (response.html) {
|
|
1244
|
+
// For infinite scroll, always use the specified target (not auto-detect from HTML)
|
|
1245
|
+
const target = $(targetSelector);
|
|
1246
|
+
if (target) {
|
|
1247
|
+
// Handle single html string for infinite scroll (array not typically used here)
|
|
1248
|
+
const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
|
|
1249
|
+
swap(target, htmlStr, swapMode, sentinel);
|
|
1250
|
+
}
|
|
1131
1251
|
// Save scroll state after content is loaded
|
|
1132
1252
|
requestAnimationFrame(() => {
|
|
1133
1253
|
saveScrollState(targetSelector, action);
|
|
@@ -1186,9 +1306,14 @@ document.addEventListener('click', async (e) => {
|
|
|
1186
1306
|
setLoading(trigger, true, action, params);
|
|
1187
1307
|
try {
|
|
1188
1308
|
const response = await api.call(action, params);
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1309
|
+
if (response.html) {
|
|
1310
|
+
// For load more, always use the specified target (not auto-detect from HTML)
|
|
1311
|
+
const targetEl = $(targetSelector);
|
|
1312
|
+
if (targetEl) {
|
|
1313
|
+
// Handle single html string for load more (array not typically used here)
|
|
1314
|
+
const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
|
|
1315
|
+
swap(targetEl, htmlStr, swapMode, trigger);
|
|
1316
|
+
}
|
|
1192
1317
|
// Save scroll state after content is loaded
|
|
1193
1318
|
requestAnimationFrame(() => {
|
|
1194
1319
|
saveScrollState(targetSelector, action);
|
|
@@ -1372,13 +1497,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1372
1497
|
location.href = response.redirect;
|
|
1373
1498
|
return;
|
|
1374
1499
|
}
|
|
1375
|
-
// Handle HTML (if present)
|
|
1376
|
-
|
|
1377
|
-
const target = $(targetSelector);
|
|
1378
|
-
if (target) {
|
|
1379
|
-
swap(target, response.html, swapMode, link);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1500
|
+
// Handle HTML (if present) - supports single string or array
|
|
1501
|
+
handleHtmlResponse(response, targetSelector, swapMode, link);
|
|
1382
1502
|
// Execute script (if present)
|
|
1383
1503
|
if (response.script) {
|
|
1384
1504
|
executeScript(response.script);
|
|
@@ -1428,13 +1548,20 @@ document.addEventListener('submit', async (e) => {
|
|
|
1428
1548
|
location.href = response.redirect;
|
|
1429
1549
|
return;
|
|
1430
1550
|
}
|
|
1431
|
-
// Handle
|
|
1432
|
-
if (response.
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
}
|
|
1551
|
+
// Handle modal (if present)
|
|
1552
|
+
if (response.modal) {
|
|
1553
|
+
const modalData = typeof response.modal === 'string'
|
|
1554
|
+
? { html: response.modal } : response.modal;
|
|
1555
|
+
openModal(modalData.html, modalData.size || 'medium', modalData.spacing);
|
|
1437
1556
|
}
|
|
1557
|
+
// Handle drawer (if present)
|
|
1558
|
+
if (response.drawer) {
|
|
1559
|
+
const drawerData = typeof response.drawer === 'string'
|
|
1560
|
+
? { html: response.drawer } : response.drawer;
|
|
1561
|
+
openDrawer(drawerData.html, drawerData.position || 'right', drawerData.size || 'medium', drawerData.spacing);
|
|
1562
|
+
}
|
|
1563
|
+
// Handle HTML (if present) - supports single string or array
|
|
1564
|
+
handleHtmlResponse(response, targetSelector, swapMode);
|
|
1438
1565
|
// Execute script (if present)
|
|
1439
1566
|
if (response.script) {
|
|
1440
1567
|
executeScript(response.script);
|
|
@@ -1483,7 +1610,10 @@ function setupValidation(el) {
|
|
|
1483
1610
|
if (response.html) {
|
|
1484
1611
|
const target = $(targetSelector);
|
|
1485
1612
|
if (target) {
|
|
1486
|
-
|
|
1613
|
+
// For validation, use first item if array, otherwise use as-is
|
|
1614
|
+
const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
|
|
1615
|
+
if (htmlStr)
|
|
1616
|
+
morph(target, htmlStr);
|
|
1487
1617
|
}
|
|
1488
1618
|
}
|
|
1489
1619
|
// Execute script (if present)
|
|
@@ -1510,6 +1640,240 @@ document.querySelectorAll('[beam-validate]').forEach((el) => {
|
|
|
1510
1640
|
el.setAttribute('beam-validation-observed', '');
|
|
1511
1641
|
setupValidation(el);
|
|
1512
1642
|
});
|
|
1643
|
+
// ============ INPUT WATCHERS ============
|
|
1644
|
+
// Usage: <input name="q" beam-action="search" beam-target="#results" beam-watch="input" beam-debounce="300">
|
|
1645
|
+
// Usage: <input type="range" beam-action="update" beam-watch="input" beam-throttle="100">
|
|
1646
|
+
// Usage: <input beam-watch="input" beam-watch-if="value.length >= 3">
|
|
1647
|
+
// Handles standalone inputs with beam-action + beam-watch (not using beam-validate)
|
|
1648
|
+
function isInputElement(el) {
|
|
1649
|
+
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
|
1650
|
+
}
|
|
1651
|
+
function getInputValue(el) {
|
|
1652
|
+
if (el.tagName === 'INPUT') {
|
|
1653
|
+
const input = el;
|
|
1654
|
+
if (input.type === 'checkbox')
|
|
1655
|
+
return input.checked;
|
|
1656
|
+
if (input.type === 'radio')
|
|
1657
|
+
return input.checked ? input.value : '';
|
|
1658
|
+
return input.value;
|
|
1659
|
+
}
|
|
1660
|
+
if (el.tagName === 'TEXTAREA')
|
|
1661
|
+
return el.value;
|
|
1662
|
+
if (el.tagName === 'SELECT')
|
|
1663
|
+
return el.value;
|
|
1664
|
+
return '';
|
|
1665
|
+
}
|
|
1666
|
+
// Cast value based on beam-cast attribute
|
|
1667
|
+
function castValue(value, castType) {
|
|
1668
|
+
if (!castType || typeof value !== 'string')
|
|
1669
|
+
return value;
|
|
1670
|
+
switch (castType) {
|
|
1671
|
+
case 'number':
|
|
1672
|
+
const num = parseFloat(value);
|
|
1673
|
+
return isNaN(num) ? 0 : num;
|
|
1674
|
+
case 'integer':
|
|
1675
|
+
const int = parseInt(value, 10);
|
|
1676
|
+
return isNaN(int) ? 0 : int;
|
|
1677
|
+
case 'boolean':
|
|
1678
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
1679
|
+
case 'trim':
|
|
1680
|
+
return value.trim();
|
|
1681
|
+
default:
|
|
1682
|
+
return value;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// Check if condition is met for beam-watch-if
|
|
1686
|
+
function checkWatchCondition(el, value) {
|
|
1687
|
+
const condition = el.getAttribute('beam-watch-if');
|
|
1688
|
+
if (!condition)
|
|
1689
|
+
return true;
|
|
1690
|
+
try {
|
|
1691
|
+
// Create a function that evaluates the condition with 'value' and 'this' context
|
|
1692
|
+
const fn = new Function('value', `with(this) { return ${condition} }`);
|
|
1693
|
+
return Boolean(fn.call(el, value));
|
|
1694
|
+
}
|
|
1695
|
+
catch (e) {
|
|
1696
|
+
console.warn('[beam] Invalid beam-watch-if condition:', condition, e);
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
// Create throttle function
|
|
1701
|
+
function createThrottle(fn, limit) {
|
|
1702
|
+
let lastRun = 0;
|
|
1703
|
+
let timeout = null;
|
|
1704
|
+
return () => {
|
|
1705
|
+
const now = Date.now();
|
|
1706
|
+
const timeSinceLastRun = now - lastRun;
|
|
1707
|
+
if (timeSinceLastRun >= limit) {
|
|
1708
|
+
lastRun = now;
|
|
1709
|
+
fn();
|
|
1710
|
+
}
|
|
1711
|
+
else if (!timeout) {
|
|
1712
|
+
// Schedule to run after remaining time
|
|
1713
|
+
timeout = setTimeout(() => {
|
|
1714
|
+
lastRun = Date.now();
|
|
1715
|
+
timeout = null;
|
|
1716
|
+
fn();
|
|
1717
|
+
}, limit - timeSinceLastRun);
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
function setupInputWatcher(el) {
|
|
1722
|
+
if (!isInputElement(el))
|
|
1723
|
+
return;
|
|
1724
|
+
const htmlEl = el;
|
|
1725
|
+
const event = htmlEl.getAttribute('beam-watch') || 'change';
|
|
1726
|
+
const debounceMs = htmlEl.getAttribute('beam-debounce');
|
|
1727
|
+
const throttleMs = htmlEl.getAttribute('beam-throttle');
|
|
1728
|
+
const action = htmlEl.getAttribute('beam-action');
|
|
1729
|
+
const targetSelector = htmlEl.getAttribute('beam-target');
|
|
1730
|
+
const swapMode = htmlEl.getAttribute('beam-swap') || 'morph';
|
|
1731
|
+
const castType = htmlEl.getAttribute('beam-cast');
|
|
1732
|
+
const loadingClass = htmlEl.getAttribute('beam-loading-class');
|
|
1733
|
+
if (!action)
|
|
1734
|
+
return;
|
|
1735
|
+
let debounceTimeout;
|
|
1736
|
+
const executeAction = async (eventType) => {
|
|
1737
|
+
const name = htmlEl.getAttribute('name');
|
|
1738
|
+
let value = getInputValue(el);
|
|
1739
|
+
// Apply type casting
|
|
1740
|
+
value = castValue(value, castType);
|
|
1741
|
+
// Check conditional trigger
|
|
1742
|
+
if (!checkWatchCondition(htmlEl, value))
|
|
1743
|
+
return;
|
|
1744
|
+
const params = getParams(htmlEl);
|
|
1745
|
+
// Add the input's value to params
|
|
1746
|
+
if (name) {
|
|
1747
|
+
params[name] = value;
|
|
1748
|
+
}
|
|
1749
|
+
// Handle checkboxes specially - they might be part of a group
|
|
1750
|
+
if (el.tagName === 'INPUT' && el.type === 'checkbox') {
|
|
1751
|
+
const form = el.closest('form');
|
|
1752
|
+
if (form && name) {
|
|
1753
|
+
const checkboxes = form.querySelectorAll(`input[type="checkbox"][name="${name}"]`);
|
|
1754
|
+
if (checkboxes.length > 1) {
|
|
1755
|
+
const values = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
|
|
1756
|
+
params[name] = values;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
// Only restore focus for "input" events, not "change" (blur) events
|
|
1761
|
+
const shouldRestoreFocus = htmlEl.hasAttribute('beam-keep') && eventType === 'input';
|
|
1762
|
+
const activeElement = document.activeElement;
|
|
1763
|
+
// Add loading class if specified
|
|
1764
|
+
if (loadingClass)
|
|
1765
|
+
htmlEl.classList.add(loadingClass);
|
|
1766
|
+
// Mark touched
|
|
1767
|
+
htmlEl.setAttribute('beam-touched', '');
|
|
1768
|
+
try {
|
|
1769
|
+
const response = await api.call(action, params);
|
|
1770
|
+
if (response.html && targetSelector) {
|
|
1771
|
+
const targets = $$(targetSelector);
|
|
1772
|
+
const htmlArray = Array.isArray(response.html) ? response.html : [response.html];
|
|
1773
|
+
targets.forEach((target, i) => {
|
|
1774
|
+
const html = htmlArray[i] || htmlArray[0];
|
|
1775
|
+
if (html) {
|
|
1776
|
+
if (swapMode === 'append') {
|
|
1777
|
+
target.insertAdjacentHTML('beforeend', html);
|
|
1778
|
+
}
|
|
1779
|
+
else if (swapMode === 'prepend') {
|
|
1780
|
+
target.insertAdjacentHTML('afterbegin', html);
|
|
1781
|
+
}
|
|
1782
|
+
else if (swapMode === 'replace') {
|
|
1783
|
+
target.outerHTML = html;
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
morph(target, html);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
// Process OOB updates (beam-touch templates)
|
|
1792
|
+
if (response.html) {
|
|
1793
|
+
const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
|
|
1794
|
+
const { oob } = parseOobSwaps(htmlStr);
|
|
1795
|
+
for (const { selector, content, swapMode: oobSwapMode } of oob) {
|
|
1796
|
+
const oobTarget = $(selector);
|
|
1797
|
+
if (oobTarget) {
|
|
1798
|
+
if (oobSwapMode === 'morph' || !oobSwapMode) {
|
|
1799
|
+
morph(oobTarget, content);
|
|
1800
|
+
}
|
|
1801
|
+
else {
|
|
1802
|
+
swap(oobTarget, content, oobSwapMode);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
// Execute script if present
|
|
1808
|
+
if (response.script) {
|
|
1809
|
+
executeScript(response.script);
|
|
1810
|
+
}
|
|
1811
|
+
// Restore focus if beam-keep is set and this was an input event (not change/blur)
|
|
1812
|
+
if (shouldRestoreFocus && activeElement instanceof HTMLElement) {
|
|
1813
|
+
const newEl = document.querySelector(`[name="${name}"]`);
|
|
1814
|
+
if (newEl && newEl !== activeElement) {
|
|
1815
|
+
newEl.focus();
|
|
1816
|
+
if (newEl instanceof HTMLInputElement || newEl instanceof HTMLTextAreaElement) {
|
|
1817
|
+
const cursorPos = activeElement.selectionStart;
|
|
1818
|
+
if (cursorPos !== null) {
|
|
1819
|
+
newEl.setSelectionRange(cursorPos, cursorPos);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
catch (err) {
|
|
1826
|
+
console.error('Input watcher error:', err);
|
|
1827
|
+
}
|
|
1828
|
+
finally {
|
|
1829
|
+
// Remove loading class
|
|
1830
|
+
if (loadingClass)
|
|
1831
|
+
htmlEl.classList.remove(loadingClass);
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
// Create the appropriate handler based on throttle vs debounce
|
|
1835
|
+
let handler;
|
|
1836
|
+
if (throttleMs) {
|
|
1837
|
+
// Use throttle mode
|
|
1838
|
+
const throttle = parseInt(throttleMs, 10);
|
|
1839
|
+
const throttledFn = createThrottle(() => executeAction('input'), throttle);
|
|
1840
|
+
handler = (e) => {
|
|
1841
|
+
throttledFn();
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
// Use debounce mode (default)
|
|
1846
|
+
const debounce = parseInt(debounceMs || '300', 10);
|
|
1847
|
+
handler = (e) => {
|
|
1848
|
+
clearTimeout(debounceTimeout);
|
|
1849
|
+
const eventType = e.type;
|
|
1850
|
+
debounceTimeout = setTimeout(() => executeAction(eventType), debounce);
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
// Support multiple events (comma-separated)
|
|
1854
|
+
const events = event.split(',').map(e => e.trim());
|
|
1855
|
+
events.forEach(evt => {
|
|
1856
|
+
htmlEl.addEventListener(evt, handler);
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
// Observe input watcher elements (current and future)
|
|
1860
|
+
const inputWatcherObserver = new MutationObserver(() => {
|
|
1861
|
+
// Select inputs with beam-action + beam-watch but NOT beam-validate (which has its own handler)
|
|
1862
|
+
document.querySelectorAll('[beam-action][beam-watch]:not([beam-validate]):not([beam-input-observed])').forEach((el) => {
|
|
1863
|
+
if (!isInputElement(el))
|
|
1864
|
+
return;
|
|
1865
|
+
el.setAttribute('beam-input-observed', '');
|
|
1866
|
+
setupInputWatcher(el);
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
inputWatcherObserver.observe(document.body, { childList: true, subtree: true });
|
|
1870
|
+
// Initialize existing input watcher elements
|
|
1871
|
+
document.querySelectorAll('[beam-action][beam-watch]:not([beam-validate])').forEach((el) => {
|
|
1872
|
+
if (!isInputElement(el))
|
|
1873
|
+
return;
|
|
1874
|
+
el.setAttribute('beam-input-observed', '');
|
|
1875
|
+
setupInputWatcher(el);
|
|
1876
|
+
});
|
|
1513
1877
|
// ============ DEFERRED LOADING ============
|
|
1514
1878
|
// Usage: <div beam-defer beam-action="loadComments" beam-target="#comments">Loading...</div>
|
|
1515
1879
|
const deferObserver = new IntersectionObserver(async (entries) => {
|
|
@@ -1533,7 +1897,10 @@ const deferObserver = new IntersectionObserver(async (entries) => {
|
|
|
1533
1897
|
if (response.html) {
|
|
1534
1898
|
const target = targetSelector ? $(targetSelector) : el;
|
|
1535
1899
|
if (target) {
|
|
1536
|
-
|
|
1900
|
+
// For deferred loading, use first item if array, otherwise use as-is
|
|
1901
|
+
const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
|
|
1902
|
+
if (htmlStr)
|
|
1903
|
+
swap(target, htmlStr, swapMode);
|
|
1537
1904
|
}
|
|
1538
1905
|
}
|
|
1539
1906
|
// Execute script (if present)
|
|
@@ -1589,7 +1956,10 @@ function startPolling(el) {
|
|
|
1589
1956
|
if (response.html) {
|
|
1590
1957
|
const target = targetSelector ? $(targetSelector) : el;
|
|
1591
1958
|
if (target) {
|
|
1592
|
-
|
|
1959
|
+
// For polling, use first item if array, otherwise use as-is
|
|
1960
|
+
const htmlStr = Array.isArray(response.html) ? response.html[0] : response.html;
|
|
1961
|
+
if (htmlStr)
|
|
1962
|
+
swap(target, htmlStr, swapMode);
|
|
1593
1963
|
}
|
|
1594
1964
|
}
|
|
1595
1965
|
// Execute script (if present)
|
|
@@ -1783,6 +2153,301 @@ document.addEventListener('click', (e) => {
|
|
|
1783
2153
|
}
|
|
1784
2154
|
}
|
|
1785
2155
|
});
|
|
2156
|
+
// ============ DIRTY FORM TRACKING ============
|
|
2157
|
+
// Usage: <form beam-dirty-track>...</form>
|
|
2158
|
+
// Usage: <span beam-dirty-indicator="#my-form">*</span> (shows when form is dirty)
|
|
2159
|
+
// Usage: <form beam-warn-unsaved>...</form> (warns on page leave)
|
|
2160
|
+
// Store original form data for dirty checking
|
|
2161
|
+
const formOriginalData = new WeakMap();
|
|
2162
|
+
const dirtyForms = new Set();
|
|
2163
|
+
function getFormDataMap(form) {
|
|
2164
|
+
const map = new Map();
|
|
2165
|
+
const formData = new FormData(form);
|
|
2166
|
+
for (const [key, value] of formData.entries()) {
|
|
2167
|
+
const existing = map.get(key);
|
|
2168
|
+
if (existing) {
|
|
2169
|
+
// Handle multiple values (checkboxes, multi-select)
|
|
2170
|
+
map.set(key, existing + ',' + String(value));
|
|
2171
|
+
}
|
|
2172
|
+
else {
|
|
2173
|
+
map.set(key, String(value));
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
return map;
|
|
2177
|
+
}
|
|
2178
|
+
function isFormDirty(form) {
|
|
2179
|
+
const original = formOriginalData.get(form);
|
|
2180
|
+
if (!original)
|
|
2181
|
+
return false;
|
|
2182
|
+
const current = getFormDataMap(form);
|
|
2183
|
+
// Check if any values changed
|
|
2184
|
+
for (const [key, value] of current.entries()) {
|
|
2185
|
+
if (original.get(key) !== value)
|
|
2186
|
+
return true;
|
|
2187
|
+
}
|
|
2188
|
+
for (const [key, value] of original.entries()) {
|
|
2189
|
+
if (current.get(key) !== value)
|
|
2190
|
+
return true;
|
|
2191
|
+
}
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
function updateDirtyState(form) {
|
|
2195
|
+
const isDirty = isFormDirty(form);
|
|
2196
|
+
if (isDirty) {
|
|
2197
|
+
dirtyForms.add(form);
|
|
2198
|
+
form.setAttribute('beam-dirty', '');
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
dirtyForms.delete(form);
|
|
2202
|
+
form.removeAttribute('beam-dirty');
|
|
2203
|
+
}
|
|
2204
|
+
// Update dirty indicators
|
|
2205
|
+
updateDirtyIndicators();
|
|
2206
|
+
}
|
|
2207
|
+
function updateDirtyIndicators() {
|
|
2208
|
+
document.querySelectorAll('[beam-dirty-indicator]').forEach((indicator) => {
|
|
2209
|
+
const formSelector = indicator.getAttribute('beam-dirty-indicator');
|
|
2210
|
+
if (!formSelector)
|
|
2211
|
+
return;
|
|
2212
|
+
const form = document.querySelector(formSelector);
|
|
2213
|
+
const isDirty = form ? dirtyForms.has(form) : false;
|
|
2214
|
+
if (indicator.hasAttribute('beam-dirty-class')) {
|
|
2215
|
+
const className = indicator.getAttribute('beam-dirty-class');
|
|
2216
|
+
indicator.classList.toggle(className, isDirty);
|
|
2217
|
+
}
|
|
2218
|
+
else {
|
|
2219
|
+
indicator.style.display = isDirty ? '' : 'none';
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
// Update show-if-dirty elements
|
|
2223
|
+
document.querySelectorAll('[beam-show-if-dirty]').forEach((el) => {
|
|
2224
|
+
const formSelector = el.getAttribute('beam-show-if-dirty');
|
|
2225
|
+
const form = formSelector
|
|
2226
|
+
? document.querySelector(formSelector)
|
|
2227
|
+
: el.closest('form');
|
|
2228
|
+
const isDirty = form ? dirtyForms.has(form) : false;
|
|
2229
|
+
el.style.display = isDirty ? '' : 'none';
|
|
2230
|
+
});
|
|
2231
|
+
// Update hide-if-dirty elements
|
|
2232
|
+
document.querySelectorAll('[beam-hide-if-dirty]').forEach((el) => {
|
|
2233
|
+
const formSelector = el.getAttribute('beam-hide-if-dirty');
|
|
2234
|
+
const form = formSelector
|
|
2235
|
+
? document.querySelector(formSelector)
|
|
2236
|
+
: el.closest('form');
|
|
2237
|
+
const isDirty = form ? dirtyForms.has(form) : false;
|
|
2238
|
+
el.style.display = isDirty ? 'none' : '';
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
function setupDirtyTracking(form) {
|
|
2242
|
+
// Store original data
|
|
2243
|
+
formOriginalData.set(form, getFormDataMap(form));
|
|
2244
|
+
// Listen to input events on all form fields
|
|
2245
|
+
const checkDirty = () => updateDirtyState(form);
|
|
2246
|
+
form.addEventListener('input', checkDirty);
|
|
2247
|
+
form.addEventListener('change', checkDirty);
|
|
2248
|
+
// Reset dirty state on form submit
|
|
2249
|
+
form.addEventListener('submit', () => {
|
|
2250
|
+
// After successful submit, update original data
|
|
2251
|
+
setTimeout(() => {
|
|
2252
|
+
formOriginalData.set(form, getFormDataMap(form));
|
|
2253
|
+
updateDirtyState(form);
|
|
2254
|
+
}, 100);
|
|
2255
|
+
});
|
|
2256
|
+
// Handle form reset
|
|
2257
|
+
form.addEventListener('reset', () => {
|
|
2258
|
+
setTimeout(() => updateDirtyState(form), 0);
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
// Observe dirty-tracked forms
|
|
2262
|
+
const dirtyFormObserver = new MutationObserver(() => {
|
|
2263
|
+
document.querySelectorAll('form[beam-dirty-track]:not([beam-dirty-observed])').forEach((form) => {
|
|
2264
|
+
form.setAttribute('beam-dirty-observed', '');
|
|
2265
|
+
setupDirtyTracking(form);
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
dirtyFormObserver.observe(document.body, { childList: true, subtree: true });
|
|
2269
|
+
// Initialize existing dirty-tracked forms
|
|
2270
|
+
document.querySelectorAll('form[beam-dirty-track]').forEach((form) => {
|
|
2271
|
+
form.setAttribute('beam-dirty-observed', '');
|
|
2272
|
+
setupDirtyTracking(form);
|
|
2273
|
+
});
|
|
2274
|
+
// Initialize dirty indicators (hidden by default)
|
|
2275
|
+
document.querySelectorAll('[beam-dirty-indicator]:not([beam-dirty-class])').forEach((el) => {
|
|
2276
|
+
el.style.display = 'none';
|
|
2277
|
+
});
|
|
2278
|
+
document.querySelectorAll('[beam-show-if-dirty]').forEach((el) => {
|
|
2279
|
+
el.style.display = 'none';
|
|
2280
|
+
});
|
|
2281
|
+
// ============ UNSAVED CHANGES WARNING ============
|
|
2282
|
+
// Usage: <form beam-warn-unsaved>...</form>
|
|
2283
|
+
// Usage: <form beam-warn-unsaved="Are you sure? You have unsaved changes.">...</form>
|
|
2284
|
+
window.addEventListener('beforeunload', (e) => {
|
|
2285
|
+
// Check if any form with beam-warn-unsaved is dirty
|
|
2286
|
+
const formsWithWarning = document.querySelectorAll('form[beam-warn-unsaved]');
|
|
2287
|
+
let hasDirtyForm = false;
|
|
2288
|
+
formsWithWarning.forEach((form) => {
|
|
2289
|
+
if (dirtyForms.has(form)) {
|
|
2290
|
+
hasDirtyForm = true;
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
if (hasDirtyForm) {
|
|
2294
|
+
e.preventDefault();
|
|
2295
|
+
// Modern browsers ignore custom messages, but we need to return something
|
|
2296
|
+
e.returnValue = '';
|
|
2297
|
+
return '';
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2300
|
+
// ============ FORM REVERT ============
|
|
2301
|
+
// Usage: <button type="button" beam-revert="#my-form">Revert</button>
|
|
2302
|
+
// Usage: <button type="button" beam-revert>Revert</button> (inside form)
|
|
2303
|
+
document.addEventListener('click', (e) => {
|
|
2304
|
+
const target = e.target;
|
|
2305
|
+
if (!target?.closest)
|
|
2306
|
+
return;
|
|
2307
|
+
const trigger = target.closest('[beam-revert]');
|
|
2308
|
+
if (trigger) {
|
|
2309
|
+
e.preventDefault();
|
|
2310
|
+
const formSelector = trigger.getAttribute('beam-revert');
|
|
2311
|
+
const form = formSelector
|
|
2312
|
+
? document.querySelector(formSelector)
|
|
2313
|
+
: trigger.closest('form');
|
|
2314
|
+
if (form) {
|
|
2315
|
+
const original = formOriginalData.get(form);
|
|
2316
|
+
if (original) {
|
|
2317
|
+
// Reset each field to its original value
|
|
2318
|
+
original.forEach((value, name) => {
|
|
2319
|
+
const fields = form.querySelectorAll(`[name="${name}"]`);
|
|
2320
|
+
fields.forEach((el) => {
|
|
2321
|
+
const field = el;
|
|
2322
|
+
if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
|
|
2323
|
+
// For checkboxes/radios, check if their value was in the original
|
|
2324
|
+
const values = value.split(',');
|
|
2325
|
+
field.checked = values.includes(field.value);
|
|
2326
|
+
}
|
|
2327
|
+
else if ('value' in field) {
|
|
2328
|
+
field.value = value;
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
});
|
|
2332
|
+
// Handle fields that weren't in original (new fields) - reset them
|
|
2333
|
+
const currentFields = form.querySelectorAll('[name]');
|
|
2334
|
+
currentFields.forEach((el) => {
|
|
2335
|
+
const field = el;
|
|
2336
|
+
const name = field.getAttribute('name');
|
|
2337
|
+
if (name && !original.has(name)) {
|
|
2338
|
+
if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
|
|
2339
|
+
field.checked = false;
|
|
2340
|
+
}
|
|
2341
|
+
else if ('value' in field) {
|
|
2342
|
+
field.value = '';
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
});
|
|
2346
|
+
// Dispatch input event for any watchers
|
|
2347
|
+
form.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2348
|
+
updateDirtyState(form);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
// ============ CONDITIONAL FORM FIELDS ============
|
|
2354
|
+
// Usage: <input name="other" beam-enable-if="#has-other:checked">
|
|
2355
|
+
// Usage: <select beam-disable-if="#country[value='']">
|
|
2356
|
+
// Usage: <div beam-visible-if="#show-details:checked">Details here</div>
|
|
2357
|
+
function evaluateCondition(condition) {
|
|
2358
|
+
// Parse condition: "#selector:pseudo" or "#selector[attr='value']"
|
|
2359
|
+
const match = condition.match(/^([^:\[]+)(?::(\w+))?(?:\[([^\]]+)\])?$/);
|
|
2360
|
+
if (!match)
|
|
2361
|
+
return false;
|
|
2362
|
+
const [, selector, pseudo, attrCondition] = match;
|
|
2363
|
+
const el = document.querySelector(selector);
|
|
2364
|
+
if (!el)
|
|
2365
|
+
return false;
|
|
2366
|
+
// Check pseudo-class
|
|
2367
|
+
if (pseudo === 'checked') {
|
|
2368
|
+
return el.checked;
|
|
2369
|
+
}
|
|
2370
|
+
if (pseudo === 'disabled') {
|
|
2371
|
+
return el.disabled;
|
|
2372
|
+
}
|
|
2373
|
+
if (pseudo === 'empty') {
|
|
2374
|
+
return !el.value;
|
|
2375
|
+
}
|
|
2376
|
+
// Check attribute condition
|
|
2377
|
+
if (attrCondition) {
|
|
2378
|
+
const attrMatch = attrCondition.match(/(\w+)([=!<>]+)'?([^']*)'?/);
|
|
2379
|
+
if (attrMatch) {
|
|
2380
|
+
const [, attr, op, expected] = attrMatch;
|
|
2381
|
+
const actual = attr === 'value' ? el.value : el.getAttribute(attr);
|
|
2382
|
+
switch (op) {
|
|
2383
|
+
case '=':
|
|
2384
|
+
case '==':
|
|
2385
|
+
return actual === expected;
|
|
2386
|
+
case '!=':
|
|
2387
|
+
return actual !== expected;
|
|
2388
|
+
case '>':
|
|
2389
|
+
return Number(actual) > Number(expected);
|
|
2390
|
+
case '<':
|
|
2391
|
+
return Number(actual) < Number(expected);
|
|
2392
|
+
case '>=':
|
|
2393
|
+
return Number(actual) >= Number(expected);
|
|
2394
|
+
case '<=':
|
|
2395
|
+
return Number(actual) <= Number(expected);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
// Default: check if element exists and has a truthy value
|
|
2400
|
+
if (el instanceof HTMLInputElement) {
|
|
2401
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2402
|
+
return el.checked;
|
|
2403
|
+
}
|
|
2404
|
+
return Boolean(el.value);
|
|
2405
|
+
}
|
|
2406
|
+
if (el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
|
|
2407
|
+
return Boolean(el.value);
|
|
2408
|
+
}
|
|
2409
|
+
return true;
|
|
2410
|
+
}
|
|
2411
|
+
function updateConditionalFields() {
|
|
2412
|
+
// Enable-if
|
|
2413
|
+
document.querySelectorAll('[beam-enable-if]').forEach((el) => {
|
|
2414
|
+
const condition = el.getAttribute('beam-enable-if');
|
|
2415
|
+
const shouldEnable = evaluateCondition(condition);
|
|
2416
|
+
el.disabled = !shouldEnable;
|
|
2417
|
+
});
|
|
2418
|
+
// Disable-if
|
|
2419
|
+
document.querySelectorAll('[beam-disable-if]').forEach((el) => {
|
|
2420
|
+
const condition = el.getAttribute('beam-disable-if');
|
|
2421
|
+
const shouldDisable = evaluateCondition(condition);
|
|
2422
|
+
el.disabled = shouldDisable;
|
|
2423
|
+
});
|
|
2424
|
+
// Visible-if (show when condition is true)
|
|
2425
|
+
document.querySelectorAll('[beam-visible-if]').forEach((el) => {
|
|
2426
|
+
const condition = el.getAttribute('beam-visible-if');
|
|
2427
|
+
const shouldShow = evaluateCondition(condition);
|
|
2428
|
+
el.style.display = shouldShow ? '' : 'none';
|
|
2429
|
+
});
|
|
2430
|
+
// Hidden-if (hide when condition is true)
|
|
2431
|
+
document.querySelectorAll('[beam-hidden-if]').forEach((el) => {
|
|
2432
|
+
const condition = el.getAttribute('beam-hidden-if');
|
|
2433
|
+
const shouldHide = evaluateCondition(condition);
|
|
2434
|
+
el.style.display = shouldHide ? 'none' : '';
|
|
2435
|
+
});
|
|
2436
|
+
// Required-if
|
|
2437
|
+
document.querySelectorAll('[beam-required-if]').forEach((el) => {
|
|
2438
|
+
const condition = el.getAttribute('beam-required-if');
|
|
2439
|
+
const shouldRequire = evaluateCondition(condition);
|
|
2440
|
+
el.required = shouldRequire;
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
// Listen for input/change events to update conditional fields
|
|
2444
|
+
document.addEventListener('input', updateConditionalFields);
|
|
2445
|
+
document.addEventListener('change', updateConditionalFields);
|
|
2446
|
+
// Initial update
|
|
2447
|
+
updateConditionalFields();
|
|
2448
|
+
// Observe for new conditional elements
|
|
2449
|
+
const conditionalObserver = new MutationObserver(updateConditionalFields);
|
|
2450
|
+
conditionalObserver.observe(document.body, { childList: true, subtree: true });
|
|
1786
2451
|
// Clear scroll state for current page or all pages
|
|
1787
2452
|
// Usage: clearScrollState() - clear all for current URL
|
|
1788
2453
|
// clearScrollState('loadMore') - clear specific action
|
|
@@ -1845,20 +2510,27 @@ window.beam = new Proxy(beamUtils, {
|
|
|
1845
2510
|
location.href = response.redirect;
|
|
1846
2511
|
return response;
|
|
1847
2512
|
}
|
|
2513
|
+
// Handle modal (if present)
|
|
2514
|
+
if (response.modal) {
|
|
2515
|
+
const modalData = typeof response.modal === 'string'
|
|
2516
|
+
? { html: response.modal } : response.modal;
|
|
2517
|
+
openModal(modalData.html, modalData.size || 'medium', modalData.spacing);
|
|
2518
|
+
}
|
|
2519
|
+
// Handle drawer (if present)
|
|
2520
|
+
if (response.drawer) {
|
|
2521
|
+
const drawerData = typeof response.drawer === 'string'
|
|
2522
|
+
? { html: response.drawer } : response.drawer;
|
|
2523
|
+
openDrawer(drawerData.html, drawerData.position || 'right', drawerData.size || 'medium', drawerData.spacing);
|
|
2524
|
+
}
|
|
1848
2525
|
// Normalize options: string is shorthand for { target: string }
|
|
1849
2526
|
const opts = typeof options === 'string'
|
|
1850
2527
|
? { target: options }
|
|
1851
2528
|
: (options || {});
|
|
1852
2529
|
// Server target/swap override frontend options
|
|
1853
|
-
const targetSelector = response.target || opts.target;
|
|
2530
|
+
const targetSelector = response.target || opts.target || null;
|
|
1854
2531
|
const swapMode = response.swap || opts.swap || 'morph';
|
|
1855
|
-
// Handle HTML swap
|
|
1856
|
-
|
|
1857
|
-
const targetEl = document.querySelector(targetSelector);
|
|
1858
|
-
if (targetEl) {
|
|
1859
|
-
swap(targetEl, response.html, swapMode);
|
|
1860
|
-
}
|
|
1861
|
-
}
|
|
2532
|
+
// Handle HTML swap - supports single string or array
|
|
2533
|
+
handleHtmlResponse(response, targetSelector, swapMode);
|
|
1862
2534
|
// Execute script if present
|
|
1863
2535
|
if (response.script) {
|
|
1864
2536
|
executeScript(response.script);
|