@benqoder/beam 0.2.0 → 0.4.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
@@ -121,6 +125,42 @@ export function greet(c) {
121
125
  <div id="greeting"></div>
122
126
  ```
123
127
 
128
+ ### Including Input Values
129
+
130
+ Use `beam-include` to collect values from input elements and include them in action params. Elements are found by `beam-id`, `id`, or `name` (in that priority order):
131
+
132
+ ```html
133
+ <!-- Define inputs with beam-id, id, or name -->
134
+ <input beam-id="name" type="text" value="Ben"/>
135
+ <input id="email" type="email" value="ben@example.com"/>
136
+ <input name="age" type="number" value="30"/>
137
+ <input beam-id="subscribe" type="checkbox" checked/>
138
+
139
+ <!-- Button includes specific inputs -->
140
+ <button
141
+ beam-action="saveUser"
142
+ beam-include="name,email,age,subscribe"
143
+ beam-data-source="form"
144
+ beam-target="#result"
145
+ >Save</button>
146
+ ```
147
+
148
+ The action receives merged params with proper type conversion:
149
+ ```json
150
+ {
151
+ "source": "form",
152
+ "name": "Ben",
153
+ "email": "ben@example.com",
154
+ "age": 30,
155
+ "subscribe": true
156
+ }
157
+ ```
158
+
159
+ Type conversion:
160
+ - `checkbox` → `boolean` (checked state)
161
+ - `number`/`range` → `number`
162
+ - All others → `string`
163
+
124
164
  ### Modals
125
165
 
126
166
  Two ways to open modals:
@@ -304,6 +344,7 @@ Async components are awaited automatically - no manual `Promise.resolve()` or he
304
344
  | `beam-action` | Action name to call | `beam-action="increment"` |
305
345
  | `beam-target` | CSS selector for where to render response | `beam-target="#counter"` |
306
346
  | `beam-data-*` | Pass data to the action | `beam-data-id="123"` |
347
+ | `beam-include` | Include values from inputs by beam-id, id, or name | `beam-include="name,email,age"` |
307
348
  | `beam-swap` | How to swap content: `morph`, `append`, `prepend`, `replace` | `beam-swap="append"` |
308
349
  | `beam-confirm` | Show confirmation dialog before action | `beam-confirm="Delete this item?"` |
309
350
  | `beam-confirm-prompt` | Require typing text to confirm | `beam-confirm-prompt="Type DELETE\|DELETE"` |
@@ -359,6 +400,40 @@ return ctx.drawer(render(<MyDrawer />), { position: 'left', size: 'medium' })
359
400
  | `beam-watch` | Event to trigger validation: `input`, `change` | `beam-watch="input"` |
360
401
  | `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
361
402
 
403
+ ### Input Watchers
404
+
405
+ | Attribute | Description | Example |
406
+ |-----------|-------------|---------|
407
+ | `beam-watch` | Event to trigger action: `input`, `change` | `beam-watch="input"` |
408
+ | `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
409
+ | `beam-throttle` | Throttle interval in milliseconds (alternative to debounce) | `beam-throttle="100"` |
410
+ | `beam-watch-if` | Condition that must be true to trigger | `beam-watch-if="value.length >= 3"` |
411
+ | `beam-cast` | Cast input value: `number`, `integer`, `boolean`, `trim` | `beam-cast="number"` |
412
+ | `beam-loading-class` | Add class to input while request is in progress | `beam-loading-class="loading"` |
413
+ | `beam-keep` | Preserve input focus and cursor position after morph | `beam-keep` |
414
+
415
+ ### Dirty Form Tracking
416
+
417
+ | Attribute | Description | Example |
418
+ |-----------|-------------|---------|
419
+ | `beam-dirty-track` | Enable dirty tracking on a form | `<form beam-dirty-track>` |
420
+ | `beam-dirty-indicator` | Show element when form is dirty | `beam-dirty-indicator="#my-form"` |
421
+ | `beam-dirty-class` | Toggle class instead of visibility | `beam-dirty-class="has-changes"` |
422
+ | `beam-warn-unsaved` | Warn before leaving page with unsaved changes | `<form beam-warn-unsaved>` |
423
+ | `beam-revert` | Button to revert form to original values | `beam-revert="#my-form"` |
424
+ | `beam-show-if-dirty` | Show element when form is dirty | `beam-show-if-dirty="#my-form"` |
425
+ | `beam-hide-if-dirty` | Hide element when form is dirty | `beam-hide-if-dirty="#my-form"` |
426
+
427
+ ### Conditional Form Fields
428
+
429
+ | Attribute | Description | Example |
430
+ |-----------|-------------|---------|
431
+ | `beam-enable-if` | Enable field when condition is true | `beam-enable-if="#subscribe:checked"` |
432
+ | `beam-disable-if` | Disable field when condition is true | `beam-disable-if="#country[value='']"` |
433
+ | `beam-visible-if` | Show field when condition is true | `beam-visible-if="#source[value='other']"` |
434
+ | `beam-hidden-if` | Hide field when condition is true | `beam-hidden-if="#premium:checked"` |
435
+ | `beam-required-if` | Make field required when condition is true | `beam-required-if="#business:checked"` |
436
+
362
437
  ### Deferred Loading
363
438
 
364
439
  | Attribute | Description | Example |
@@ -602,6 +677,308 @@ export function submitForm(c) {
602
677
 
603
678
  ---
604
679
 
680
+ ## Input Watchers
681
+
682
+ Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.
683
+
684
+ ### Basic Usage
685
+
686
+ ```html
687
+ <!-- Live search with debounce -->
688
+ <input
689
+ name="q"
690
+ placeholder="Search..."
691
+ beam-action="search"
692
+ beam-target="#results"
693
+ beam-watch="input"
694
+ beam-debounce="300"
695
+ />
696
+ <div id="results"></div>
697
+ ```
698
+
699
+ ### Throttle vs Debounce
700
+
701
+ Use `beam-throttle` for real-time updates (like range sliders) where you want periodic updates:
702
+
703
+ ```html
704
+ <!-- Range slider with throttle - updates every 100ms while dragging -->
705
+ <input
706
+ type="range"
707
+ name="price"
708
+ beam-action="updatePrice"
709
+ beam-target="#price-display"
710
+ beam-watch="input"
711
+ beam-throttle="100"
712
+ />
713
+
714
+ <!-- Search with debounce - waits 300ms after user stops typing -->
715
+ <input
716
+ name="q"
717
+ beam-action="search"
718
+ beam-target="#results"
719
+ beam-watch="input"
720
+ beam-debounce="300"
721
+ />
722
+ ```
723
+
724
+ ### Conditional Triggers
725
+
726
+ Only trigger action when a condition is met:
727
+
728
+ ```html
729
+ <!-- Only search when 3+ characters are typed -->
730
+ <input
731
+ name="q"
732
+ placeholder="Type 3+ chars to search..."
733
+ beam-action="search"
734
+ beam-target="#results"
735
+ beam-watch="input"
736
+ beam-watch-if="value.length >= 3"
737
+ beam-debounce="300"
738
+ />
739
+ ```
740
+
741
+ The condition has access to `value` (current input value) and `this` (the element).
742
+
743
+ ### Type Casting
744
+
745
+ Cast input values before sending to the server:
746
+
747
+ ```html
748
+ <!-- Send as number instead of string -->
749
+ <input
750
+ type="range"
751
+ name="quantity"
752
+ beam-action="updateQuantity"
753
+ beam-cast="number"
754
+ beam-watch="input"
755
+ />
756
+ ```
757
+
758
+ Cast types:
759
+ - `number` - Parse as float
760
+ - `integer` - Parse as integer
761
+ - `boolean` - Convert "true"/"1"/"yes" to true
762
+ - `trim` - Trim whitespace
763
+
764
+ ### Loading Feedback
765
+
766
+ Add a class to the input while the request is in progress:
767
+
768
+ ```html
769
+ <input
770
+ name="q"
771
+ placeholder="Search..."
772
+ beam-action="search"
773
+ beam-target="#results"
774
+ beam-watch="input"
775
+ beam-loading-class="input-loading"
776
+ />
777
+
778
+ <style>
779
+ .input-loading {
780
+ border-color: blue;
781
+ animation: pulse 1s infinite;
782
+ }
783
+ </style>
784
+ ```
785
+
786
+ ### Preserving Focus
787
+
788
+ Use `beam-keep` to preserve focus and cursor position after the response morphs the DOM:
789
+
790
+ ```html
791
+ <input
792
+ name="bio"
793
+ beam-action="validateBio"
794
+ beam-target="#bio-feedback"
795
+ beam-watch="input"
796
+ beam-keep
797
+ />
798
+ ```
799
+
800
+ ### Auto-Save on Blur
801
+
802
+ Trigger action when the user leaves the field:
803
+
804
+ ```html
805
+ <input
806
+ name="username"
807
+ beam-action="saveField"
808
+ beam-data-field="username"
809
+ beam-target="#save-status"
810
+ beam-watch="change"
811
+ beam-keep
812
+ />
813
+ <div id="save-status">Not saved yet</div>
814
+ ```
815
+
816
+ ---
817
+
818
+ ## Dirty Form Tracking
819
+
820
+ Track form changes and warn users before losing unsaved work.
821
+
822
+ ### Basic Usage
823
+
824
+ ```html
825
+ <form id="profile-form" beam-dirty-track>
826
+ <input name="username" value="johndoe" />
827
+ <input name="email" value="john@example.com" />
828
+ <button type="submit">Save</button>
829
+ </form>
830
+ ```
831
+
832
+ The form gets a `beam-dirty` attribute when modified.
833
+
834
+ ### Dirty Indicator
835
+
836
+ Show an indicator when the form has unsaved changes:
837
+
838
+ ```html
839
+ <h2>
840
+ Profile Settings
841
+ <span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
842
+ </h2>
843
+
844
+ <form id="profile-form" beam-dirty-track>
845
+ <!-- form fields -->
846
+ </form>
847
+
848
+ <style>
849
+ [beam-dirty-indicator] { display: none; color: orange; }
850
+ </style>
851
+ ```
852
+
853
+ ### Revert Changes
854
+
855
+ Add a button to restore original values:
856
+
857
+ ```html
858
+ <form id="profile-form" beam-dirty-track>
859
+ <input name="username" value="johndoe" />
860
+ <input name="email" value="john@example.com" />
861
+
862
+ <button type="button" beam-revert="#profile-form" beam-show-if-dirty="#profile-form">
863
+ Revert Changes
864
+ </button>
865
+ <button type="submit">Save</button>
866
+ </form>
867
+ ```
868
+
869
+ The revert button only shows when the form is dirty.
870
+
871
+ ### Unsaved Changes Warning
872
+
873
+ Warn users before navigating away with unsaved changes:
874
+
875
+ ```html
876
+ <form beam-dirty-track beam-warn-unsaved>
877
+ <input name="important-data" />
878
+ <button type="submit">Save</button>
879
+ </form>
880
+ ```
881
+
882
+ The browser will show a confirmation dialog if the user tries to close the tab or navigate away.
883
+
884
+ ### Conditional Visibility
885
+
886
+ Show/hide elements based on dirty state:
887
+
888
+ ```html
889
+ <form id="settings" beam-dirty-track>
890
+ <!-- Show when dirty -->
891
+ <div beam-show-if-dirty="#settings" class="warning">
892
+ You have unsaved changes
893
+ </div>
894
+
895
+ <!-- Hide when dirty -->
896
+ <div beam-hide-if-dirty="#settings">
897
+ All changes saved
898
+ </div>
899
+ </form>
900
+ ```
901
+
902
+ ---
903
+
904
+ ## Conditional Form Fields
905
+
906
+ Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.
907
+
908
+ ### Enable/Disable Fields
909
+
910
+ ```html
911
+ <label>
912
+ <input type="checkbox" id="subscribe" name="subscribe" />
913
+ Subscribe to newsletter
914
+ </label>
915
+
916
+ <!-- Enabled only when checkbox is checked -->
917
+ <input
918
+ type="email"
919
+ name="email"
920
+ placeholder="Enter your email..."
921
+ beam-enable-if="#subscribe:checked"
922
+ disabled
923
+ />
924
+ ```
925
+
926
+ ### Show/Hide Fields
927
+
928
+ ```html
929
+ <select name="source" id="source">
930
+ <option value="">-- Select --</option>
931
+ <option value="google">Google</option>
932
+ <option value="friend">Friend</option>
933
+ <option value="other">Other</option>
934
+ </select>
935
+
936
+ <!-- Only visible when "other" is selected -->
937
+ <div beam-visible-if="#source[value='other']">
938
+ <label>Please specify</label>
939
+ <input type="text" name="source-other" />
940
+ </div>
941
+ ```
942
+
943
+ ### Required Fields
944
+
945
+ ```html
946
+ <label>
947
+ <input type="checkbox" id="business" name="is-business" />
948
+ This is a business account
949
+ </label>
950
+
951
+ <!-- Required only when checkbox is checked -->
952
+ <input
953
+ type="text"
954
+ name="company"
955
+ placeholder="Company name"
956
+ beam-required-if="#business:checked"
957
+ />
958
+ ```
959
+
960
+ ### Condition Syntax
961
+
962
+ Conditions support:
963
+ - `:checked` - Checkbox/radio is checked
964
+ - `:disabled` - Element is disabled
965
+ - `:empty` - Input has no value
966
+ - `[value='x']` - Input value equals 'x'
967
+ - `[value!='x']` - Input value not equals 'x'
968
+ - `[value>'5']` - Numeric comparison
969
+
970
+ ```html
971
+ <!-- Enable when country is selected -->
972
+ <select beam-disable-if="#country[value='']" name="state">
973
+
974
+ <!-- Show when amount is over 100 -->
975
+ <div beam-visible-if="#amount[value>'100']">
976
+ Large order discount applied!
977
+ </div>
978
+ ```
979
+
980
+ ---
981
+
605
982
  ## Deferred Loading
606
983
 
607
984
  Load content only when it enters the viewport:
package/dist/client.d.ts CHANGED
@@ -31,6 +31,8 @@ interface CallOptions {
31
31
  swap?: string;
32
32
  }
33
33
  declare function clearScrollState(actionOrAll?: string | boolean): void;
34
+ declare function checkWsConnected(): boolean;
35
+ declare function manualReconnect(): Promise<BeamServerStub>;
34
36
  declare const beamUtils: {
35
37
  showToast: typeof showToast;
36
38
  closeModal: typeof closeModal;
@@ -38,6 +40,8 @@ declare const beamUtils: {
38
40
  clearCache: typeof clearCache;
39
41
  clearScrollState: typeof clearScrollState;
40
42
  isOnline: () => boolean;
43
+ isConnected: typeof checkWsConnected;
44
+ reconnect: typeof manualReconnect;
41
45
  getSession: () => Promise<BeamServerStub>;
42
46
  };
43
47
  type ActionCaller = (data?: Record<string, unknown>, options?: string | CallOptions) => Promise<ActionResponse>;
@@ -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;AAq8BzC,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,iBAAS,gBAAgB,IAAI,OAAO,CAEnC;AAED,iBAAS,eAAe,IAAI,OAAO,CAAC,cAAc,CAAC,CAGlD;AAED,QAAA,MAAM,SAAS;;;;;;;;;sBA5rFO,OAAO,CAAC,cAAc,CAAC;CAssF5C,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
@@ -27,6 +27,10 @@ function getAuthToken() {
27
27
  let isOnline = navigator.onLine;
28
28
  let rpcSession = null;
29
29
  let connectingPromise = null;
30
+ let wsConnected = false;
31
+ let reconnectAttempts = 0;
32
+ const MAX_RECONNECT_ATTEMPTS = 5;
33
+ const RECONNECT_DELAY_BASE = 1000;
30
34
  // Client callback handler for server-initiated updates
31
35
  function handleServerEvent(event, data) {
32
36
  // Dispatch custom event for app to handle
@@ -42,6 +46,43 @@ function handleServerEvent(event, data) {
42
46
  window.dispatchEvent(new CustomEvent('beam:refresh', { detail: { selector } }));
43
47
  }
44
48
  }
49
+ // Handle WebSocket disconnection
50
+ function handleWsDisconnect(error) {
51
+ console.warn('[beam] WebSocket disconnected:', error);
52
+ wsConnected = false;
53
+ rpcSession = null;
54
+ connectingPromise = null;
55
+ // Dispatch event for app to handle
56
+ window.dispatchEvent(new CustomEvent('beam:disconnected', { detail: { error } }));
57
+ document.body.classList.add('beam-disconnected');
58
+ // Show any disconnect indicators
59
+ document.querySelectorAll('[beam-disconnected]').forEach((el) => {
60
+ el.style.display = '';
61
+ });
62
+ // Auto-reconnect with exponential backoff
63
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
64
+ const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts);
65
+ reconnectAttempts++;
66
+ console.log(`[beam] Reconnecting in ${delay}ms (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
67
+ setTimeout(() => {
68
+ connect().then(() => {
69
+ console.log('[beam] Reconnected');
70
+ document.body.classList.remove('beam-disconnected');
71
+ document.querySelectorAll('[beam-disconnected]').forEach((el) => {
72
+ el.style.display = 'none';
73
+ });
74
+ window.dispatchEvent(new CustomEvent('beam:reconnected'));
75
+ }).catch((err) => {
76
+ console.error('[beam] Reconnect failed:', err);
77
+ });
78
+ }, delay);
79
+ }
80
+ else {
81
+ console.error('[beam] Max reconnect attempts reached');
82
+ showToast('Connection lost. Please refresh the page.', 'error');
83
+ window.dispatchEvent(new CustomEvent('beam:reconnect-failed'));
84
+ }
85
+ }
45
86
  function connect() {
46
87
  if (connectingPromise) {
47
88
  return connectingPromise;
@@ -70,8 +111,16 @@ function connect() {
70
111
  authenticatedSession.registerCallback?.(handleServerEvent)?.catch?.(() => {
71
112
  // Server may not support callbacks, that's ok
72
113
  });
114
+ // Handle connection broken (WebSocket disconnect)
115
+ // @ts-ignore - onRpcBroken is available on capnweb stubs
116
+ if (typeof authenticatedSession.onRpcBroken === 'function') {
117
+ authenticatedSession.onRpcBroken(handleWsDisconnect);
118
+ }
73
119
  rpcSession = authenticatedSession;
74
120
  connectingPromise = null;
121
+ wsConnected = true;
122
+ reconnectAttempts = 0;
123
+ window.dispatchEvent(new CustomEvent('beam:connected'));
75
124
  return authenticatedSession;
76
125
  }
77
126
  catch (err) {
@@ -120,51 +169,42 @@ function $$(selector) {
120
169
  return document.querySelectorAll(selector);
121
170
  }
122
171
  function morph(target, html, options) {
123
- // Handle beam-keep elements
124
172
  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
173
  // @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;
174
+ Idiomorph.morph(target, html, {
175
+ morphStyle: 'innerHTML',
176
+ callbacks: {
177
+ // Skip morphing elements marked with beam-keep
178
+ beforeNodeMorphed: (fromEl, toEl) => {
179
+ // Only handle Element nodes
180
+ if (!(fromEl instanceof Element))
181
+ return true;
182
+ // Check if element has beam-keep attribute
183
+ if (fromEl.hasAttribute('beam-keep')) {
184
+ // Don't morph this element - keep it as is
185
+ return false;
186
+ }
187
+ // Check if element matches any keep selectors
188
+ for (const selector of keepSelectors) {
189
+ try {
190
+ if (fromEl.matches(selector)) {
191
+ return false;
192
+ }
193
+ }
194
+ catch {
195
+ // Invalid selector, ignore
196
+ }
197
+ }
198
+ return true;
199
+ },
200
+ // Prevent removal of beam-keep elements
201
+ beforeNodeRemoved: (node) => {
202
+ if (node instanceof Element && node.hasAttribute('beam-keep')) {
203
+ return false;
204
+ }
205
+ return true;
161
206
  }
162
207
  }
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
208
  });
169
209
  }
170
210
  function getParams(el) {
@@ -183,8 +223,42 @@ function getParams(el) {
183
223
  }
184
224
  }
185
225
  }
226
+ // Handle beam-include: collect values from referenced inputs
227
+ const includeAttr = el.getAttribute('beam-include');
228
+ if (includeAttr) {
229
+ const ids = includeAttr.split(',').map(id => id.trim());
230
+ for (const id of ids) {
231
+ // Find element by beam-id, id, or name (priority order)
232
+ const inputEl = document.querySelector(`[beam-id="${id}"]`) ||
233
+ document.getElementById(id) ||
234
+ document.querySelector(`[name="${id}"]`);
235
+ if (inputEl) {
236
+ params[id] = getIncludedInputValue(inputEl);
237
+ }
238
+ }
239
+ }
186
240
  return params;
187
241
  }
242
+ // Get value from an included input element with proper type conversion
243
+ function getIncludedInputValue(el) {
244
+ if (el.tagName === 'INPUT') {
245
+ const input = el;
246
+ if (input.type === 'checkbox')
247
+ return input.checked;
248
+ if (input.type === 'radio')
249
+ return input.checked ? input.value : '';
250
+ if (input.type === 'number' || input.type === 'range') {
251
+ const num = parseFloat(input.value);
252
+ return isNaN(num) ? 0 : num;
253
+ }
254
+ return input.value;
255
+ }
256
+ if (el.tagName === 'TEXTAREA')
257
+ return el.value;
258
+ if (el.tagName === 'SELECT')
259
+ return el.value;
260
+ return '';
261
+ }
188
262
  // ============ CONFIRMATION DIALOGS ============
189
263
  // Usage: <button beam-action="delete" beam-confirm="Are you sure?">Delete</button>
190
264
  // Usage: <button beam-action="delete" beam-confirm.prompt="Type DELETE to confirm|DELETE">Delete</button>
@@ -623,7 +697,7 @@ document.addEventListener('click', async (e) => {
623
697
  const target = e.target;
624
698
  if (!target?.closest)
625
699
  return;
626
- const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite])');
700
+ const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite]):not([beam-watch])');
627
701
  if (!btn || btn.tagName === 'FORM')
