@benqoder/beam 0.2.0 → 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 CHANGED
@@ -11,6 +11,10 @@ A lightweight, declarative UI framework for building interactive web application
11
11
  - **Smart Loading** - Per-action loading indicators with parameter matching
12
12
  - **DOM Morphing** - Smooth updates via Idiomorph
13
13
  - **Real-time Validation** - Validate forms as users type
14
+ - **Input Watchers** - Trigger actions on input/change events with debounce/throttle
15
+ - **Conditional Triggers** - Only trigger when conditions are met (`beam-watch-if`)
16
+ - **Dirty Form Tracking** - Track unsaved changes with indicators and warnings
17
+ - **Conditional Fields** - Enable/disable/show/hide fields based on other values
14
18
  - **Deferred Loading** - Load content when scrolled into view
15
19
  - **Polling** - Auto-refresh content at intervals
16
20
  - **Hungry Elements** - Auto-update elements across actions
@@ -359,6 +363,40 @@ return ctx.drawer(render(<MyDrawer />), { position: 'left', size: 'medium' })
359
363
  | `beam-watch` | Event to trigger validation: `input`, `change` | `beam-watch="input"` |
360
364
  | `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
361
365
 
366
+ ### Input Watchers
367
+
368
+ | Attribute | Description | Example |
369
+ |-----------|-------------|---------|
370
+ | `beam-watch` | Event to trigger action: `input`, `change` | `beam-watch="input"` |
371
+ | `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
372
+ | `beam-throttle` | Throttle interval in milliseconds (alternative to debounce) | `beam-throttle="100"` |
373
+ | `beam-watch-if` | Condition that must be true to trigger | `beam-watch-if="value.length >= 3"` |
374
+ | `beam-cast` | Cast input value: `number`, `integer`, `boolean`, `trim` | `beam-cast="number"` |
375
+ | `beam-loading-class` | Add class to input while request is in progress | `beam-loading-class="loading"` |
376
+ | `beam-keep` | Preserve input focus and cursor position after morph | `beam-keep` |
377
+
378
+ ### Dirty Form Tracking
379
+
380
+ | Attribute | Description | Example |
381
+ |-----------|-------------|---------|
382
+ | `beam-dirty-track` | Enable dirty tracking on a form | `<form beam-dirty-track>` |
383
+ | `beam-dirty-indicator` | Show element when form is dirty | `beam-dirty-indicator="#my-form"` |
384
+ | `beam-dirty-class` | Toggle class instead of visibility | `beam-dirty-class="has-changes"` |
385
+ | `beam-warn-unsaved` | Warn before leaving page with unsaved changes | `<form beam-warn-unsaved>` |
386
+ | `beam-revert` | Button to revert form to original values | `beam-revert="#my-form"` |
387
+ | `beam-show-if-dirty` | Show element when form is dirty | `beam-show-if-dirty="#my-form"` |
388
+ | `beam-hide-if-dirty` | Hide element when form is dirty | `beam-hide-if-dirty="#my-form"` |
389
+
390
+ ### Conditional Form Fields
391
+
392
+ | Attribute | Description | Example |
393
+ |-----------|-------------|---------|
394
+ | `beam-enable-if` | Enable field when condition is true | `beam-enable-if="#subscribe:checked"` |
395
+ | `beam-disable-if` | Disable field when condition is true | `beam-disable-if="#country[value='']"` |
396
+ | `beam-visible-if` | Show field when condition is true | `beam-visible-if="#source[value='other']"` |
397
+ | `beam-hidden-if` | Hide field when condition is true | `beam-hidden-if="#premium:checked"` |
398
+ | `beam-required-if` | Make field required when condition is true | `beam-required-if="#business:checked"` |
399
+
362
400
  ### Deferred Loading
363
401
 
364
402
  | Attribute | Description | Example |
