@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 +377 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +654 -42
- package/package.json +1 -1
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>;
|
package/dist/client.d.ts.map
CHANGED
|
@@ -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;
|
|
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, {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|