628
702
  return;
629
703
  // Skip if submit button inside a beam form
@@ -1649,6 +1723,240 @@ document.querySelectorAll('[beam-validate]').forEach((el) => {
1649
1723
  el.setAttribute('beam-validation-observed', '');
1650
1724
  setupValidation(el);
1651
1725
  });
1726
+ // ============ INPUT WATCHERS ============
1727
+ // Usage: <input name="q" beam-action="search" beam-target="#results" beam-watch="input" beam-debounce="300">
1728
+ // Usage: <input type="range" beam-action="update" beam-watch="input" beam-throttle="100">
1729
+ // Usage: <input beam-watch="input" beam-watch-if="value.length >= 3">
1730
+ // Handles standalone inputs with beam-action + beam-watch (not using beam-validate)
1731
+ function isInputElement(el) {
1732
+ return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
1733
+ }
1734
+ function getInputValue(el) {
1735
+ if (el.tagName === 'INPUT') {
1736
+ const input = el;
1737
+ if (input.type === 'checkbox')
1738
+ return input.checked;
1739
+ if (input.type === 'radio')
1740
+ return input.checked ? input.value : '';
1741
+ return input.value;
1742
+ }
1743
+ if (el.tagName === 'TEXTAREA')
1744
+ return el.value;
1745
+ if (el.tagName === 'SELECT')
1746
+ return el.value;
1747
+ return '';
1748
+ }
1749
+ // Cast value based on beam-cast attribute
1750
+ function castValue(value, castType) {
1751
+ if (!castType || typeof value !== 'string')
1752
+ return value;
1753
+ switch (castType) {
1754
+ case 'number':
1755
+ const num = parseFloat(value);
1756
+ return isNaN(num) ? 0 : num;
1757
+ case 'integer':
1758
+ const int = parseInt(value, 10);
1759
+ return isNaN(int) ? 0 : int;
1760
+ case 'boolean':
1761
+ return value === 'true' || value === '1' || value === 'yes';
1762
+ case 'trim':
1763
+ return value.trim();
1764
+ default:
1765
+ return value;
1766
+ }
1767
+ }
1768
+ // Check if condition is met for beam-watch-if
1769
+ function checkWatchCondition(el, value) {
1770
+ const condition = el.getAttribute('beam-watch-if');
1771
+ if (!condition)
1772
+ return true;
1773
+ try {
1774
+ // Create a function that evaluates the condition with 'value' and 'this' context
1775
+ const fn = new Function('value', `with(this) { return ${condition} }`);
1776
+ return Boolean(fn.call(el, value));
1777
+ }
1778
+ catch (e) {
1779
+ console.warn('[beam] Invalid beam-watch-if condition:', condition, e);
1780
+ return true;
1781
+ }
1782
+ }
1783
+ // Create throttle function
1784
+ function createThrottle(fn, limit) {
1785
+ let lastRun = 0;
1786
+ let timeout = null;
1787
+ return () => {
1788
+ const now = Date.now();
1789
+ const timeSinceLastRun = now - lastRun;
1790
+ if (timeSinceLastRun >= limit) {
1791
+ lastRun = now;
1792
+ fn();
1793
+ }
1794
+ else if (!timeout) {
1795
+ // Schedule to run after remaining time
1796
+ timeout = setTimeout(() => {
1797
+ lastRun = Date.now();
1798
+ timeout = null;
1799
+ fn();
1800
+ }, limit - timeSinceLastRun);
1801
+ }
1802
+ };
1803
+ }
1804
+ function setupInputWatcher(el) {
1805
+ if (!isInputElement(el))
1806
+ return;
1807
+ const htmlEl = el;
1808
+ const event = htmlEl.getAttribute('beam-watch') || 'change';
1809
+ const debounceMs = htmlEl.getAttribute('beam-debounce');
1810
+ const throttleMs = htmlEl.getAttribute('beam-throttle');
1811
+ const action = htmlEl.getAttribute('beam-action');
1812
+ const targetSelector = htmlEl.getAttribute('beam-target');
1813
+ const swapMode = htmlEl.getAttribute('beam-swap') || 'morph';
1814
+ const castType = htmlEl.getAttribute('beam-cast');
1815
+ const loadingClass = htmlEl.getAttribute('beam-loading-class');
1816
+ if (!action)
1817
+ return;
1818
+ let debounceTimeout;
1819
+ const executeAction = async (eventType) => {
1820
+ const name = htmlEl.getAttribute('name');
1821
+ let value = getInputValue(el);
1822
+ // Apply type casting
1823
+ value = castValue(value, castType);
1824
+ // Check conditional trigger
1825
+ if (!checkWatchCondition(htmlEl, value))
1826
+ return;
1827
+ const params = getParams(htmlEl);
1828
+ // Add the input's value to params
1829
+ if (name) {
1830
+ params[name] = value;
1831
+ }
1832
+ // Handle checkboxes specially - they might be part of a group
1833
+ if (el.tagName === 'INPUT' && el.type === 'checkbox') {
1834
+ const form = el.closest('form');
1835
+ if (form && name) {
1836
+ const checkboxes = form.querySelectorAll(`input[type="checkbox"][name="${name}"]`);
1837
+ if (checkboxes.length > 1) {
1838
+ const values = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
1839
+ params[name] = values;
1840
+ }
1841
+ }
1842
+ }
1843
+ // Only restore focus for "input" events, not "change" (blur) events
1844
+ const shouldRestoreFocus = htmlEl.hasAttribute('beam-keep') && eventType === 'input';
1845
+ const activeElement = document.activeElement;
1846
+ // Add loading class if specified
1847
+ if (loadingClass)
1848
+ htmlEl.classList.add(loadingClass);
1849
+ // Mark touched
1850
+ htmlEl.setAttribute('beam-touched', '');
1851
+ try {
1852
+ const response = await api.call(action, params);
1853
+ if (response.html && targetSelector) {
1854
+ const targets = $$(targetSelector);
1855
+ const htmlArray = Array.isArray(response.html) ? response.html : [response.html];
1856
+ targets.forEach((target, i) => {
1857
+ const html = htmlArray[i] || htmlArray[0];
1858
+ if (html) {
1859
+ if (swapMode === 'append') {
1860
+ target.insertAdjacentHTML('beforeend', html);
1861
+ }
1862
+ else if (swapMode === 'prepend') {
1863
+ target.insertAdjacentHTML('afterbegin', html);
1864
+ }
1865
+ else if (swapMode === 'replace') {
1866
+ target.outerHTML = html;
1867
+ }
1868
+ else {
1869
+ morph(target, html);
1870
+ }
1871
+ }
1872
+ });
1873
+ }
1874
+ // Process OOB updates (beam-touch templates)
1875
+ if (response.html) {
1876
+ const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
1877
+ const { oob } = parseOobSwaps(htmlStr);
1878
+ for (const { selector, content, swapMode: oobSwapMode } of oob) {
1879
+ const oobTarget = $(selector);
1880
+ if (oobTarget) {
1881
+ if (oobSwapMode === 'morph' || !oobSwapMode) {
1882
+ morph(oobTarget, content);
1883
+ }
1884
+ else {
1885
+ swap(oobTarget, content, oobSwapMode);
1886
+ }
1887
+ }
1888
+ }
1889
+ }
1890
+ // Execute script if present
1891
+ if (response.script) {
1892
+ executeScript(response.script);
1893
+ }
1894
+ // Restore focus if beam-keep is set and this was an input event (not change/blur)
1895
+ if (shouldRestoreFocus && activeElement instanceof HTMLElement) {
1896
+ const newEl = document.querySelector(`[name="${name}"]`);
1897
+ if (newEl && newEl !== activeElement) {
1898
+ newEl.focus();
1899
+ if (newEl instanceof HTMLInputElement || newEl instanceof HTMLTextAreaElement) {
1900
+ const cursorPos = activeElement.selectionStart;
1901
+ if (cursorPos !== null) {
1902
+ newEl.setSelectionRange(cursorPos, cursorPos);
1903
+ }
1904
+ }
1905
+ }
1906
+ }
1907
+ }
1908
+ catch (err) {
1909
+ console.error('Input watcher error:', err);
1910
+ }
1911
+ finally {
1912
+ // Remove loading class
1913
+ if (loadingClass)
1914
+ htmlEl.classList.remove(loadingClass);
1915
+ }
1916
+ };
1917
+ // Create the appropriate handler based on throttle vs debounce
1918
+ let handler;
1919
+ if (throttleMs) {
1920
+ // Use throttle mode
1921
+ const throttle = parseInt(throttleMs, 10);
1922
+ const throttledFn = createThrottle(() => executeAction('input'), throttle);
1923
+ handler = (e) => {
1924
+ throttledFn();
1925
+ };
1926
+ }
1927
+ else {
1928
+ // Use debounce mode (default)
1929
+ const debounce = parseInt(debounceMs || '300', 10);
1930
+ handler = (e) => {
1931
+ clearTimeout(debounceTimeout);
1932
+ const eventType = e.type;
1933
+ debounceTimeout = setTimeout(() => executeAction(eventType), debounce);
1934
+ };
1935
+ }
1936
+ // Support multiple events (comma-separated)
1937
+ const events = event.split(',').map(e => e.trim());
1938
+ events.forEach(evt => {
1939
+ htmlEl.addEventListener(evt, handler);
1940
+ });
1941
+ }
1942
+ // Observe input watcher elements (current and future)
1943
+ const inputWatcherObserver = new MutationObserver(() => {
1944
+ // Select inputs with beam-action + beam-watch but NOT beam-validate (which has its own handler)
1945
+ document.querySelectorAll('[beam-action][beam-watch]:not([beam-validate]):not([beam-input-observed])').forEach((el) => {
1946
+ if (!isInputElement(el))
1947
+ return;
1948
+ el.setAttribute('beam-input-observed', '');
1949
+ setupInputWatcher(el);
1950
+ });
1951
+ });
1952
+ inputWatcherObserver.observe(document.body, { childList: true, subtree: true });
1953
+ // Initialize existing input watcher elements
1954
+ document.querySelectorAll('[beam-action][beam-watch]:not([beam-validate])').forEach((el) => {
1955
+ if (!isInputElement(el))
1956
+ return;
1957
+ el.setAttribute('beam-input-observed', '');
1958
+ setupInputWatcher(el);
1959
+ });
1652
1960
  // ============ DEFERRED LOADING ============