@@ -602,6 +640,308 @@ export function submitForm(c) {
602
640
 
603
641
  ---
604
642
 
643
+ ## Input Watchers
644
+
645
+ Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.
646
+
647
+ ### Basic Usage
648
+
649
+ ```html
650
+ <!-- Live search with debounce -->
651
+ <input
652
+ name="q"
653
+ placeholder="Search..."
654
+ beam-action="search"
655
+ beam-target="#results"
656
+ beam-watch="input"
657
+ beam-debounce="300"
658
+ />
659
+ <div id="results"></div>
660
+ ```
661
+
662
+ ### Throttle vs Debounce
663
+
664
+ Use `beam-throttle` for real-time updates (like range sliders) where you want periodic updates:
665
+
666
+ ```html
667
+ <!-- Range slider with throttle - updates every 100ms while dragging -->
668
+ <input
669
+ type="range"
670
+ name="price"
671
+ beam-action="updatePrice"
672
+ beam-target="#price-display"
673
+ beam-watch="input"
674
+ beam-throttle="100"
675
+ />
676
+
677
+ <!-- Search with debounce - waits 300ms after user stops typing -->
678
+ <input
679
+ name="q"
680
+ beam-action="search"
681
+ beam-target="#results"
682
+ beam-watch="input"
683
+ beam-debounce="300"
684
+ />
685
+ ```
686
+
687
+ ### Conditional Triggers
688
+
689
+ Only trigger action when a condition is met:
690
+
691
+ ```html
692
+ <!-- Only search when 3+ characters are typed -->
693
+ <input
694
+ name="q"
695
+ placeholder="Type 3+ chars to search..."
696
+ beam-action="search"
697
+ beam-target="#results"
698
+ beam-watch="input"
699
+ beam-watch-if="value.length >= 3"
700
+ beam-debounce="300"
701
+ />
702
+ ```
703
+
704
+ The condition has access to `value` (current input value) and `this` (the element).
705
+
706
+ ### Type Casting
707
+
708
+ Cast input values before sending to the server:
709
+
710
+ ```html
711
+ <!-- Send as number instead of string -->
712
+ <input
713
+ type="range"
714
+ name="quantity"
715
+ beam-action="updateQuantity"
716
+ beam-cast="number"
717
+ beam-watch="input"
718
+ />
719
+ ```
720
+
721
+ Cast types:
722
+ - `number` - Parse as float
723
+ - `integer` - Parse as integer
724
+ - `boolean` - Convert "true"/"1"/"yes" to true
725
+ - `trim` - Trim whitespace
726
+
727
+ ### Loading Feedback
728
+
729
+ Add a class to the input while the request is in progress:
730
+
731
+ ```html
732
+ <input
733
+ name="q"
734
+ placeholder="Search..."
735
+ beam-action="search"
736
+ beam-target="#results"
737
+ beam-watch="input"
738
+ beam-loading-class="input-loading"
739
+ />
740
+
741
+ <style>
742
+ .input-loading {
743
+ border-color: blue;
744
+ animation: pulse 1s infinite;
745
+ }
746
+ </style>
747
+ ```
748
+
749
+ ### Preserving Focus
750
+
751
+ Use `beam-keep` to preserve focus and cursor position after the response morphs the DOM:
752
+
753
+ ```html
754
+ <input
755
+ name="bio"
756
+ beam-action="validateBio"
757
+ beam-target="#bio-feedback"
758
+ beam-watch="input"
759
+ beam-keep
760
+ />
761
+ ```
762
+
763
+ ### Auto-Save on Blur
764
+
765
+ Trigger action when the user leaves the field:
766
+
767
+ ```html
768
+ <input
769
+ name="username"
770
+ beam-action="saveField"
771
+ beam-data-field="username"
772
+ beam-target="#save-status"
773
+ beam-watch="change"
774
+ beam-keep
775
+ />
776
+ <div id="save-status">Not saved yet</div>
777
+ ```
778
+
779
+ ---
780
+
781
+ ## Dirty Form Tracking
782
+
783
+ Track form changes and warn users before losing unsaved work.
784
+
785
+ ### Basic Usage
786
+
787
+ ```html
788
+ <form id="profile-form" beam-dirty-track>
789
+ <input name="username" value="johndoe" />
790
+ <input name="email" value="john@example.com" />
791
+ <button type="submit">Save</button>
792
+ </form>
793
+ ```
794
+
795
+ The form gets a `beam-dirty` attribute when modified.
796
+
797
+ ### Dirty Indicator
798
+
799
+ Show an indicator when the form has unsaved changes:
800
+
801
+ ```html
802
+ <h2>
803
+ Profile Settings
804
+ <span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
805
+ </h2>
806
+
807
+ <form id="profile-form" beam-dirty-track>
808
+ <!-- form fields -->
809
+ </form>
810
+
811
+ <style>
812
+ [beam-dirty-indicator] { display: none; color: orange; }
813
+ </style>
814
+ ```
815
+
816
+ ### Revert Changes
817
+
818
+ Add a button to restore original values:
819
+
820
+ ```html
821
+ <form id="profile-form" beam-dirty-track>
822
+ <input name="username" value="johndoe" />
823
+ <input name="email" value="john@example.com" />
824
+
825
+ <button type="button" beam-revert="#profile-form" beam-show-if-dirty="#profile-form">
826
+ Revert Changes
827
+ </button>
828
+ <button type="submit">Save</button>
829
+ </form>
830
+ ```
831
+
832
+ The revert button only shows when the form is dirty.
833
+
834
+ ### Unsaved Changes Warning
835
+
836
+ Warn users before navigating away with unsaved changes:
837
+
838
+ ```html
839
+ <form beam-dirty-track beam-warn-unsaved>
840
+ <input name="important-data" />
841
+ <button type="submit">Save</button>
842
+ </form>
843
+ ```
844
+
845
+ The browser will show a confirmation dialog if the user tries to close the tab or navigate away.
846
+
847
+ ### Conditional Visibility
848
+
849
+ Show/hide elements based on dirty state:
850
+
851
+ ```html
852
+ <form id="settings" beam-dirty-track>
853
+ <!-- Show when dirty -->
854
+ <div beam-show-if-dirty="#settings" class="warning">
855
+ You have unsaved changes
856
+ </div>
857
+
858
+ <!-- Hide when dirty -->
859
+ <div beam-hide-if-dirty="#settings">
860
+ All changes saved
861
+ </div>
862
+ </form>
863
+ ```
864
+
865
+ ---
866
+
867
+ ## Conditional Form Fields
868
+
869
+ Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.
870
+
871
+ ### Enable/Disable Fields
872
+
873
+ ```html
874
+ <label>
875
+ <input type="checkbox" id="subscribe" name="subscribe" />
876
+ Subscribe to newsletter
877
+ </label>
878
+
879
+ <!-- Enabled only when checkbox is checked -->
880
+ <input
881
+ type="email"
882
+ name="email"
883
+ placeholder="Enter your email..."
884
+ beam-enable-if="#subscribe:checked"
885
+ disabled
886
+ />
887
+ ```
888
+
889
+ ### Show/Hide Fields
890
+
891
+ ```html
892
+ <select name="source" id="source">
893
+ <option value="">-- Select --</option>
894
+ <option value="google">Google</option>
895
+ <option value="friend">Friend</option>
896
+ <option value="other">Other</option>
897
+ </select>
898
+
899
+ <!-- Only visible when "other" is selected -->
900
+ <div beam-visible-if="#source[value='other']">
901
+ <label>Please specify</label>
902
+ <input type="text" name="source-other" />
903
+ </div>
904
+ ```
905
+
906
+ ### Required Fields
907
+
908
+ ```html
909
+ <label>
910
+ <input type="checkbox" id="business" name="is-business" />
911
+ This is a business account
912
+ </label>
913
+
914
+ <!-- Required only when checkbox is checked -->
915
+ <input
916
+ type="text"
917
+ name="company"
918
+ placeholder="Company name"
919
+ beam-required-if="#business:checked"
920
+ />
921
+ ```
922
+
923
+ ### Condition Syntax
924
+
925
+ Conditions support:
926
+ - `:checked` - Checkbox/radio is checked
927
+ - `:disabled` - Element is disabled
928
+ - `:empty` - Input has no value
929
+ - `[value='x']` - Input value equals 'x'
930
+ - `[value!='x']` - Input value not equals 'x'
931
+ - `[value>'5']` - Numeric comparison
932
+
933
+ ```html
934
+ <!-- Enable when country is selected -->
935
+ <select beam-disable-if="#country[value='']" name="state">
936
+
937
+ <!-- Show when amount is over 100 -->
938
+ <div beam-visible-if="#amount[value>'100']">
939
+ Large order discount applied!
940
+ </div>
941
+ ```
942
+
943
+ ---
944
+
605
945
  ## Deferred Loading
606
946
 
607
947
  Load content only when it enters the viewport:
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAA0B,KAAK,OAAO,EAAE,MAAM,SAAS,CAAA;AA8B9D,UAAU,cAAc;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACxB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAClE,MAAM,CAAC,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACvF;AAGD,UAAU,UAAU;IAClB,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAAA;IAC7E,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAClF;AAQD,KAAK,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;AAu3BzC,iBAAS,UAAU,IAAI,IAAI,CAU1B;AAkCD,iBAAS,WAAW,IAAI,IAAI,CAU3B;AAID,iBAAS,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,SAAS,GAAG,OAAmB,GAAG,IAAI,CAsB/E;AAkrBD,iBAAS,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAUzC;AA8gBD,UAAU,WAAW;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAMD,iBAAS,gBAAgB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CA0B9D;AAGD,QAAA,MAAM,SAAS;;;;;;;sBAtlEO,OAAO,CAAC,cAAc,CAAC;CA8lE5C,CAAA;AAGD,KAAK,YAAY,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,WAAW,KAAK,OAAO,CAAC,cAAc,CAAC,CAAA;AAE/G,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,IAAI,EAAE,OAAO,SAAS,GAAG;YACvB,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAAA;SAC/B,CAAA;KACF;CACF"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAA0B,KAAK,OAAO,EAAE,MAAM,SAAS,CAAA;AA8B9D,UAAU,cAAc;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACxB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAClE,MAAM,CAAC,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CACvF;AAGD,UAAU,UAAU;IAClB,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,CAAA;IAC7E,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAClF;AAQD,KAAK,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CAAA;AA82BzC,iBAAS,UAAU,IAAI,IAAI,CAU1B;AAkCD,iBAAS,WAAW,IAAI,IAAI,CAU3B;AAID,iBAAS,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,SAAS,GAAG,OAAmB,GAAG,IAAI,CAsB/E;AAkrBD,iBAAS,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAUzC;AAmlCD,UAAU,WAAW;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAMD,iBAAS,gBAAgB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CA0B9D;AAGD,QAAA,MAAM,SAAS;;;;;;;sBAlpFO,OAAO,CAAC,cAAc,CAAC;CA0pF5C,CAAA;AAGD,KAAK,YAAY,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,WAAW,KAAK,OAAO,CAAC,cAAc,CAAC,CAAA;AAE/G,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,IAAI,EAAE,OAAO,SAAS,GAAG;YACvB,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAAA;SAC/B,CAAA;KACF;CACF"}
package/dist/client.js CHANGED
@@ -120,51 +120,42 @@ function $$(selector) {
120
120
  return document.querySelectorAll(selector);
121
121
  }
