@benqoder/beam 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +340 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +562 -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
|
|
@@ -359,6 +363,40 @@ return ctx.drawer(render(<MyDrawer />), { position: 'left', size: 'medium' })
|
|
|
359
363
|
| `beam-watch` | Event to trigger validation: `input`, `change` | `beam-watch="input"` |
|
|
360
364
|
| `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
|
|
361
365
|
|
|
366
|
+
### Input Watchers
|
|
367
|
+
|
|
368
|
+
| Attribute | Description | Example |
|
|
369
|
+
|-----------|-------------|---------|
|
|
370
|
+
| `beam-watch` | Event to trigger action: `input`, `change` | `beam-watch="input"` |
|
|
371
|
+
| `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
|
|
372
|
+
| `beam-throttle` | Throttle interval in milliseconds (alternative to debounce) | `beam-throttle="100"` |
|
|
373
|
+
| `beam-watch-if` | Condition that must be true to trigger | `beam-watch-if="value.length >= 3"` |
|
|
374
|
+
| `beam-cast` | Cast input value: `number`, `integer`, `boolean`, `trim` | `beam-cast="number"` |
|
|
375
|
+
| `beam-loading-class` | Add class to input while request is in progress | `beam-loading-class="loading"` |
|
|
376
|
+
| `beam-keep` | Preserve input focus and cursor position after morph | `beam-keep` |
|
|
377
|
+
|
|
378
|
+
### Dirty Form Tracking
|
|
379
|
+
|
|
380
|
+
| Attribute | Description | Example |
|
|
381
|
+
|-----------|-------------|---------|
|
|
382
|
+
| `beam-dirty-track` | Enable dirty tracking on a form | `<form beam-dirty-track>` |
|
|
383
|
+
| `beam-dirty-indicator` | Show element when form is dirty | `beam-dirty-indicator="#my-form"` |
|
|
384
|
+
| `beam-dirty-class` | Toggle class instead of visibility | `beam-dirty-class="has-changes"` |
|
|
385
|
+
| `beam-warn-unsaved` | Warn before leaving page with unsaved changes | `<form beam-warn-unsaved>` |
|
|
386
|
+
| `beam-revert` | Button to revert form to original values | `beam-revert="#my-form"` |
|
|
387
|
+
| `beam-show-if-dirty` | Show element when form is dirty | `beam-show-if-dirty="#my-form"` |
|
|
388
|
+
| `beam-hide-if-dirty` | Hide element when form is dirty | `beam-hide-if-dirty="#my-form"` |
|
|
389
|
+
|
|
390
|
+
### Conditional Form Fields
|
|
391
|
+
|
|
392
|
+
| Attribute | Description | Example |
|
|
393
|
+
|-----------|-------------|---------|
|
|
394
|
+
| `beam-enable-if` | Enable field when condition is true | `beam-enable-if="#subscribe:checked"` |
|
|
395
|
+
| `beam-disable-if` | Disable field when condition is true | `beam-disable-if="#country[value='']"` |
|
|
396
|
+
| `beam-visible-if` | Show field when condition is true | `beam-visible-if="#source[value='other']"` |
|
|
397
|
+
| `beam-hidden-if` | Hide field when condition is true | `beam-hidden-if="#premium:checked"` |
|
|
398
|
+
| `beam-required-if` | Make field required when condition is true | `beam-required-if="#business:checked"` |
|
|
399
|
+
|
|
362
400
|
### Deferred Loading
|
|
363
401
|
|
|
364
402
|
| Attribute | Description | Example |
|
|
@@ -602,6 +640,308 @@ export function submitForm(c) {
|
|
|
602
640
|
|
|
603
641
|
---
|
|
604
642
|
|
|
643
|
+
## Input Watchers
|
|
644
|
+
|
|
645
|
+
Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.
|
|
646
|
+
|
|
647
|
+
### Basic Usage
|
|
648
|
+
|
|
649
|
+
```html
|
|
650
|
+
<!-- Live search with debounce -->
|
|
651
|
+
<input
|
|
652
|
+
name="q"
|
|
653
|
+
placeholder="Search..."
|
|
654
|
+
beam-action="search"
|
|
655
|
+
beam-target="#results"
|
|
656
|
+
beam-watch="input"
|
|
657
|
+
beam-debounce="300"
|
|
658
|
+
/>
|
|
659
|
+
<div id="results"></div>
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Throttle vs Debounce
|
|
663
|
+
|
|
664
|
+
Use `beam-throttle` for real-time updates (like range sliders) where you want periodic updates:
|
|
665
|
+
|
|
666
|
+
```html
|
|
667
|
+
<!-- Range slider with throttle - updates every 100ms while dragging -->
|
|
668
|
+
<input
|
|
669
|
+
type="range"
|
|
670
|
+
name="price"
|
|
671
|
+
beam-action="updatePrice"
|
|
672
|
+
beam-target="#price-display"
|
|
673
|
+
beam-watch="input"
|
|
674
|
+
beam-throttle="100"
|
|
675
|
+
/>
|
|
676
|
+
|
|
677
|
+
<!-- Search with debounce - waits 300ms after user stops typing -->
|
|
678
|
+
<input
|
|
679
|
+
name="q"
|
|
680
|
+
beam-action="search"
|
|
681
|
+
beam-target="#results"
|
|
682
|
+
beam-watch="input"
|
|
683
|
+
beam-debounce="300"
|
|
684
|
+
/>
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### Conditional Triggers
|
|
688
|
+
|
|
689
|
+
Only trigger action when a condition is met:
|
|
690
|
+
|
|
691
|
+
```html
|
|
692
|
+
<!-- Only search when 3+ characters are typed -->
|
|
693
|
+
<input
|
|
694
|
+
name="q"
|
|
695
|
+
placeholder="Type 3+ chars to search..."
|
|
696
|
+
beam-action="search"
|
|
697
|
+
beam-target="#results"
|
|
698
|
+
beam-watch="input"
|
|
699
|
+
beam-watch-if="value.length >= 3"
|
|
700
|
+
beam-debounce="300"
|
|
701
|
+
/>
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
The condition has access to `value` (current input value) and `this` (the element).
|
|
705
|
+
|
|
706
|
+
### Type Casting
|
|
707
|
+
|
|
708
|
+
Cast input values before sending to the server:
|
|
709
|
+
|
|
710
|
+
```html
|
|
711
|
+
<!-- Send as number instead of string -->
|
|
712
|
+
<input
|
|
713
|
+
type="range"
|
|
714
|
+
name="quantity"
|
|
715
|
+
beam-action="updateQuantity"
|
|
716
|
+
beam-cast="number"
|
|
717
|
+
beam-watch="input"
|
|
718
|
+
/>
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Cast types:
|
|
722
|
+
- `number` - Parse as float
|
|
723
|
+
- `integer` - Parse as integer
|
|
724
|
+
- `boolean` - Convert "true"/"1"/"yes" to true
|
|
725
|
+
- `trim` - Trim whitespace
|
|
726
|
+
|
|
727
|
+
### Loading Feedback
|
|
728
|
+
|
|
729
|
+
Add a class to the input while the request is in progress:
|
|
730
|
+
|
|
731
|
+
```html
|
|
732
|
+
<input
|
|
733
|
+
name="q"
|
|
734
|
+
placeholder="Search..."
|
|
735
|
+
beam-action="search"
|
|
736
|
+
beam-target="#results"
|
|
737
|
+
beam-watch="input"
|
|
738
|
+
beam-loading-class="input-loading"
|
|
739
|
+
/>
|
|
740
|
+
|
|
741
|
+
<style>
|
|
742
|
+
.input-loading {
|
|
743
|
+
border-color: blue;
|
|
744
|
+
animation: pulse 1s infinite;
|
|
745
|
+
}
|
|
746
|
+
</style>
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### Preserving Focus
|
|
750
|
+
|
|
751
|
+
Use `beam-keep` to preserve focus and cursor position after the response morphs the DOM:
|
|
752
|
+
|
|
753
|
+
```html
|
|
754
|
+
<input
|
|
755
|
+
name="bio"
|
|
756
|
+
beam-action="validateBio"
|
|
757
|
+
beam-target="#bio-feedback"
|
|
758
|
+
beam-watch="input"
|
|
759
|
+
beam-keep
|
|
760
|
+
/>
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Auto-Save on Blur
|
|
764
|
+
|
|
765
|
+
Trigger action when the user leaves the field:
|
|
766
|
+
|
|
767
|
+
```html
|
|
768
|
+
<input
|
|
769
|
+
name="username"
|
|
770
|
+
beam-action="saveField"
|
|
771
|
+
beam-data-field="username"
|
|
772
|
+
beam-target="#save-status"
|
|
773
|
+
beam-watch="change"
|
|
774
|
+
beam-keep
|
|
775
|
+
/>
|
|
776
|
+
<div id="save-status">Not saved yet</div>
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
## Dirty Form Tracking
|
|
782
|
+
|
|
783
|
+
Track form changes and warn users before losing unsaved work.
|
|
784
|
+
|
|
785
|
+
### Basic Usage
|
|
786
|
+
|
|
787
|
+
```html
|
|
788
|
+
<form id="profile-form" beam-dirty-track>
|
|
789
|
+
<input name="username" value="johndoe" />
|
|
790
|
+
<input name="email" value="john@example.com" />
|
|
791
|
+
<button type="submit">Save</button>
|
|
792
|
+
</form>
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
The form gets a `beam-dirty` attribute when modified.
|
|
796
|
+
|
|
797
|
+
### Dirty Indicator
|
|
798
|
+
|
|
799
|
+
Show an indicator when the form has unsaved changes:
|
|
800
|
+
|
|
801
|
+
```html
|
|
802
|
+
<h2>
|
|
803
|
+
Profile Settings
|
|
804
|
+
<span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
|
|
805
|
+
</h2>
|
|
806
|
+
|
|
807
|
+
<form id="profile-form" beam-dirty-track>
|
|
808
|
+
<!-- form fields -->
|
|
809
|
+
</form>
|
|
810
|
+
|
|
811
|
+
<style>
|
|
812
|
+
[beam-dirty-indicator] { display: none; color: orange; }
|
|
813
|
+
</style>
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
### Revert Changes
|
|
817
|
+
|
|
818
|
+
Add a button to restore original values:
|
|
819
|
+
|
|
820
|
+
```html
|
|
821
|
+
<form id="profile-form" beam-dirty-track>
|
|
822
|
+
<input name="username" value="johndoe" />
|
|
823
|
+
<input name="email" value="john@example.com" />
|
|
824
|
+
|
|
825
|
+
<button type="button" beam-revert="#profile-form" beam-show-if-dirty="#profile-form">
|
|
826
|
+
Revert Changes
|
|
827
|
+
</button>
|
|
828
|
+
<button type="submit">Save</button>
|
|
829
|
+
</form>
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
The revert button only shows when the form is dirty.
|
|
833
|
+
|
|
834
|
+
### Unsaved Changes Warning
|
|
835
|
+
|
|
836
|
+
Warn users before navigating away with unsaved changes:
|
|
837
|
+
|
|
838
|
+
```html
|
|
839
|
+
<form beam-dirty-track beam-warn-unsaved>
|
|
840
|
+
<input name="important-data" />
|
|
841
|
+
<button type="submit">Save</button>
|
|
842
|
+
</form>
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
The browser will show a confirmation dialog if the user tries to close the tab or navigate away.
|
|
846
|
+
|
|
847
|
+
### Conditional Visibility
|
|
848
|
+
|
|
849
|
+
Show/hide elements based on dirty state:
|
|
850
|
+
|
|
851
|
+
```html
|
|
852
|
+
<form id="settings" beam-dirty-track>
|
|
853
|
+
<!-- Show when dirty -->
|
|
854
|
+
<div beam-show-if-dirty="#settings" class="warning">
|
|
855
|
+
You have unsaved changes
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<!-- Hide when dirty -->
|
|
859
|
+
<div beam-hide-if-dirty="#settings">
|
|
860
|
+
All changes saved
|
|
861
|
+
</div>
|
|
862
|
+
</form>
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
## Conditional Form Fields
|
|
868
|
+
|
|
869
|
+
Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.
|
|
870
|
+
|
|
871
|
+
### Enable/Disable Fields
|
|
872
|
+
|
|
873
|
+
```html
|
|
874
|
+
<label>
|
|
875
|
+
<input type="checkbox" id="subscribe" name="subscribe" />
|
|
876
|
+
Subscribe to newsletter
|
|
877
|
+
</label>
|
|
878
|
+
|
|
879
|
+
<!-- Enabled only when checkbox is checked -->
|
|
880
|
+
<input
|
|
881
|
+
type="email"
|
|
882
|
+
name="email"
|
|
883
|
+
placeholder="Enter your email..."
|
|
884
|
+
beam-enable-if="#subscribe:checked"
|
|
885
|
+
disabled
|
|
886
|
+
/>
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
### Show/Hide Fields
|
|
890
|
+
|
|
891
|
+
```html
|
|
892
|
+
<select name="source" id="source">
|
|
893
|
+
<option value="">-- Select --</option>
|
|
894
|
+
<option value="google">Google</option>
|
|
895
|
+
<option value="friend">Friend</option>
|
|
896
|
+
<option value="other">Other</option>
|
|
897
|
+
</select>
|
|
898
|
+
|
|
899
|
+
<!-- Only visible when "other" is selected -->
|
|
900
|
+
<div beam-visible-if="#source[value='other']">
|
|
901
|
+
<label>Please specify</label>
|
|
902
|
+
<input type="text" name="source-other" />
|
|
903
|
+
</div>
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
### Required Fields
|
|
907
|
+
|
|
908
|
+
```html
|
|
909
|
+
<label>
|
|
910
|
+
<input type="checkbox" id="business" name="is-business" />
|
|
911
|
+
This is a business account
|
|
912
|
+
</label>
|
|
913
|
+
|
|
914
|
+
<!-- Required only when checkbox is checked -->
|
|
915
|
+
<input
|
|
916
|
+
type="text"
|
|
917
|
+
name="company"
|
|
918
|
+
placeholder="Company name"
|
|
919
|
+
beam-required-if="#business:checked"
|
|
920
|
+
/>
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
### Condition Syntax
|
|
924
|
+
|
|
925
|
+
Conditions support:
|
|
926
|
+
- `:checked` - Checkbox/radio is checked
|
|
927
|
+
- `:disabled` - Element is disabled
|
|
928
|
+
- `:empty` - Input has no value
|
|
929
|
+
- `[value='x']` - Input value equals 'x'
|
|
930
|
+
- `[value!='x']` - Input value not equals 'x'
|
|
931
|
+
- `[value>'5']` - Numeric comparison
|
|
932
|
+
|
|
933
|
+
```html
|
|
934
|
+
<!-- Enable when country is selected -->
|
|
935
|
+
<select beam-disable-if="#country[value='']" name="state">
|
|
936
|
+
|
|
937
|
+
<!-- Show when amount is over 100 -->
|
|
938
|
+
<div beam-visible-if="#amount[value>'100']">
|
|
939
|
+
Large order discount applied!
|
|
940
|
+
</div>
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
---
|
|
944
|
+
|
|
605
945
|
## Deferred Loading
|
|
606
946
|
|
|
607
947
|
Load content only when it enters the viewport:
|
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;AA82BzC,iBAAS,UAAU,IAAI,IAAI,CAU1B;AAkCD,iBAAS,WAAW,IAAI,IAAI,CAU3B;AAID,iBAAS,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,SAAS,GAAG,OAAmB,GAAG,IAAI,CAsB/E;AAkrBD,iBAAS,UAAU,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAUzC;AAmlCD,UAAU,WAAW;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAMD,iBAAS,gBAAgB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CA0B9D;AAGD,QAAA,MAAM,SAAS;;;;;;;sBAlpFO,OAAO,CAAC,cAAc,CAAC;CA0pF5C,CAAA;AAGD,KAAK,YAAY,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,WAAW,KAAK,OAAO,CAAC,cAAc,CAAC,CAAA;AAE/G,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,IAAI,EAAE,OAAO,SAAS,GAAG;YACvB,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAAA;SAC/B,CAAA;KACF;CACF"}
|
package/dist/client.js
CHANGED
|
@@ -120,51 +120,42 @@ function $$(selector) {
|
|
|
120
120
|
return document.querySelectorAll(selector);
|
|
121
121
|
}
|
|
122
122
|
function morph(target, html, options) {
|
|
123
|
-
// Handle beam-keep elements
|
|
124
123
|
const keepSelectors = options?.keepElements || [];
|
|
125
|
-
const keptElements = new Map();
|
|
126
|
-
// Preserve elements marked with beam-keep
|
|
127
|
-
target.querySelectorAll('[beam-keep]').forEach((el) => {
|
|
128
|
-
const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
|
|
129
|
-
if (!el.id)
|
|
130
|
-
el.id = id;
|
|
131
|
-
const placeholder = document.createComment(`beam-keep:${id}`);
|
|
132
|
-
el.parentNode?.insertBefore(placeholder, el);
|
|
133
|
-
keptElements.set(id, { el, placeholder });
|
|
134
|
-
el.remove();
|
|
135
|
-
});
|
|
136
|
-
// Also handle explicitly specified keep selectors
|
|
137
|
-
keepSelectors.forEach((selector) => {
|
|
138
|
-
target.querySelectorAll(selector).forEach((el) => {
|
|
139
|
-
const id = el.id || `beam-keep-${Math.random().toString(36).slice(2)}`;
|
|
140
|
-
if (!el.id)
|
|
141
|
-
el.id = id;
|
|
142
|
-
if (!keptElements.has(id)) {
|
|
143
|
-
const placeholder = document.createComment(`beam-keep:${id}`);
|
|
144
|
-
el.parentNode?.insertBefore(placeholder, el);
|
|
145
|
-
keptElements.set(id, { el, placeholder });
|
|
146
|
-
el.remove();
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
124
|
// @ts-ignore - idiomorph types
|
|
151
|
-
Idiomorph.morph(target, html, {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
125
|
+
Idiomorph.morph(target, html, {
|
|
126
|
+
morphStyle: 'innerHTML',
|
|
127
|
+
callbacks: {
|
|
128
|
+
// Skip morphing elements marked with beam-keep
|
|
129
|
+
beforeNodeMorphed: (fromEl, toEl) => {
|
|
130
|
+
// Only handle Element nodes
|
|
131
|
+
if (!(fromEl instanceof Element))
|
|
132
|
+
return true;
|
|
133
|
+
// Check if element has beam-keep attribute
|
|
134
|
+
if (fromEl.hasAttribute('beam-keep')) {
|
|
135
|
+
// Don't morph this element - keep it as is
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// Check if element matches any keep selectors
|
|
139
|
+
for (const selector of keepSelectors) {
|
|
140
|
+
try {
|
|
141
|
+
if (fromEl.matches(selector)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Invalid selector, ignore
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
// Prevent removal of beam-keep elements
|
|
152
|
+
beforeNodeRemoved: (node) => {
|
|
153
|
+
if (node instanceof Element && node.hasAttribute('beam-keep')) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
161
157
|
}
|
|
162
158
|
}
|
|
163
|
-
// If no placeholder, look for element with same ID to replace
|
|
164
|
-
const newEl = target.querySelector(`#${id}`);
|
|
165
|
-
if (newEl) {
|
|
166
|
-
newEl.parentNode?.replaceChild(el, newEl);
|
|
167
|
-
}
|
|
168
159
|
});
|
|
169
160
|
}
|
|
170
161
|
function getParams(el) {
|
|
@@ -623,7 +614,7 @@ document.addEventListener('click', async (e) => {
|
|
|
623
614
|
const target = e.target;
|
|
624
615
|
if (!target?.closest)
|
|
625
616
|
return;
|
|
626
|
-
const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite])');
|
|
617
|
+
const btn = target.closest('[beam-action]:not(form):not([beam-instant]):not([beam-load-more]):not([beam-infinite]):not([beam-watch])');
|
|
627
618
|
if (!btn || btn.tagName === 'FORM')
|
|
628
619
|
return;
|
|
629
620
|
// Skip if submit button inside a beam form
|
|
@@ -1649,6 +1640,240 @@ document.querySelectorAll('[beam-validate]').forEach((el) => {
|
|
|
1649
1640
|
el.setAttribute('beam-validation-observed', '');
|
|
1650
1641
|
setupValidation(el);
|
|
1651
1642
|
});
|
|
1643
|
+
// ============ INPUT WATCHERS ============
|
|
1644
|
+
// Usage: <input name="q" beam-action="search" beam-target="#results" beam-watch="input" beam-debounce="300">
|
|
1645
|
+
// Usage: <input type="range" beam-action="update" beam-watch="input" beam-throttle="100">
|
|
1646
|
+
// Usage: <input beam-watch="input" beam-watch-if="value.length >= 3">
|
|
1647
|
+
// Handles standalone inputs with beam-action + beam-watch (not using beam-validate)
|
|
1648
|
+
function isInputElement(el) {
|
|
1649
|
+
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
|
|
1650
|
+
}
|
|
1651
|
+
function getInputValue(el) {
|
|
1652
|
+
if (el.tagName === 'INPUT') {
|
|
1653
|
+
const input = el;
|
|
1654
|
+
if (input.type === 'checkbox')
|
|
1655
|
+
return input.checked;
|
|
1656
|
+
if (input.type === 'radio')
|
|
1657
|
+
return input.checked ? input.value : '';
|
|
1658
|
+
return input.value;
|
|
1659
|
+
}
|
|
1660
|
+
if (el.tagName === 'TEXTAREA')
|
|
1661
|
+
return el.value;
|
|
1662
|
+
if (el.tagName === 'SELECT')
|
|
1663
|
+
return el.value;
|
|
1664
|
+
return '';
|
|
1665
|
+
}
|
|
1666
|
+
// Cast value based on beam-cast attribute
|
|
1667
|
+
function castValue(value, castType) {
|
|
1668
|
+
if (!castType || typeof value !== 'string')
|
|
1669
|
+
return value;
|
|
1670
|
+
switch (castType) {
|
|
1671
|
+
case 'number':
|
|
1672
|
+
const num = parseFloat(value);
|
|
1673
|
+
return isNaN(num) ? 0 : num;
|
|
1674
|
+
case 'integer':
|
|
1675
|
+
const int = parseInt(value, 10);
|
|
1676
|
+
return isNaN(int) ? 0 : int;
|
|
1677
|
+
case 'boolean':
|
|
1678
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
1679
|
+
case 'trim':
|
|
1680
|
+
return value.trim();
|
|
1681
|
+
default:
|
|
1682
|
+
return value;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
// Check if condition is met for beam-watch-if
|
|
1686
|
+
function checkWatchCondition(el, value) {
|
|
1687
|
+
const condition = el.getAttribute('beam-watch-if');
|
|
1688
|
+
if (!condition)
|
|
1689
|
+
return true;
|
|
1690
|
+
try {
|
|
1691
|
+
// Create a function that evaluates the condition with 'value' and 'this' context
|
|
1692
|
+
const fn = new Function('value', `with(this) { return ${condition} }`);
|
|
1693
|
+
return Boolean(fn.call(el, value));
|
|
1694
|
+
}
|
|
1695
|
+
catch (e) {
|
|
1696
|
+
console.warn('[beam] Invalid beam-watch-if condition:', condition, e);
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
// Create throttle function
|
|
1701
|
+
function createThrottle(fn, limit) {
|
|
1702
|
+
let lastRun = 0;
|
|
1703
|
+
let timeout = null;
|
|
1704
|
+
return () => {
|
|
1705
|
+
const now = Date.now();
|
|
1706
|
+
const timeSinceLastRun = now - lastRun;
|
|
1707
|
+
if (timeSinceLastRun >= limit) {
|
|
1708
|
+
lastRun = now;
|
|
1709
|
+
fn();
|
|
1710
|
+
}
|
|
1711
|
+
else if (!timeout) {
|
|
1712
|
+
// Schedule to run after remaining time
|
|
1713
|
+
timeout = setTimeout(() => {
|
|
1714
|
+
lastRun = Date.now();
|
|
1715
|
+
timeout = null;
|
|
1716
|
+
fn();
|
|
1717
|
+
}, limit - timeSinceLastRun);
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
function setupInputWatcher(el) {
|
|
1722
|
+
if (!isInputElement(el))
|
|
1723
|
+
return;
|
|
1724
|
+
const htmlEl = el;
|
|
1725
|
+
const event = htmlEl.getAttribute('beam-watch') || 'change';
|
|
1726
|
+
const debounceMs = htmlEl.getAttribute('beam-debounce');
|
|
1727
|
+
const throttleMs = htmlEl.getAttribute('beam-throttle');
|
|
1728
|
+
const action = htmlEl.getAttribute('beam-action');
|
|
1729
|
+
const targetSelector = htmlEl.getAttribute('beam-target');
|
|
1730
|
+
const swapMode = htmlEl.getAttribute('beam-swap') || 'morph';
|
|
1731
|
+
const castType = htmlEl.getAttribute('beam-cast');
|
|
1732
|
+
const loadingClass = htmlEl.getAttribute('beam-loading-class');
|
|
1733
|
+
if (!action)
|
|
1734
|
+
return;
|
|
1735
|
+
let debounceTimeout;
|
|
1736
|
+
const executeAction = async (eventType) => {
|
|
1737
|
+
const name = htmlEl.getAttribute('name');
|
|
1738
|
+
let value = getInputValue(el);
|
|
1739
|
+
// Apply type casting
|
|
1740
|
+
value = castValue(value, castType);
|
|
1741
|
+
// Check conditional trigger
|
|
1742
|
+
if (!checkWatchCondition(htmlEl, value))
|
|
1743
|
+
return;
|
|
1744
|
+
const params = getParams(htmlEl);
|
|
1745
|
+
// Add the input's value to params
|
|
1746
|
+
if (name) {
|
|
1747
|
+
params[name] = value;
|
|
1748
|
+
}
|
|
1749
|
+
// Handle checkboxes specially - they might be part of a group
|
|
1750
|
+
if (el.tagName === 'INPUT' && el.type === 'checkbox') {
|
|
1751
|
+
const form = el.closest('form');
|
|
1752
|
+
if (form && name) {
|
|
1753
|
+
const checkboxes = form.querySelectorAll(`input[type="checkbox"][name="${name}"]`);
|
|
1754
|
+
if (checkboxes.length > 1) {
|
|
1755
|
+
const values = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
|
|
1756
|
+
params[name] = values;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
// Only restore focus for "input" events, not "change" (blur) events
|
|
1761
|
+
const shouldRestoreFocus = htmlEl.hasAttribute('beam-keep') && eventType === 'input';
|
|
1762
|
+
const activeElement = document.activeElement;
|
|
1763
|
+
// Add loading class if specified
|
|
1764
|
+
if (loadingClass)
|
|
1765
|
+
htmlEl.classList.add(loadingClass);
|
|
1766
|
+
// Mark touched
|
|
1767
|
+
htmlEl.setAttribute('beam-touched', '');
|
|
1768
|
+
try {
|
|
1769
|
+
const response = await api.call(action, params);
|
|
1770
|
+
if (response.html && targetSelector) {
|
|
1771
|
+
const targets = $$(targetSelector);
|
|
1772
|
+
const htmlArray = Array.isArray(response.html) ? response.html : [response.html];
|
|
1773
|
+
targets.forEach((target, i) => {
|
|
1774
|
+
const html = htmlArray[i] || htmlArray[0];
|
|
1775
|
+
if (html) {
|
|
1776
|
+
if (swapMode === 'append') {
|
|
1777
|
+
target.insertAdjacentHTML('beforeend', html);
|
|
1778
|
+
}
|
|
1779
|
+
else if (swapMode === 'prepend') {
|
|
1780
|
+
target.insertAdjacentHTML('afterbegin', html);
|
|
1781
|
+
}
|
|
1782
|
+
else if (swapMode === 'replace') {
|
|
1783
|
+
target.outerHTML = html;
|
|
1784
|
+
}
|
|
1785
|
+
else {
|
|
1786
|
+
morph(target, html);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
}
|
|
1791
|
+
// Process OOB updates (beam-touch templates)
|
|
1792
|
+
if (response.html) {
|
|
1793
|
+
const htmlStr = Array.isArray(response.html) ? response.html.join('') : response.html;
|
|
1794
|
+
const { oob } = parseOobSwaps(htmlStr);
|
|
1795
|
+
for (const { selector, content, swapMode: oobSwapMode } of oob) {
|
|
1796
|
+
const oobTarget = $(selector);
|
|
1797
|
+
if (oobTarget) {
|
|
1798
|
+
if (oobSwapMode === 'morph' || !oobSwapMode) {
|
|
1799
|
+
morph(oobTarget, content);
|
|
1800
|
+
}
|
|
1801
|
+
else {
|
|
1802
|
+
swap(oobTarget, content, oobSwapMode);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
// Execute script if present
|
|
1808
|
+
if (response.script) {
|
|
1809
|
+
executeScript(response.script);
|
|
1810
|
+
}
|
|
1811
|
+
// Restore focus if beam-keep is set and this was an input event (not change/blur)
|
|
1812
|
+
if (shouldRestoreFocus && activeElement instanceof HTMLElement) {
|
|
1813
|
+
const newEl = document.querySelector(`[name="${name}"]`);
|
|
1814
|
+
if (newEl && newEl !== activeElement) {
|
|
1815
|
+
newEl.focus();
|
|
1816
|
+
if (newEl instanceof HTMLInputElement || newEl instanceof HTMLTextAreaElement) {
|
|
1817
|
+
const cursorPos = activeElement.selectionStart;
|
|
1818
|
+
if (cursorPos !== null) {
|
|
1819
|
+
newEl.setSelectionRange(cursorPos, cursorPos);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
catch (err) {
|
|
1826
|
+
console.error('Input watcher error:', err);
|
|
1827
|
+
}
|
|
1828
|
+
finally {
|
|
1829
|
+
// Remove loading class
|
|
1830
|
+
if (loadingClass)
|
|
1831
|
+
htmlEl.classList.remove(loadingClass);
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
// Create the appropriate handler based on throttle vs debounce
|
|
1835
|
+
let handler;
|
|
1836
|
+
if (throttleMs) {
|
|
1837
|
+
// Use throttle mode
|
|
1838
|
+
const throttle = parseInt(throttleMs, 10);
|
|
1839
|
+
const throttledFn = createThrottle(() => executeAction('input'), throttle);
|
|
1840
|
+
handler = (e) => {
|
|
1841
|
+
throttledFn();
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
// Use debounce mode (default)
|
|
1846
|
+
const debounce = parseInt(debounceMs || '300', 10);
|
|
1847
|
+
handler = (e) => {
|
|
1848
|
+
clearTimeout(debounceTimeout);
|
|
1849
|
+
const eventType = e.type;
|
|
1850
|
+
debounceTimeout = setTimeout(() => executeAction(eventType), debounce);
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
// Support multiple events (comma-separated)
|
|
1854
|
+
const events = event.split(',').map(e => e.trim());
|
|
1855
|
+
events.forEach(evt => {
|
|
1856
|
+
htmlEl.addEventListener(evt, handler);
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
// Observe input watcher elements (current and future)
|
|
1860
|
+
const inputWatcherObserver = new MutationObserver(() => {
|
|
1861
|
+
// Select inputs with beam-action + beam-watch but NOT beam-validate (which has its own handler)
|
|
1862
|
+
document.querySelectorAll('[beam-action][beam-watch]:not([beam-validate]):not([beam-input-observed])').forEach((el) => {
|
|
1863
|
+
if (!isInputElement(el))
|
|
1864
|
+
return;
|
|
1865
|
+
el.setAttribute('beam-input-observed', '');
|
|
1866
|
+
setupInputWatcher(el);
|
|
1867
|
+
});
|
|
1868
|
+
});
|
|
1869
|
+
inputWatcherObserver.observe(document.body, { childList: true, subtree: true });
|
|
1870
|
+
// Initialize existing input watcher elements
|
|
1871
|
+
document.querySelectorAll('[beam-action][beam-watch]:not([beam-validate])').forEach((el) => {
|
|
1872
|
+
if (!isInputElement(el))
|
|
1873
|
+
return;
|
|
1874
|
+
el.setAttribute('beam-input-observed', '');
|
|
1875
|
+
setupInputWatcher(el);
|
|
1876
|
+
});
|
|
1652
1877
|
// ============ DEFERRED LOADING ============
|
|
1653
1878
|
// Usage: <div beam-defer beam-action="loadComments" beam-target="#comments">Loading...</div>
|
|
1654
1879
|
const deferObserver = new IntersectionObserver(async (entries) => {
|
|
@@ -1928,6 +2153,301 @@ document.addEventListener('click', (e) => {
|
|
|
1928
2153
|
}
|
|
1929
2154
|
}
|
|
1930
2155
|
});
|
|
2156
|
+
// ============ DIRTY FORM TRACKING ============
|
|
2157
|
+
// Usage: <form beam-dirty-track>...</form>
|
|
2158
|
+
// Usage: <span beam-dirty-indicator="#my-form">*</span> (shows when form is dirty)
|
|
2159
|
+
// Usage: <form beam-warn-unsaved>...</form> (warns on page leave)
|
|
2160
|
+
// Store original form data for dirty checking
|
|
2161
|
+
const formOriginalData = new WeakMap();
|
|
2162
|
+
const dirtyForms = new Set();
|
|
2163
|
+
function getFormDataMap(form) {
|
|
2164
|
+
const map = new Map();
|
|
2165
|
+
const formData = new FormData(form);
|
|
2166
|
+
for (const [key, value] of formData.entries()) {
|
|
2167
|
+
const existing = map.get(key);
|
|
2168
|
+
if (existing) {
|
|
2169
|
+
// Handle multiple values (checkboxes, multi-select)
|
|
2170
|
+
map.set(key, existing + ',' + String(value));
|
|
2171
|
+
}
|
|
2172
|
+
else {
|
|
2173
|
+
map.set(key, String(value));
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
return map;
|
|
2177
|
+
}
|
|
2178
|
+
function isFormDirty(form) {
|
|
2179
|
+
const original = formOriginalData.get(form);
|
|
2180
|
+
if (!original)
|
|
2181
|
+
return false;
|
|
2182
|
+
const current = getFormDataMap(form);
|
|
2183
|
+
// Check if any values changed
|
|
2184
|
+
for (const [key, value] of current.entries()) {
|
|
2185
|
+
if (original.get(key) !== value)
|
|
2186
|
+
return true;
|
|
2187
|
+
}
|
|
2188
|
+
for (const [key, value] of original.entries()) {
|
|
2189
|
+
if (current.get(key) !== value)
|
|
2190
|
+
return true;
|
|
2191
|
+
}
|
|
2192
|
+
return false;
|
|
2193
|
+
}
|
|
2194
|
+
function updateDirtyState(form) {
|
|
2195
|
+
const isDirty = isFormDirty(form);
|
|
2196
|
+
if (isDirty) {
|
|
2197
|
+
dirtyForms.add(form);
|
|
2198
|
+
form.setAttribute('beam-dirty', '');
|
|
2199
|
+
}
|
|
2200
|
+
else {
|
|
2201
|
+
dirtyForms.delete(form);
|
|
2202
|
+
form.removeAttribute('beam-dirty');
|
|
2203
|
+
}
|
|
2204
|
+
// Update dirty indicators
|
|
2205
|
+
updateDirtyIndicators();
|
|
2206
|
+
}
|
|
2207
|
+
function updateDirtyIndicators() {
|
|
2208
|
+
document.querySelectorAll('[beam-dirty-indicator]').forEach((indicator) => {
|
|
2209
|
+
const formSelector = indicator.getAttribute('beam-dirty-indicator');
|
|
2210
|
+
if (!formSelector)
|
|
2211
|
+
return;
|
|
2212
|
+
const form = document.querySelector(formSelector);
|
|
2213
|
+
const isDirty = form ? dirtyForms.has(form) : false;
|
|
2214
|
+
if (indicator.hasAttribute('beam-dirty-class')) {
|
|
2215
|
+
const className = indicator.getAttribute('beam-dirty-class');
|
|
2216
|
+
indicator.classList.toggle(className, isDirty);
|
|
2217
|
+
}
|
|
2218
|
+
else {
|
|
2219
|
+
indicator.style.display = isDirty ? '' : 'none';
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
// Update show-if-dirty elements
|
|
2223
|
+
document.querySelectorAll('[beam-show-if-dirty]').forEach((el) => {
|
|
2224
|
+
const formSelector = el.getAttribute('beam-show-if-dirty');
|
|
2225
|
+
const form = formSelector
|
|
2226
|
+
? document.querySelector(formSelector)
|
|
2227
|
+
: el.closest('form');
|
|
2228
|
+
const isDirty = form ? dirtyForms.has(form) : false;
|
|
2229
|
+
el.style.display = isDirty ? '' : 'none';
|
|
2230
|
+
});
|
|
2231
|
+
// Update hide-if-dirty elements
|
|
2232
|
+
document.querySelectorAll('[beam-hide-if-dirty]').forEach((el) => {
|
|
2233
|
+
const formSelector = el.getAttribute('beam-hide-if-dirty');
|
|
2234
|
+
const form = formSelector
|
|
2235
|
+
? document.querySelector(formSelector)
|
|
2236
|
+
: el.closest('form');
|
|
2237
|
+
const isDirty = form ? dirtyForms.has(form) : false;
|
|
2238
|
+
el.style.display = isDirty ? 'none' : '';
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
function setupDirtyTracking(form) {
|
|
2242
|
+
// Store original data
|
|
2243
|
+
formOriginalData.set(form, getFormDataMap(form));
|
|
2244
|
+
// Listen to input events on all form fields
|
|
2245
|
+
const checkDirty = () => updateDirtyState(form);
|
|
2246
|
+
form.addEventListener('input', checkDirty);
|
|
2247
|
+
form.addEventListener('change', checkDirty);
|
|
2248
|
+
// Reset dirty state on form submit
|
|
2249
|
+
form.addEventListener('submit', () => {
|
|
2250
|
+
// After successful submit, update original data
|
|
2251
|
+
setTimeout(() => {
|
|
2252
|
+
formOriginalData.set(form, getFormDataMap(form));
|
|
2253
|
+
updateDirtyState(form);
|
|
2254
|
+
}, 100);
|
|
2255
|
+
});
|
|
2256
|
+
// Handle form reset
|
|
2257
|
+
form.addEventListener('reset', () => {
|
|
2258
|
+
setTimeout(() => updateDirtyState(form), 0);
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
// Observe dirty-tracked forms
|
|
2262
|
+
const dirtyFormObserver = new MutationObserver(() => {
|
|
2263
|
+
document.querySelectorAll('form[beam-dirty-track]:not([beam-dirty-observed])').forEach((form) => {
|
|
2264
|
+
form.setAttribute('beam-dirty-observed', '');
|
|
2265
|
+
setupDirtyTracking(form);
|
|
2266
|
+
});
|
|
2267
|
+
});
|
|
2268
|
+
dirtyFormObserver.observe(document.body, { childList: true, subtree: true });
|
|
2269
|
+
// Initialize existing dirty-tracked forms
|
|
2270
|
+
document.querySelectorAll('form[beam-dirty-track]').forEach((form) => {
|
|
2271
|
+
form.setAttribute('beam-dirty-observed', '');
|
|
2272
|
+
setupDirtyTracking(form);
|
|
2273
|
+
});
|
|
2274
|
+
// Initialize dirty indicators (hidden by default)
|
|
2275
|
+
document.querySelectorAll('[beam-dirty-indicator]:not([beam-dirty-class])').forEach((el) => {
|
|
2276
|
+
el.style.display = 'none';
|
|
2277
|
+
});
|
|
2278
|
+
document.querySelectorAll('[beam-show-if-dirty]').forEach((el) => {
|
|
2279
|
+
el.style.display = 'none';
|
|
2280
|
+
});
|
|
2281
|
+
// ============ UNSAVED CHANGES WARNING ============
|
|
2282
|
+
// Usage: <form beam-warn-unsaved>...</form>
|
|
2283
|
+
// Usage: <form beam-warn-unsaved="Are you sure? You have unsaved changes.">...</form>
|
|
2284
|
+
window.addEventListener('beforeunload', (e) => {
|
|
2285
|
+
// Check if any form with beam-warn-unsaved is dirty
|
|
2286
|
+
const formsWithWarning = document.querySelectorAll('form[beam-warn-unsaved]');
|
|
2287
|
+
let hasDirtyForm = false;
|
|
2288
|
+
formsWithWarning.forEach((form) => {
|
|
2289
|
+
if (dirtyForms.has(form)) {
|
|
2290
|
+
hasDirtyForm = true;
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
if (hasDirtyForm) {
|
|
2294
|
+
e.preventDefault();
|
|
2295
|
+
// Modern browsers ignore custom messages, but we need to return something
|
|
2296
|
+
e.returnValue = '';
|
|
2297
|
+
return '';
|
|
2298
|
+
}
|
|
2299
|
+
});
|
|
2300
|
+
// ============ FORM REVERT ============
|
|
2301
|
+
// Usage: <button type="button" beam-revert="#my-form">Revert</button>
|
|
2302
|
+
// Usage: <button type="button" beam-revert>Revert</button> (inside form)
|
|
2303
|
+
document.addEventListener('click', (e) => {
|
|
2304
|
+
const target = e.target;
|
|
2305
|
+
if (!target?.closest)
|
|
2306
|
+
return;
|
|
2307
|
+
const trigger = target.closest('[beam-revert]');
|
|
2308
|
+
if (trigger) {
|
|
2309
|
+
e.preventDefault();
|
|
2310
|
+
const formSelector = trigger.getAttribute('beam-revert');
|
|
2311
|
+
const form = formSelector
|
|
2312
|
+
? document.querySelector(formSelector)
|
|
2313
|
+
: trigger.closest('form');
|
|
2314
|
+
if (form) {
|
|
2315
|
+
const original = formOriginalData.get(form);
|
|
2316
|
+
if (original) {
|
|
2317
|
+
// Reset each field to its original value
|
|
2318
|
+
original.forEach((value, name) => {
|
|
2319
|
+
const fields = form.querySelectorAll(`[name="${name}"]`);
|
|
2320
|
+
fields.forEach((el) => {
|
|
2321
|
+
const field = el;
|
|
2322
|
+
if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
|
|
2323
|
+
// For checkboxes/radios, check if their value was in the original
|
|
2324
|
+
const values = value.split(',');
|
|
2325
|
+
field.checked = values.includes(field.value);
|
|
2326
|
+
}
|
|
2327
|
+
else if ('value' in field) {
|
|
2328
|
+
field.value = value;
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
});
|
|
2332
|
+
// Handle fields that weren't in original (new fields) - reset them
|
|
2333
|
+
const currentFields = form.querySelectorAll('[name]');
|
|
2334
|
+
currentFields.forEach((el) => {
|
|
2335
|
+
const field = el;
|
|
2336
|
+
const name = field.getAttribute('name');
|
|
2337
|
+
if (name && !original.has(name)) {
|
|
2338
|
+
if (field instanceof HTMLInputElement && (field.type === 'checkbox' || field.type === 'radio')) {
|
|
2339
|
+
field.checked = false;
|
|
2340
|
+
}
|
|
2341
|
+
else if ('value' in field) {
|
|
2342
|
+
field.value = '';
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
});
|
|
2346
|
+
// Dispatch input event for any watchers
|
|
2347
|
+
form.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2348
|
+
updateDirtyState(form);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
// ============ CONDITIONAL FORM FIELDS ============
|
|
2354
|
+
// Usage: <input name="other" beam-enable-if="#has-other:checked">
|
|
2355
|
+
// Usage: <select beam-disable-if="#country[value='']">
|
|
2356
|
+
// Usage: <div beam-visible-if="#show-details:checked">Details here</div>
|
|
2357
|
+
function evaluateCondition(condition) {
|
|
2358
|
+
// Parse condition: "#selector:pseudo" or "#selector[attr='value']"
|
|
2359
|
+
const match = condition.match(/^([^:\[]+)(?::(\w+))?(?:\[([^\]]+)\])?$/);
|
|
2360
|
+
if (!match)
|
|
2361
|
+
return false;
|
|
2362
|
+
const [, selector, pseudo, attrCondition] = match;
|
|
2363
|
+
const el = document.querySelector(selector);
|
|
2364
|
+
if (!el)
|
|
2365
|
+
return false;
|
|
2366
|
+
// Check pseudo-class
|
|
2367
|
+
if (pseudo === 'checked') {
|
|
2368
|
+
return el.checked;
|
|
2369
|
+
}
|
|
2370
|
+
if (pseudo === 'disabled') {
|
|
2371
|
+
return el.disabled;
|
|
2372
|
+
}
|
|
2373
|
+
if (pseudo === 'empty') {
|
|
2374
|
+
return !el.value;
|
|
2375
|
+
}
|
|
2376
|
+
// Check attribute condition
|
|
2377
|
+
if (attrCondition) {
|
|
2378
|
+
const attrMatch = attrCondition.match(/(\w+)([=!<>]+)'?([^']*)'?/);
|
|
2379
|
+
if (attrMatch) {
|
|
2380
|
+
const [, attr, op, expected] = attrMatch;
|
|
2381
|
+
const actual = attr === 'value' ? el.value : el.getAttribute(attr);
|
|
2382
|
+
switch (op) {
|
|
2383
|
+
case '=':
|
|
2384
|
+
case '==':
|
|
2385
|
+
return actual === expected;
|
|
2386
|
+
case '!=':
|
|
2387
|
+
return actual !== expected;
|
|
2388
|
+
case '>':
|
|
2389
|
+
return Number(actual) > Number(expected);
|
|
2390
|
+
case '<':
|
|
2391
|
+
return Number(actual) < Number(expected);
|
|
2392
|
+
case '>=':
|
|
2393
|
+
return Number(actual) >= Number(expected);
|
|
2394
|
+
case '<=':
|
|
2395
|
+
return Number(actual) <= Number(expected);
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
// Default: check if element exists and has a truthy value
|
|
2400
|
+
if (el instanceof HTMLInputElement) {
|
|
2401
|
+
if (el.type === 'checkbox' || el.type === 'radio') {
|
|
2402
|
+
return el.checked;
|
|
2403
|
+
}
|
|
2404
|
+
return Boolean(el.value);
|
|
2405
|
+
}
|
|
2406
|
+
if (el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
|
|
2407
|
+
return Boolean(el.value);
|
|
2408
|
+
}
|
|
2409
|
+
return true;
|
|
2410
|
+
}
|
|
2411
|
+
function updateConditionalFields() {
|
|
2412
|
+
// Enable-if
|
|
2413
|
+
document.querySelectorAll('[beam-enable-if]').forEach((el) => {
|
|
2414
|
+
const condition = el.getAttribute('beam-enable-if');
|
|
2415
|
+
const shouldEnable = evaluateCondition(condition);
|
|
2416
|
+
el.disabled = !shouldEnable;
|
|
2417
|
+
});
|
|
2418
|
+
// Disable-if
|
|
2419
|
+
document.querySelectorAll('[beam-disable-if]').forEach((el) => {
|
|
2420
|
+
const condition = el.getAttribute('beam-disable-if');
|
|
2421
|
+
const shouldDisable = evaluateCondition(condition);
|
|
2422
|
+
el.disabled = shouldDisable;
|
|
2423
|
+
});
|
|
2424
|
+
// Visible-if (show when condition is true)
|
|
2425
|
+
document.querySelectorAll('[beam-visible-if]').forEach((el) => {
|
|
2426
|
+
const condition = el.getAttribute('beam-visible-if');
|
|
2427
|
+
const shouldShow = evaluateCondition(condition);
|
|
2428
|
+
el.style.display = shouldShow ? '' : 'none';
|
|
2429
|
+
});
|
|
2430
|
+
// Hidden-if (hide when condition is true)
|
|
2431
|
+
document.querySelectorAll('[beam-hidden-if]').forEach((el) => {
|
|
2432
|
+
const condition = el.getAttribute('beam-hidden-if');
|
|
2433
|
+
const shouldHide = evaluateCondition(condition);
|
|
2434
|
+
el.style.display = shouldHide ? 'none' : '';
|
|
2435
|
+
});
|
|
2436
|
+
// Required-if
|
|
2437
|
+
document.querySelectorAll('[beam-required-if]').forEach((el) => {
|
|
2438
|
+
const condition = el.getAttribute('beam-required-if');
|
|
2439
|
+
const shouldRequire = evaluateCondition(condition);
|
|
2440
|
+
el.required = shouldRequire;
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
// Listen for input/change events to update conditional fields
|
|
2444
|
+
document.addEventListener('input', updateConditionalFields);
|
|
2445
|
+
document.addEventListener('change', updateConditionalFields);
|
|
2446
|
+
// Initial update
|
|
2447
|
+
updateConditionalFields();
|
|
2448
|
+
// Observe for new conditional elements
|
|
2449
|
+
const conditionalObserver = new MutationObserver(updateConditionalFields);
|
|
2450
|
+
conditionalObserver.observe(document.body, { childList: true, subtree: true });
|
|
1931
2451
|
// Clear scroll state for current page or all pages
|
|
1932
2452
|
// Usage: clearScrollState() - clear all for current URL
|
|
1933
2453
|
// clearScrollState('loadMore') - clear specific action
|