1653
1961
  // Usage: <div beam-defer beam-action="loadComments" beam-target="#comments">Loading...</div>
1654
1962
  const deferObserver = new IntersectionObserver(async (entries) => {
@@ -1928,6 +2236,301 @@ document.addEventListener('click', (e) => {
1928
2236
  }
1929
2237
  }
1930
2238
  });
2239
+ // ============ DIRTY FORM TRACKING ============
2240
+ // Usage: <form beam-dirty-track>...</form>
2241
+ // Usage: <span beam-dirty-indicator="#my-form">*</span> (shows when form is dirty)
2242
+ // Usage: <form beam-warn-unsaved>...</form> (warns on page leave)
2243
+ // Store original form data for dirty checking
2244
+ const formOriginalData = new WeakMap();
2245
+ const dirtyForms = new Set();
2246
+ function getFormDataMap(form) {
2247
+ const map = new Map();
2248
+ const formData = new FormData(form);
2249
+ for (const [key, value] of formData.entries()) {
2250
+ const existing = map.get(key);
2251
+ if (existing) {
2252
+ // Handle multiple values (checkboxes, multi-select)
2253
+ map.set(key, existing + ',' + String(value));
2254
+ }
2255
+ else {
2256
+ map.set(key, String(value));
2257
+ }
2258
+ }
2259
+ return map;
2260
+ }
2261
+ function isFormDirty(form) {
2262
+ const original = formOriginalData.get(form);
2263
+ if (!original)
2264
+ return false;
2265
+ const current = getFormDataMap(form);
2266
+ // Check if any values changed
2267
+ for (const [key, value] of current.entries()) {
2268
+ if (original.get(key) !== value)
2269
+ return true;
2270
+ }
2271
+ for (const [key, value] of original.entries()) {
2272
+ if (current.get(key) !== value)
2273
+ return true;
2274
+ }
2275
+ return false;
2276
+ }
2277
+ function updateDirtyState(form) {
2278
+ const isDirty = isFormDirty(form);
2279
+ if (isDirty) {
2280
+ dirtyForms.add(form);
2281
+ form.setAttribute('beam-dirty', '');
2282
+ }
2283
+ else {
2284
+ dirtyForms.delete(form);
2285
+ form.removeAttribute('beam-dirty');
2286
+ }
2287
+ // Update dirty indicators
2288
+ updateDirtyIndicators();
2289
+ }
2290
+ function updateDirtyIndicators() {
2291
+ document.querySelectorAll('[beam-dirty-indicator]').forEach((indicator) => {
2292
+ const formSelector = indicator.getAttribute('beam-dirty-indicator');
2293
+ if (!formSelector)
2294
+ return;
2295
+ const form = document.querySelector(formSelector);
2296
+ const isDirty = form ? dirtyForms.has(form) : false;
2297
+ if (indicator.hasAttribute('beam-dirty-class')) {
2298
+ const className = indicator.getAttribute('beam-dirty-class');
2299
+ indicator.classList.toggle(className, isDirty);
2300
+ }
2301
+ else {
2302
+ indicator.style.display = isDirty ? '' : 'none';
2303
+ }
2304
+ });
2305
+ // Update show-if-dirty elements
2306
+ document.querySelectorAll('[beam-show-if-dirty]').forEach((el) => {
2307
+ const formSelector = el.getAttribute('beam-show-if-dirty');
2308
+ const form = formSelector
2309
+ ? document.querySelector(formSelector)
2310
+ : el.closest('form');
2311
+ const isDirty = form ? dirtyForms.has(form) : false;
2312
+ el.style.display = isDirty ? '' : 'none';
2313
+ });
2314
+ // Update hide-if-dirty elements
2315
+ document.querySelectorAll('[beam-hide-if-dirty]').forEach((el) => {
2316
+ const formSelector = el.getAttribute('beam-hide-if-dirty');
2317
+ const form = formSelector
2318
+ ? document.querySelector(formSelector)
2319
+ : el.closest('form');
2320
+ const isDirty = form ? dirtyForms.has(form) : false;
2321
+ el.style.display = isDirty ? 'none' : '';
2322
+ });
2323
+ }
2324
+ function setupDirtyTracking(form) {
2325
+ // Store original data
2326
+ formOriginalData.set(form, getFormDataMap(form));
2327
+ // Listen to input events on all form fields
2328
+ const checkDirty = () => updateDirtyState(form);
2329
+ form.addEventListener('input', checkDirty);
2330
+ form.addEventListener('change', checkDirty);
2331
+ // Reset dirty state on form submit
2332
+ form.addEventListener('submit', () => {
2333
+ // After successful submit, update original data
2334
+ setTimeout(() => {
2335
+ formOriginalData.set(form, getFormDataMap(form));
2336
+ updateDirtyState(form);
2337
+ }, 100);
2338
+ });
2339
+ // Handle form reset
2340
+ form.addEventListener('reset', () => {
2341
+ setTimeout(() => updateDirtyState(form), 0);
2342
+ });
2343
+ }
2344
+ // Observe dirty-tracked forms
2345
+ const dirtyFormObserver = new MutationObserver(() => {
2346
+ document.querySelectorAll('form[beam-dirty-track]:not([beam-dirty-observed])').forEach((form) => {
2347
+ form.setAttribute('beam-dirty-observed', '');
2348
+ setupDirtyTracking(form);
2349
+ });
2350
+ });
2351
+ dirtyFormObserver.observe(document.body, { childList: true, subtree: true });
2352
+ // Initialize existing dirty-tracked forms
2353
+ document.querySelectorAll('form[beam-dirty-track]').forEach((form) => {
2354
+ form.setAttribute('beam-dirty-observed', '');
2355
+ setupDirtyTracking(form);
2356
+ });
2357
+ // Initialize dirty indicators (hidden by default)
2358
+ document.querySelectorAll('[beam-dirty-indicator]:not([beam-dirty-class])').forEach((el) => {
2359
+ el.style.display = 'none';
2360
+ });
2361
+ document.querySelectorAll('[beam-show-if-dirty]').forEach((el) => {
2362
+ el.style.display = 'none';
2363
+ });
2364
+ // ============ UNSAVED CHANGES WARNING ============
2365
+ // Usage: <form beam-warn-unsaved>...</form>
2366
+ // Usage: <form beam-warn-unsaved="Are you sure? You have unsaved changes.">...</form>
2367
+ window.addEventListener('beforeunload', (e) => {
2368
+ // Check if any form with beam-warn-unsaved is dirty
2369
+ const formsWithWarning = document.querySelectorAll('form[beam-warn-unsaved]');
2370
+ let hasDirtyForm = false;
2371
+ formsWithWarning.forEach((form) => {
2372
+ if (dirtyForms.has(form)) {
2373
+ hasDirtyForm = true;
2374
+ }
2375
+ });
2376
+ if (hasDirtyForm) {
2377
+ e.preventDefault();
2378
+ // Modern browsers ignore custom messages, but we need to return something
2379
+ e.returnValue = '';
2380
+ return '';
2381
+ }
2382
+ });
2383
+ // ============ FORM REVERT ============
2384
+ // Usage: <button type="button" beam-revert="#my-form">Revert</button>
2385
+ // Usage: <button type="button" beam-revert>Revert</button> (inside form)
2386
+ document.addEventListener('click', (e) => {
2387
+ const target = e.target;
2388
+ if (!target?.closest)
2389
+ return;
2390
+ const trigger = target.closest('[beam-revert]');
2391
+ if (trigger) {
2392
+ e.preventDefault();
2393
+ const formSelector = trigger.getAttribute('beam-revert');
2394
+ const form = formSelector
2395
+ ? document.querySelector(formSelector)
2396
+ : trigger.closest('form');
2397
+ if (form) {
2398
+ const original = formOriginalData.get(form);
2399
+ if (original) {
2400
+ // Reset each field to its original value
2401
+ original.forEach((value, name) => {
2402
+ const fields = form.querySelectorAll(`[name="${name}"]`);
2403
+ fields.forEach((el) => {
2404
+ const field = el;
2405
+ if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
2406
+ // For checkboxes/radios, check if their value was in the original
2407
+ const values = value.split(',');
2408
+ field.checked = values.includes(field.value);
2409
+ }
2410
+ else if ('value' in field) {
2411
+ field.value = value;
2412
+ }
2413
+ });
2414
+ });
2415
+ // Handle fields that weren't in original (new fields) - reset them
2416
+ const currentFields = form.querySelectorAll('[name]');
2417
+ currentFields.forEach((el) => {
2418
+ const field = el;
2419
+ const name = field.getAttribute('name');
2420
+ if (name && !original.has(name)) {
2421
+ if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
2422
+ field.checked = false;
2423
+ }
2424
+ else if ('value' in field) {
2425
+ field.value = '';
2426
+ }
2427
+ }
2428
+ });
2429
+ // Dispatch input event for any watchers
2430
+ form.dispatchEvent(new Event('input', { bubbles: true }));
2431
+ updateDirtyState(form);
2432
+ }
2433
+ }
2434
+ }
2435
+ });
2436
+ // ============ CONDITIONAL FORM FIELDS ============
2437
+ // Usage: <input name="other" beam-enable-if="#has-other:checked">
2438
+ // Usage: <select beam-disable-if="#country[value='']">
2439
+ // Usage: <div beam-visible-if="#show-details:checked">Details here</div>
2440
+ function evaluateCondition(condition) {
2441
+ // Parse condition: "#selector:pseudo" or "#selector[attr='value']"
2442
+ const match = condition.match(/^([^:\[]+)(?::(\w+))?(?:\[([^\]]+)\])?$/);
2443
+ if (!match)
2444
+ return false;
2445
+ const [, selector, pseudo, attrCondition] = match;
2446
+ const el = document.querySelector(selector);
2447
+ if (!el)
2448
+ return false;
2449
+ // Check pseudo-class
2450
+ if (pseudo === 'checked') {
2451
+ return el.checked;
2452
+ }
2453
+ if (pseudo === 'disabled') {
2454
+ return el.disabled;
2455
+ }
2456
+ if (pseudo === 'empty') {
2457
+ return !el.value;
2458
+ }
2459
+ // Check attribute condition
2460
+ if (attrCondition) {
2461
+ const attrMatch = attrCondition.match(/(\w+)([=!<>]+)'?([^']*)'?/);
2462
+ if (attrMatch) {
2463
+ const [, attr, op, expected] = attrMatch;
2464
+ const actual = attr === 'value' ? el.value : el.getAttribute(attr);
2465
+ switch (op) {
2466
+ case '=':
2467
+ case '==':
2468
+ return actual === expected;
2469
+ case '!=':
2470
+ return actual !== expected;
2471
+ case '>':
2472
+ return Number(actual) > Number(expected);
2473
+ case '<':
2474
+ return Number(actual) < Number(expected);
2475
+ case '>=':
2476
+ return Number(actual) >= Number(expected);
2477
+ case '<=':
2478
+ return Number(actual) <= Number(expected);
2479
+ }
2480
+ }
2481
+ }
2482
+ // Default: check if element exists and has a truthy value
2483
+ if (el instanceof HTMLInputElement) {
2484
+ if (el.type === 'checkbox' || el.type === 'radio') {
2485
+ return el.checked;
2486
+ }
2487
+ return Boolean(el.value);
2488
+ }
2489
+ if (el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
2490
+ return Boolean(el.value);
2491
+ }
2492
+ return true;
2493
+ }
2494
+ function updateConditionalFields() {
2495
+ // Enable-if
2496
+ document.querySelectorAll('[beam-enable-if]').forEach((el) => {
2497
+ const condition = el.getAttribute('beam-enable-if');
2498
+ const shouldEnable = evaluateCondition(condition);
2499
+ el.disabled = !shouldEnable;
2500
+ });
2501
+ // Disable-if
2502
+ document.querySelectorAll('[beam-disable-if]').forEach((el) => {
2503
+ const condition = el.getAttribute('beam-disable-if');
2504
+ const shouldDisable = evaluateCondition(condition);
2505
+ el.disabled = shouldDisable;
2506
+ });
2507
+ // Visible-if (show when condition is true)
2508
+ document.querySelectorAll('[beam-visible-if]').forEach((el) => {
2509
+ const condition = el.getAttribute('beam-visible-if');
2510
+ const shouldShow = evaluateCondition(condition);
2511
+ el.style.display = shouldShow ? '' : 'none';
2512
+ });
2513
+ // Hidden-if (hide when condition is true)
2514
+ document.querySelectorAll('[beam-hidden-if]').forEach((el) => {
2515
+ const condition = el.getAttribute('beam-hidden-if');
2516
+ const shouldHide = evaluateCondition(condition);
2517
+ el.style.display = shouldHide ? 'none' : '';
2518
+ });
2519
+ // Required-if
2520
+ document.querySelectorAll('[beam-required-if]').forEach((el) => {
2521
+ const condition = el.getAttribute('beam-required-if');
2522
+ const shouldRequire = evaluateCondition(condition);
2523
+ el.required = shouldRequire;
2524
+ });
2525
+ }
2526
+ // Listen for input/change events to update conditional fields
2527
+ document.addEventListener('input', updateConditionalFields);
2528
+ document.addEventListener('change', updateConditionalFields);
2529
+ // Initial update
2530
+ updateConditionalFields();
2531
+ // Observe for new conditional elements
2532
+ const conditionalObserver = new MutationObserver(updateConditionalFields);
2533
+ conditionalObserver.observe(document.body, { childList: true, subtree: true });
1931
2534
  // Clear scroll state for current page or all pages
1932
2535
  // Usage: clearScrollState() - clear all for current URL
1933
2536
  // clearScrollState('loadMore') - clear specific action
@@ -1962,6 +2565,13 @@ function clearScrollState(actionOrAll) {
1962
2565
  }
1963
2566
  }
1964
2567
  // Base utilities that are always available on window.beam
2568
+ function checkWsConnected() {
2569
+ return wsConnected;
2570
+ }
2571
+ function manualReconnect() {
2572
+ reconnectAttempts = 0;
2573
+ return connect();
2574
+ }
1965
2575
  const beamUtils = {
1966
2576
  showToast,
1967
2577
  closeModal,
@@ -1969,6 +2579,8 @@ const beamUtils = {
1969
2579
  clearCache,
1970
2580
  clearScrollState,
1971
2581
  isOnline: () => isOnline,
2582
+ isConnected: checkWsConnected,
2583
+ reconnect: manualReconnect,
1972
2584
  getSession: api.getSession,
1973
2585
  };
1974
2586
  // Create a Proxy that handles both utility methods and dynamic action calls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benqoder/beam",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",