@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/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, { morphStyle: 'innerHTML' });
162
- // Restore kept elements
163
- keptElements.forEach(({ el, placeholder }, id) => {
164
- // Find the placeholder or element with same ID in new content
165
- const walker = document.createTreeWalker(target, NodeFilter.SHOW_COMMENT);
166
- let node;
167
- while ((node = walker.nextNode())) {
168
- if (node.textContent === `beam-keep:${id}`) {
169
- node.parentNode?.replaceChild(el, node);
170
- return;
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
- // Server target/swap override frontend values
488
- const targetSelector = response.target || frontendTarget;
489
- const swapMode = response.swap || frontendSwap;
490
- // Handle HTML (if present)
491
- if (response.html && targetSelector) {
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
- document.addEventListener('click', (e) => {
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 modalId = trigger.getAttribute('beam-modal');
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
- if (modalId) {
601
- openModal(modalId, params, { size });
654
+ const placeholder = trigger.getAttribute('beam-placeholder');
655
+ // Show placeholder modal while loading
656
+ if (placeholder) {
657
+ openModal(placeholder, size);
602
658
  }
603
- }
604
- // Close on backdrop click
605
- if (target.matches?.('#modal-backdrop')) {
606
- closeModal();
607
- }
608
- // Close button (handles both modal and drawer)
609
- const closeBtn = target.closest('[beam-close]');
610
- if (closeBtn && !closeBtn.hasAttribute('beam-action')) {
611
- if (activeDrawer) {
612
- closeDrawer();
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
- else {
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
- // Drawer triggers
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 drawerId = trigger.getAttribute('beam-drawer');
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
- if (drawerId) {
635
- openDrawer(drawerId, params, { position, size });
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
- async function openModal(id, params = {}, options = { size: 'medium' }) {
654
- try {
655
- const html = await api.modal(id, params);
656
- let backdrop = $('#modal-backdrop');
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
- async function openDrawer(id, params = {}, options) {
693
- try {
694
- const html = await api.drawer(id, params);
695
- let backdrop = $('#drawer-backdrop');
696
- if (!backdrop) {
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
- const target = $(targetSelector);
1129
- if (target && response.html) {
1130
- swap(target, response.html, swapMode, sentinel);
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
- const targetEl = $(targetSelector);
1190
- if (targetEl && response.html) {
1191
- swap(targetEl, response.html, swapMode, trigger);
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
- if (response.html && targetSelector) {
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 HTML (if present)
1432
- if (response.html && targetSelector) {
1433
- const target = $(targetSelector);
1434
- if (target) {
1435
- swap(target, response.html, swapMode);
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
- morph(target, response.html);
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
- swap(target, response.html, swapMode);
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
- swap(target, response.html, swapMode);
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 if target provided
1856
- if (response.html && targetSelector) {
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);