122
122
  function morph(target, html, options) {
123
- // Handle beam-keep elements
124
123
  const keepSelectors = options?.keepElements || [];
125
- const keptElements = new Map();
126
- // Preserve elements marked with beam-keep
127
- target.querySelectorAll('[beam-keep]').forEach((el) => {
128
- const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
129
- if (!el.id)
130
- el.id = id;
131
- const placeholder = document.createComment(`beam-keep:${id}`);
132
- el.parentNode?.insertBefore(placeholder, el);
133
- keptElements.set(id, { el, placeholder });
134
- el.remove();
135
- });
136
- // Also handle explicitly specified keep selectors
137
- keepSelectors.forEach((selector) => {
138
- target.querySelectorAll(selector).forEach((el) => {
139
- const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
140
- if (!el.id)
141
- el.id = id;
142
- if (!keptElements.has(id)) {
143
- const placeholder = document.createComment(`beam-keep:${id}`);
144
- el.parentNode?.insertBefore(placeholder, el);
145
- keptElements.set(id, { el, placeholder });
146
- el.remove();
147
- }
148
- });
149
- });
150
124
  // @ts-ignore - idiomorph types
151
- Idiomorph.morph(target, html, { morphStyle: 'innerHTML' });
152
- // Restore kept elements
153
- keptElements.forEach(({ el, placeholder }, id) => {
154
- // Find the placeholder or element with same ID in new content
155
- const walker = document.createTreeWalker(target, NodeFilter.SHOW_COMMENT);
156
- let node;
157
- while ((node = walker.nextNode())) {
158
- if (node.textContent === `beam-keep:${id}`) {
159
- node.parentNode?.replaceChild(el, node);
160
- 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;
161
157
  }
162
158
  }
163
- // If no placeholder, look for element with same ID to replace
164
- const newEl = target.querySelector(`#${id}`);
165
- if (newEl) {
166
- newEl.parentNode?.replaceChild(el, newEl);
167
- }
168
159
  });
169
160
  }
170
161
  function getParams(el) {
@@ -623,7 +614,7 @@ document.addEventListener('click', async (e) => {
623
614
  const target = e.target;
624
615
  if (!target?.closest)
625
616
  return;
626
- 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])');
627
618
  if (!btn || btn.tagName === 'FORM')
628
619
  return;
629
620
  // Skip if submit button inside a beam form
@@ -1649,6 +1640,240 @@ document.querySelectorAll('[beam-validate]').forEach((el) => {
1649
1640
  el.setAttribute('beam-validation-observed', '');
1650
1641
  setupValidation(el);
1651
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
+ });
1652
1877
  // ============ DEFERRED LOADING ============
1653
1878
  // Usage: <div beam-defer beam-action="loadComments" beam-target="#comments">Loading...</div>
1654
1879
  const deferObserver = new IntersectionObserver(async (entries) => {
@@ -1928,6 +2153,301 @@ document.addEventListener('click', (e) => {
1928
2153
  }
1929
2154
  }
1930
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 });
1931
2451
  // Clear scroll state for current page or all pages
1932
2452
  // Usage: clearScrollState() - clear all for current URL
1933
2453
  // clearScrollState('loadMore') - clear specific action
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benqoder/beam",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",