@benqoder/beam 0.1.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 ADDED
@@ -0,0 +1,1574 @@
1
+ # Beam
2
+
3
+ A lightweight, declarative UI framework for building interactive web applications with WebSocket RPC. Beam provides Unpoly-like functionality with zero JavaScript configuration—just add attributes to your HTML.
4
+
5
+ ## Features
6
+
7
+ - **WebSocket RPC** - Real-time communication without HTTP overhead
8
+ - **Declarative** - No JavaScript needed, just HTML attributes
9
+ - **Auto-discovery** - Handlers are automatically found via Vite plugin
10
+ - **Modals & Drawers** - Built-in overlay components
11
+ - **Smart Loading** - Per-action loading indicators with parameter matching
12
+ - **DOM Morphing** - Smooth updates via Idiomorph
13
+ - **Real-time Validation** - Validate forms as users type
14
+ - **Deferred Loading** - Load content when scrolled into view
15
+ - **Polling** - Auto-refresh content at intervals
16
+ - **Hungry Elements** - Auto-update elements across actions
17
+ - **Confirmation Dialogs** - Confirm before destructive actions
18
+ - **Instant Click** - Trigger on mousedown for snappier UX
19
+ - **Offline Detection** - Show/hide content based on connection
20
+ - **Navigation Feedback** - Auto-highlight current page links
21
+ - **Conditional Show/Hide** - Toggle visibility based on form values
22
+ - **Auto-submit Forms** - Submit on field change
23
+ - **Boost Links** - Upgrade regular links to AJAX
24
+ - **History Management** - Push/replace browser history
25
+ - **Placeholders** - Show loading content in target
26
+ - **Keep Elements** - Preserve elements during updates
27
+ - **Toggle** - Client-side show/hide with transitions (no server)
28
+ - **Dropdowns** - Click-outside closing, Escape key support (no server)
29
+ - **Collapse** - Expand/collapse with text swap (no server)
30
+ - **Class Toggle** - Toggle CSS classes on elements (no server)
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ npm install @benqoder/beam
36
+ ```
37
+
38
+ ## Quick Start
39
+
40
+ ### 1. Add the Vite Plugin
41
+
42
+ ```typescript
43
+ // vite.config.ts
44
+ import { beamPlugin } from '@benqoder/beam/vite'
45
+
46
+ export default defineConfig({
47
+ plugins: [
48
+ beamPlugin({
49
+ actions: './actions/*.tsx',
50
+ modals: './modals/*.tsx',
51
+ drawers: './drawers/*.tsx',
52
+ }),
53
+ ],
54
+ })
55
+ ```
56
+
57
+ ### 2. Initialize Beam Server
58
+
59
+ ```typescript
60
+ // app/server.ts
61
+ import { createApp } from 'honox/server'
62
+ import { beam } from 'virtual:beam'
63
+
64
+ const app = createApp({
65
+ init: beam.init,
66
+ })
67
+
68
+ export default app
69
+ ```
70
+
71
+ ### 3. Add the Client Script
72
+
73
+ ```typescript
74
+ // app/client.ts
75
+ import '@benqoder/beam/client'
76
+ ```
77
+
78
+ ### 4. Create an Action
79
+
80
+ ```tsx
81
+ // app/actions/counter.tsx
82
+ export function increment(c) {
83
+ const count = parseInt(c.req.query('count') || '0')
84
+ return <div>Count: {count + 1}</div>
85
+ }
86
+ ```
87
+
88
+ ### 5. Use in HTML
89
+
90
+ ```html
91
+ <div id="counter">Count: 0</div>
92
+ <button beam-action="increment" beam-target="#counter">
93
+ Increment
94
+ </button>
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Core Concepts
100
+
101
+ ### Actions
102
+
103
+ Actions are server functions that return HTML. They're the primary way to handle user interactions.
104
+
105
+ ```tsx
106
+ // app/actions/demo.tsx
107
+ export function greet(c) {
108
+ const name = c.req.query('name') || 'World'
109
+ return <div>Hello, {name}!</div>
110
+ }
111
+ ```
112
+
113
+ ```html
114
+ <button
115
+ beam-action="greet"
116
+ beam-data-name="Alice"
117
+ beam-target="#greeting"
118
+ >
119
+ Say Hello
120
+ </button>
121
+ <div id="greeting"></div>
122
+ ```
123
+
124
+ ### Modals
125
+
126
+ Modals are overlay dialogs rendered from server components.
127
+
128
+ ```tsx
129
+ // app/modals/confirm.tsx
130
+ import { ModalFrame } from '@benqoder/beam'
131
+
132
+ export function confirmDelete(c) {
133
+ const id = c.req.query('id')
134
+ return (
135
+ <ModalFrame title="Confirm Delete">
136
+ <p>Are you sure you want to delete item {id}?</p>
137
+ <button beam-action="deleteItem" beam-data-id={id} beam-close>
138
+ Delete
139
+ </button>
140
+ <button beam-close>Cancel</button>
141
+ </ModalFrame>
142
+ )
143
+ }
144
+ ```
145
+
146
+ ```html
147
+ <button beam-modal="confirmDelete" beam-data-id="123">
148
+ Delete Item
149
+ </button>
150
+ ```
151
+
152
+ ### Drawers
153
+
154
+ Drawers are slide-in panels from the left or right edge.
155
+
156
+ ```tsx
157
+ // app/drawers/cart.tsx
158
+ import { DrawerFrame } from '@benqoder/beam'
159
+
160
+ export function shoppingCart(c) {
161
+ return (
162
+ <DrawerFrame title="Shopping Cart">
163
+ <div class="cart-items">
164
+ {/* Cart contents */}
165
+ </div>
166
+ </DrawerFrame>
167
+ )
168
+ }
169
+ ```
170
+
171
+ ```html
172
+ <button beam-drawer="shoppingCart" beam-position="right" beam-size="medium">
173
+ Open Cart
174
+ </button>
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Attribute Reference
180
+
181
+ ### Actions
182
+
183
+ | Attribute | Description | Example |
184
+ |-----------|-------------|---------|
185
+ | `beam-action` | Action name to call | `beam-action="increment"` |
186
+ | `beam-target` | CSS selector for where to render response | `beam-target="#counter"` |
187
+ | `beam-data-*` | Pass data to the action | `beam-data-id="123"` |
188
+ | `beam-swap` | How to swap content: `morph`, `append`, `prepend`, `replace` | `beam-swap="append"` |
189
+ | `beam-confirm` | Show confirmation dialog before action | `beam-confirm="Delete this item?"` |
190
+ | `beam-confirm-prompt` | Require typing text to confirm | `beam-confirm-prompt="Type DELETE\|DELETE"` |
191
+ | `beam-instant` | Trigger on mousedown instead of click | `beam-instant` |
192
+ | `beam-disable` | Disable element(s) during request | `beam-disable` or `beam-disable="#btn"` |
193
+ | `beam-placeholder` | Show placeholder in target while loading | `beam-placeholder="<p>Loading...</p>"` |
194
+ | `beam-push` | Push URL to browser history after action | `beam-push="/new-url"` |
195
+ | `beam-replace` | Replace current URL in history | `beam-replace="?page=2"` |
196
+
197
+ ### Modals
198
+
199
+ | Attribute | Description | Example |
200
+ |-----------|-------------|---------|
201
+ | `beam-modal` | Modal handler name to open | `beam-modal="editUser"` |
202
+ | `beam-close` | Close the current modal when clicked | `beam-close` |
203
+
204
+ ### Drawers
205
+
206
+ | Attribute | Description | Example |
207
+ |-----------|-------------|---------|
208
+ | `beam-drawer` | Drawer handler name to open | `beam-drawer="settings"` |
209
+ | `beam-position` | Side to open from: `left`, `right` | `beam-position="left"` |
210
+ | `beam-size` | Drawer width: `small`, `medium`, `large` | `beam-size="large"` |
211
+ | `beam-close` | Close the current drawer when clicked | `beam-close` |
212
+
213
+ ### Forms
214
+
215
+ | Attribute | Description | Example |
216
+ |-----------|-------------|---------|
217
+ | `beam-action` | Action to call on submit (on `<form>`) | `<form beam-action="saveUser">` |
218
+ | `beam-reset` | Reset form after successful submit | `beam-reset` |
219
+
220
+ ### Loading States
221
+
222
+ | Attribute | Description | Example |
223
+ |-----------|-------------|---------|
224
+ | `beam-loading-for` | Show element while action is loading | `beam-loading-for="saveUser"` |
225
+ | `beam-loading-for="*"` | Show for any loading action | `beam-loading-for="*"` |
226
+ | `beam-loading-data-*` | Match specific parameters | `beam-loading-data-id="123"` |
227
+ | `beam-loading-class` | Add class while loading | `beam-loading-class="opacity-50"` |
228
+ | `beam-loading-remove` | Hide element while NOT loading | `beam-loading-remove` |
229
+
230
+ ### Validation
231
+
232
+ | Attribute | Description | Example |
233
+ |-----------|-------------|---------|
234
+ | `beam-validate` | Target selector to update with validation | `beam-validate="#email-error"` |
235
+ | `beam-watch` | Event to trigger validation: `input`, `change` | `beam-watch="input"` |
236
+ | `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
237
+
238
+ ### Deferred Loading
239
+
240
+ | Attribute | Description | Example |
241
+ |-----------|-------------|---------|
242
+ | `beam-defer` | Load content when element enters viewport | `beam-defer` |
243
+ | `beam-action` | Action to call (used with `beam-defer`) | `beam-action="loadComments"` |
244
+
245
+ ### Polling
246
+
247
+ | Attribute | Description | Example |
248
+ |-----------|-------------|---------|
249
+ | `beam-poll` | Enable polling on this element | `beam-poll` |
250
+ | `beam-interval` | Poll interval in milliseconds | `beam-interval="5000"` |
251
+ | `beam-action` | Action to call on each poll | `beam-action="getStatus"` |
252
+
253
+ ### Hungry Elements
254
+
255
+ | Attribute | Description | Example |
256
+ |-----------|-------------|---------|
257
+ | `beam-hungry` | Auto-update when any response contains matching ID | `beam-hungry` |
258
+
259
+ ### Out-of-Band Updates
260
+
261
+ | Attribute | Description | Example |
262
+ |-----------|-------------|---------|
263
+ | `beam-touch` | Update additional elements (on server response) | `beam-touch="#sidebar,#footer"` |
264
+
265
+ ### Optimistic UI
266
+
267
+ | Attribute | Description | Example |
268
+ |-----------|-------------|---------|
269
+ | `beam-optimistic` | Immediately update with this HTML before response | `beam-optimistic="<div>Saving...</div>"` |
270
+
271
+ ### Preloading & Caching
272
+
273
+ | Attribute | Description | Example |
274
+ |-----------|-------------|---------|
275
+ | `beam-preload` | Preload on hover: `hover`, `mount` | `beam-preload="hover"` |
276
+ | `beam-cache` | Cache duration in seconds | `beam-cache="60"` |
277
+
278
+ ### Infinite Scroll
279
+
280
+ | Attribute | Description | Example |
281
+ |-----------|-------------|---------|
282
+ | `beam-infinite` | Load more when scrolled near bottom (auto-trigger) | `beam-infinite` |
283
+ | `beam-load-more` | Load more on click (manual trigger) | `beam-load-more` |
284
+ | `beam-action` | Action to call for next page | `beam-action="loadMore"` |
285
+ | `beam-item-id` | Unique ID for list items (deduplication + fresh data sync) | `beam-item-id={item.id}` |
286
+
287
+ ### Navigation Feedback
288
+
289
+ | Attribute | Description | Example |
290
+ |-----------|-------------|---------|
291
+ | `beam-nav` | Mark container as navigation (children get `.beam-current`) | `<nav beam-nav>` |
292
+ | `beam-nav-exact` | Only match exact URL paths | `beam-nav-exact` |
293
+
294
+ ### Offline Detection
295
+
296
+ | Attribute | Description | Example |
297
+ |-----------|-------------|---------|
298
+ | `beam-offline` | Show element when offline, hide when online | `beam-offline` |
299
+ | `beam-offline-class` | Toggle class instead of visibility | `beam-offline-class="offline-warning"` |
300
+ | `beam-offline-disable` | Disable element when offline | `beam-offline-disable` |
301
+
302
+ ### Conditional Show/Hide
303
+
304
+ | Attribute | Description | Example |
305
+ |-----------|-------------|---------|
306
+ | `beam-switch` | Watch this field and control target elements | `beam-switch=".options"` |
307
+ | `beam-show-for` | Show when switch value matches | `beam-show-for="premium"` |
308
+ | `beam-hide-for` | Hide when switch value matches | `beam-hide-for="free"` |
309
+ | `beam-enable-for` | Enable when switch value matches | `beam-enable-for="admin"` |
310
+ | `beam-disable-for` | Disable when switch value matches | `beam-disable-for="guest"` |
311
+
312
+ ### Auto-submit Forms
313
+
314
+ | Attribute | Description | Example |
315
+ |-----------|-------------|---------|
316
+ | `beam-autosubmit` | Submit form when any field changes | `beam-autosubmit` |
317
+ | `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
318
+
319
+ ### Boost Links
320
+
321
+ | Attribute | Description | Example |
322
+ |-----------|-------------|---------|
323
+ | `beam-boost` | Upgrade links to AJAX (on container or link) | `<main beam-boost>` |
324
+ | `beam-boost-off` | Exclude specific links from boosting | `beam-boost-off` |
325
+
326
+ ### Keep Elements
327
+
328
+ | Attribute | Description | Example |
329
+ |-----------|-------------|---------|
330
+ | `beam-keep` | Preserve element during DOM updates | `<video beam-keep>` |
331
+
332
+ ### Client-Side UI State (No Server Round-Trip)
333
+
334
+ These attributes handle UI state entirely on the client, without making server requests. Perfect for menus, dropdowns, accordions, and other interactive UI patterns.
335
+
336
+ #### Toggle
337
+
338
+ | Attribute | Description | Example |
339
+ |-----------|-------------|---------|
340
+ | `beam-toggle` | Toggle visibility of target element | `beam-toggle="#menu"` |
341
+ | `beam-hidden` | Mark element as initially hidden | `<div beam-hidden>` |
342
+ | `beam-transition` | Add transition effect: `fade`, `slide`, `scale` | `beam-transition="fade"` |
343
+
344
+ #### Dropdown
345
+
346
+ | Attribute | Description | Example |
347
+ |-----------|-------------|---------|
348
+ | `beam-dropdown` | Container for dropdown (provides positioning) | `<div beam-dropdown>` |
349
+ | `beam-dropdown-trigger` | Button that opens the dropdown | `<button beam-dropdown-trigger>` |
350
+ | `beam-dropdown-content` | The dropdown content (add `beam-hidden`) | `<div beam-dropdown-content beam-hidden>` |
351
+
352
+ #### Collapse
353
+
354
+ | Attribute | Description | Example |
355
+ |-----------|-------------|---------|
356
+ | `beam-collapse` | Toggle collapsed state of target | `beam-collapse="#details"` |
357
+ | `beam-collapsed` | Mark element as initially collapsed | `<div beam-collapsed>` |
358
+ | `beam-collapse-text` | Swap button text when toggled | `beam-collapse-text="Show less"` |
359
+
360
+ #### Class Toggle
361
+
362
+ | Attribute | Description | Example |
363
+ |-----------|-------------|---------|
364
+ | `beam-class-toggle` | CSS class to toggle | `beam-class-toggle="active"` |
365
+ | `beam-class-target` | Target element (defaults to self) | `beam-class-target="#sidebar"` |
366
+
367
+ ---
368
+
369
+ ## Swap Modes
370
+
371
+ Control how content is inserted into the target element:
372
+
373
+ | Mode | Description |
374
+ |------|-------------|
375
+ | `morph` | Smart DOM diffing (default) - preserves focus, animations |
376
+ | `replace` | Replace innerHTML completely |
377
+ | `append` | Add to the end of target |
378
+ | `prepend` | Add to the beginning of target |
379
+
380
+ ```html
381
+ <!-- Append new items to a list -->
382
+ <button beam-action="addItem" beam-swap="append" beam-target="#items">
383
+ Add Item
384
+ </button>
385
+
386
+ <!-- Smooth morph update -->
387
+ <button beam-action="refresh" beam-swap="morph" beam-target="#content">
388
+ Refresh
389
+ </button>
390
+ ```
391
+
392
+ ---
393
+
394
+ ## Loading States
395
+
396
+ ### Global Loading
397
+
398
+ Show a loader for any action:
399
+
400
+ ```html
401
+ <div beam-loading-for="*" class="spinner">Loading...</div>
402
+ ```
403
+
404
+ ### Per-Action Loading
405
+
406
+ Show a loader only for specific actions:
407
+
408
+ ```html
409
+ <button beam-action="save">
410
+ Save
411
+ <span beam-loading-for="save" class="spinner"></span>
412
+ </button>
413
+ ```
414
+
415
+ ### Parameter Matching
416
+
417
+ Show loading state only when parameters match:
418
+
419
+ ```html
420
+ <div class="item">
421
+ <span>Item 1</span>
422
+ <span beam-loading-for="deleteItem" beam-loading-data-id="1">
423
+ Deleting...
424
+ </span>
425
+ <button beam-action="deleteItem" beam-data-id="1">Delete</button>
426
+ </div>
427
+ ```
428
+
429
+ ### Loading Classes
430
+
431
+ Toggle a class instead of showing/hiding:
432
+
433
+ ```html
434
+ <div beam-loading-for="save" beam-loading-class="opacity-50">
435
+ Content that fades while saving
436
+ </div>
437
+ ```
438
+
439
+ ---
440
+
441
+ ## Real-time Validation
442
+
443
+ Validate form fields as the user types:
444
+
445
+ ```html
446
+ <form beam-action="submitForm" beam-target="#result">
447
+ <input
448
+ name="email"
449
+ beam-validate="#email-error"
450
+ beam-watch="input"
451
+ beam-debounce="300"
452
+ />
453
+ <div id="email-error"></div>
454
+
455
+ <button type="submit">Submit</button>
456
+ </form>
457
+ ```
458
+
459
+ The action receives `_validate` parameter indicating which field triggered validation:
460
+
461
+ ```tsx
462
+ export function submitForm(c) {
463
+ const data = await c.req.parseBody()
464
+ const validateField = data._validate
465
+
466
+ if (validateField === 'email') {
467
+ // Return just the validation feedback
468
+ if (data.email === 'taken@example.com') {
469
+ return <div class="error">Email already taken</div>
470
+ }
471
+ return <div class="success">Email available</div>
472
+ }
473
+
474
+ // Full form submission
475
+ return <div>Form submitted!</div>
476
+ }
477
+ ```
478
+
479
+ ---
480
+
481
+ ## Deferred Loading
482
+
483
+ Load content only when it enters the viewport:
484
+
485
+ ```html
486
+ <div beam-defer beam-action="loadComments" class="placeholder">
487
+ Loading comments...
488
+ </div>
489
+ ```
490
+
491
+ The action is called once when the element becomes visible (with 100px margin).
492
+
493
+ ---
494
+
495
+ ## Polling
496
+
497
+ Auto-refresh content at regular intervals:
498
+
499
+ ```html
500
+ <div beam-poll beam-interval="5000" beam-action="getNotifications">
501
+ <!-- Updated every 5 seconds -->
502
+ </div>
503
+ ```
504
+
505
+ Polling automatically stops when the element is removed from the DOM.
506
+
507
+ ---
508
+
509
+ ## Hungry Elements
510
+
511
+ Elements marked with `beam-hungry` automatically update whenever any action returns HTML with a matching ID:
512
+
513
+ ```html
514
+ <!-- This badge updates when any action returns #cart-count -->
515
+ <span id="cart-count" beam-hungry>0</span>
516
+
517
+ <!-- Clicking this updates both #cart-result AND #cart-count -->
518
+ <button beam-action="addToCart" beam-target="#cart-result">
519
+ Add to Cart
520
+ </button>
521
+ ```
522
+
523
+ ```tsx
524
+ export function addToCart(c) {
525
+ const cartCount = getCartCount() + 1
526
+ return (
527
+ <>
528
+ <div>Item added to cart!</div>
529
+ {/* This updates the hungry element */}
530
+ <span id="cart-count">{cartCount}</span>
531
+ </>
532
+ )
533
+ }
534
+ ```
535
+
536
+ ---
537
+
538
+ ## Out-of-Band Updates
539
+
540
+ Update multiple elements from a single action using `beam-touch`:
541
+
542
+ ```tsx
543
+ export function updateDashboard(c) {
544
+ return (
545
+ <>
546
+ <div>Main content updated</div>
547
+ <div beam-touch="#sidebar">New sidebar content</div>
548
+ <div beam-touch="#notifications">3 new notifications</div>
549
+ </>
550
+ )
551
+ }
552
+ ```
553
+
554
+ ---
555
+
556
+ ## Confirmation Dialogs
557
+
558
+ Require user confirmation before destructive actions:
559
+
560
+ ```html
561
+ <!-- Simple confirmation -->
562
+ <button beam-action="deletePost" beam-confirm="Delete this post?">
563
+ Delete
564
+ </button>
565
+
566
+ <!-- Require typing to confirm (for high-risk actions) -->
567
+ <button
568
+ beam-action="deleteAccount"
569
+ beam-confirm="This will permanently delete your account"
570
+ beam-confirm-prompt="Type DELETE to confirm|DELETE"
571
+ >
572
+ Delete Account
573
+ </button>
574
+ ```
575
+
576
+ ---
577
+
578
+ ## Instant Click
579
+
580
+ Trigger actions on `mousedown` instead of `click` for ~100ms faster response:
581
+
582
+ ```html
583
+ <button beam-action="navigate" beam-instant>
584
+ Next Page
585
+ </button>
586
+ ```
587
+
588
+ ---
589
+
590
+ ## Disable During Request
591
+
592
+ Prevent double-submissions by disabling elements during requests:
593
+
594
+ ```html
595
+ <!-- Disable the button itself -->
596
+ <button beam-action="save" beam-disable>
597
+ Save
598
+ </button>
599
+
600
+ <!-- Disable specific elements -->
601
+ <form beam-action="submit" beam-disable="#submit-btn, .form-inputs">
602
+ <input class="form-inputs" name="email" />
603
+ <button id="submit-btn" type="submit">Submit</button>
604
+ </form>
605
+ ```
606
+
607
+ ---
608
+
609
+ ## Placeholders
610
+
611
+ Show loading content in the target while waiting for response:
612
+
613
+ ```html
614
+ <!-- Inline HTML placeholder -->
615
+ <button
616
+ beam-action="loadContent"
617
+ beam-target="#content"
618
+ beam-placeholder="<div class='skeleton'>Loading...</div>"
619
+ >
620
+ Load Content
621
+ </button>
622
+
623
+ <!-- Reference a template -->
624
+ <button
625
+ beam-action="loadContent"
626
+ beam-target="#content"
627
+ beam-placeholder="#loading-template"
628
+ >
629
+ Load Content
630
+ </button>
631
+
632
+ <template id="loading-template">
633
+ <div class="skeleton-loader">
634
+ <div class="skeleton-line"></div>
635
+ <div class="skeleton-line"></div>
636
+ </div>
637
+ </template>
638
+ ```
639
+
640
+ ---
641
+
642
+ ## Navigation Feedback
643
+
644
+ Automatically highlight current page links:
645
+
646
+ ```html
647
+ <nav beam-nav>
648
+ <a href="/home">Home</a> <!-- Gets .beam-current when on /home -->
649
+ <a href="/products">Products</a> <!-- Gets .beam-current when on /products/* -->
650
+ <a href="/about" beam-nav-exact>About</a> <!-- Only exact match -->
651
+ </nav>
652
+ ```
653
+
654
+ Links also get `.beam-active` class while loading.
655
+
656
+ CSS classes:
657
+ - `.beam-current` - Link matches current URL
658
+ - `.beam-active` - Action is in progress
659
+
660
+ ---
661
+
662
+ ## Offline Detection
663
+
664
+ Show/hide content based on connection status:
665
+
666
+ ```html
667
+ <!-- Show warning when offline -->
668
+ <div beam-offline class="offline-banner">
669
+ You are offline. Changes won't be saved.
670
+ </div>
671
+
672
+ <!-- Disable buttons when offline -->
673
+ <button beam-action="save" beam-offline-disable>
674
+ Save
675
+ </button>
676
+ ```
677
+
678
+ The `body` also gets `.beam-offline` class when disconnected.
679
+
680
+ ---
681
+
682
+ ## Conditional Show/Hide
683
+
684
+ Toggle element visibility based on form field values (no server round-trip):
685
+
686
+ ```html
687
+ <select name="plan" beam-switch=".plan-options">
688
+ <option value="free">Free</option>
689
+ <option value="pro">Pro</option>
690
+ <option value="enterprise">Enterprise</option>
691
+ </select>
692
+
693
+ <div class="plan-options" beam-show-for="free">
694
+ Free plan: 5 projects, 1GB storage
695
+ </div>
696
+
697
+ <div class="plan-options" beam-show-for="pro">
698
+ Pro plan: Unlimited projects, 100GB storage
699
+ </div>
700
+
701
+ <div class="plan-options" beam-show-for="enterprise">
702
+ Enterprise: Custom limits, dedicated support
703
+ </div>
704
+
705
+ <!-- Enable/disable based on selection -->
706
+ <button class="plan-options" beam-enable-for="pro,enterprise">
707
+ Advanced Settings
708
+ </button>
709
+ ```
710
+
711
+ ---
712
+
713
+ ## Auto-submit Forms
714
+
715
+ Automatically submit forms when fields change (great for filters):
716
+
717
+ ```html
718
+ <form beam-action="filterProducts" beam-target="#products" beam-autosubmit beam-debounce="300">
719
+ <input name="search" placeholder="Search..." />
720
+
721
+ <select name="category">
722
+ <option value="">All Categories</option>
723
+ <option value="electronics">Electronics</option>
724
+ <option value="clothing">Clothing</option>
725
+ </select>
726
+
727
+ <select name="sort">
728
+ <option value="newest">Newest</option>
729
+ <option value="price">Price</option>
730
+ </select>
731
+ </form>
732
+
733
+ <div id="products">
734
+ <!-- Results update automatically as user changes filters -->
735
+ </div>
736
+ ```
737
+
738
+ ---
739
+
740
+ ## Boost Links
741
+
742
+ Upgrade regular links to use AJAX navigation:
743
+
744
+ ```html
745
+ <!-- Boost all links in a container -->
746
+ <main beam-boost>
747
+ <a href="/page1">Page 1</a> <!-- Now uses AJAX -->
748
+ <a href="/page2">Page 2</a> <!-- Now uses AJAX -->
749
+ <a href="/external.com" beam-boost-off>External</a> <!-- Not boosted -->
750
+ </main>
751
+
752
+ <!-- Or boost individual links -->
753
+ <a href="/about" beam-boost beam-target="#content">About</a>
754
+ ```
755
+
756
+ Boosted links:
757
+ - Fetch pages via AJAX
758
+ - Update the specified target (default: `body`)
759
+ - Push to browser history
760
+ - Update page title
761
+ - Fall back to normal navigation on error
762
+
763
+ ---
764
+
765
+ ## History Management
766
+
767
+ Update browser URL after actions:
768
+
769
+ ```html
770
+ <!-- Push new URL to history -->
771
+ <button beam-action="loadTab" beam-data-tab="settings" beam-push="/settings">
772
+ Settings
773
+ </button>
774
+
775
+ <!-- Replace current URL (no new history entry) -->
776
+ <button beam-action="filter" beam-data-page="2" beam-replace="?page=2">
777
+ Page 2
778
+ </button>
779
+ ```
780
+
781
+ ---
782
+
783
+ ## Keep Elements
784
+
785
+ Preserve specific elements during DOM updates (useful for video players, animations):
786
+
787
+ ```html
788
+ <div id="content">
789
+ <!-- This video keeps playing during updates -->
790
+ <video beam-keep src="video.mp4" autoplay></video>
791
+
792
+ <!-- This content gets updated -->
793
+ <div id="comments">
794
+ <!-- Comments here -->
795
+ </div>
796
+ </div>
797
+ ```
798
+
799
+ ---
800
+
801
+ ## Client-Side UI State
802
+
803
+ These features handle UI interactions entirely on the client without server requests. They replace common Alpine.js patterns.
804
+
805
+ ### Toggle
806
+
807
+ Show/hide elements with optional transitions:
808
+
809
+ ```html
810
+ <!-- Basic toggle -->
811
+ <button beam-toggle="#menu">Toggle Menu</button>
812
+ <div id="menu" beam-hidden>
813
+ <a href="/home">Home</a>
814
+ <a href="/about">About</a>
815
+ </div>
816
+
817
+ <!-- With fade transition -->
818
+ <button beam-toggle="#panel">Show Panel</button>
819
+ <div id="panel" beam-hidden beam-transition="fade">
820
+ Fades in and out smoothly
821
+ </div>
822
+
823
+ <!-- With slide transition -->
824
+ <button beam-toggle="#dropdown">Open</button>
825
+ <div id="dropdown" beam-hidden beam-transition="slide">
826
+ Slides down from top
827
+ </div>
828
+ ```
829
+
830
+ The trigger button automatically gets `aria-expanded` attribute updated.
831
+
832
+ ### Dropdown
833
+
834
+ Dropdowns with automatic outside-click closing and Escape key support:
835
+
836
+ ```html
837
+ <div beam-dropdown>
838
+ <button beam-dropdown-trigger>Account â–Ľ</button>
839
+ <div beam-dropdown-content beam-hidden>
840
+ <a href="/profile">Profile</a>
841
+ <a href="/settings">Settings</a>
842
+ <a href="/logout">Logout</a>
843
+ </div>
844
+ </div>
845
+ ```
846
+
847
+ Features:
848
+ - Click outside to close
849
+ - Press Escape to close
850
+ - Only one dropdown open at a time
851
+ - Automatic `aria-expanded` management
852
+
853
+ ### Collapse
854
+
855
+ Expand/collapse content with automatic button text swapping:
856
+
857
+ ```html
858
+ <button beam-collapse="#details" beam-collapse-text="Show less">
859
+ Show more
860
+ </button>
861
+ <div id="details" beam-collapsed>
862
+ <p>This content is initially hidden.</p>
863
+ <p>Click the button to expand/collapse.</p>
864
+ <p>Notice the button text changes automatically!</p>
865
+ </div>
866
+ ```
867
+
868
+ The button text swaps between "Show more" and "Show less" on each click.
869
+
870
+ ### Class Toggle
871
+
872
+ Toggle CSS classes on elements:
873
+
874
+ ```html
875
+ <!-- Toggle class on another element -->
876
+ <button beam-class-toggle="active" beam-class-target="#sidebar">
877
+ Toggle Sidebar
878
+ </button>
879
+ <div id="sidebar">Sidebar content</div>
880
+
881
+ <!-- Toggle class on self -->
882
+ <button beam-class-toggle="pressed">
883
+ Toggle Me
884
+ </button>
885
+ ```
886
+
887
+ ### CSS for Transitions
888
+
889
+ Include the Beam CSS for transition support:
890
+
891
+ ```css
892
+ /* Required base styles */
893
+ [beam-hidden] { display: none !important; }
894
+ [beam-collapsed] { display: none !important; }
895
+
896
+ /* Fade transition */
897
+ [beam-transition="fade"] {
898
+ transition: opacity 150ms ease-out;
899
+ }
900
+ [beam-transition="fade"][beam-hidden] {
901
+ opacity: 0;
902
+ pointer-events: none;
903
+ display: block !important;
904
+ }
905
+
906
+ /* Slide transition */
907
+ [beam-transition="slide"] {
908
+ transition: opacity 150ms ease-out, transform 150ms ease-out;
909
+ }
910
+ [beam-transition="slide"][beam-hidden] {
911
+ opacity: 0;
912
+ transform: translateY(-10px);
913
+ pointer-events: none;
914
+ display: block !important;
915
+ }
916
+ ```
917
+
918
+ Or import the included CSS:
919
+
920
+ ```typescript
921
+ import '@benqoder/beam/styles'
922
+ // or
923
+ import '@benqoder/beam/beam.css'
924
+ ```
925
+
926
+ ### Migration from Alpine.js
927
+
928
+ **Before (Alpine.js):**
929
+ ```html
930
+ <div x-data="{ open: false }">
931
+ <button @click="open = !open">Menu</button>
932
+ <div x-show="open" x-cloak>Content</div>
933
+ </div>
934
+ ```
935
+
936
+ **After (Beam):**
937
+ ```html
938
+ <div>
939
+ <button beam-toggle="#menu">Menu</button>
940
+ <div id="menu" beam-hidden>Content</div>
941
+ </div>
942
+ ```
943
+
944
+ **Before (Alpine.js dropdown):**
945
+ ```html
946
+ <div x-data="{ open: false }" @click.outside="open = false">
947
+ <button @click="open = !open">Account</button>
948
+ <div x-show="open">Dropdown</div>
949
+ </div>
950
+ ```
951
+
952
+ **After (Beam):**
953
+ ```html
954
+ <div beam-dropdown>
955
+ <button beam-dropdown-trigger>Account</button>
956
+ <div beam-dropdown-content beam-hidden>Dropdown</div>
957
+ </div>
958
+ ```
959
+
960
+ ---
961
+
962
+ ## Server API
963
+
964
+ ### createBeam
965
+
966
+ Creates a Beam instance with handlers:
967
+
968
+ ```typescript
969
+ import { createBeam } from '@benqoder/beam'
970
+
971
+ const beam = createBeam<Env>({
972
+ actions: { increment, decrement },
973
+ modals: { editUser, confirmDelete },
974
+ drawers: { settings, cart },
975
+ })
976
+ ```
977
+
978
+ ### ModalFrame
979
+
980
+ Wrapper component for modals:
981
+
982
+ ```tsx
983
+ import { ModalFrame } from '@benqoder/beam'
984
+
985
+ export function myModal(c) {
986
+ return (
987
+ <ModalFrame title="Modal Title">
988
+ <p>Modal content</p>
989
+ </ModalFrame>
990
+ )
991
+ }
992
+ ```
993
+
994
+ ### DrawerFrame
995
+
996
+ Wrapper component for drawers:
997
+
998
+ ```tsx
999
+ import { DrawerFrame } from '@benqoder/beam'
1000
+
1001
+ export function myDrawer(c) {
1002
+ return (
1003
+ <DrawerFrame title="Drawer Title">
1004
+ <p>Drawer content</p>
1005
+ </DrawerFrame>
1006
+ )
1007
+ }
1008
+ ```
1009
+
1010
+ ### render
1011
+
1012
+ Utility to render JSX to HTML string:
1013
+
1014
+ ```typescript
1015
+ import { render } from '@benqoder/beam'
1016
+
1017
+ const html = render(<div>Hello</div>)
1018
+ ```
1019
+
1020
+ ---
1021
+
1022
+ ## Vite Plugin Options
1023
+
1024
+ ```typescript
1025
+ beamPlugin({
1026
+ // Glob patterns for handler files (must start with '/' for virtual modules)
1027
+ actions: '/app/actions/*.tsx', // default
1028
+ modals: '/app/modals/*.tsx', // default
1029
+ drawers: '/app/drawers/*.tsx', // default
1030
+ })
1031
+ ```
1032
+
1033
+ ---
1034
+
1035
+ ## TypeScript
1036
+
1037
+ ### Handler Types
1038
+
1039
+ ```typescript
1040
+ import type { ActionHandler, ModalHandler, DrawerHandler } from '@benqoder/beam'
1041
+
1042
+ const myAction: ActionHandler<Env> = (c) => {
1043
+ return <div>Hello</div>
1044
+ }
1045
+
1046
+ const myModal: ModalHandler<Env> = (c) => {
1047
+ return <ModalFrame title="Hi"><p>Content</p></ModalFrame>
1048
+ }
1049
+
1050
+ const myDrawer: DrawerHandler<Env> = (c) => {
1051
+ return <DrawerFrame title="Hi"><p>Content</p></DrawerFrame>
1052
+ }
1053
+ ```
1054
+
1055
+ ### Virtual Module Types
1056
+
1057
+ Add to your `app/vite-env.d.ts`:
1058
+
1059
+ ```typescript
1060
+ /// <reference types="@benqoder/beam/virtual" />
1061
+ ```
1062
+
1063
+ ---
1064
+
1065
+ ## Examples
1066
+
1067
+ ### Todo List
1068
+
1069
+ ```tsx
1070
+ // actions/todos.tsx
1071
+ let todos = ['Learn Beam', 'Build something']
1072
+
1073
+ export function addTodo(c) {
1074
+ const text = c.req.query('text')
1075
+ if (text) todos.push(text)
1076
+ return <TodoList todos={todos} />
1077
+ }
1078
+
1079
+ export function deleteTodo(c) {
1080
+ const index = parseInt(c.req.query('index'))
1081
+ todos.splice(index, 1)
1082
+ return <TodoList todos={todos} />
1083
+ }
1084
+
1085
+ function TodoList({ todos }) {
1086
+ return (
1087
+ <ul>
1088
+ {todos.map((todo, i) => (
1089
+ <li>
1090
+ {todo}
1091
+ <button beam-action="deleteTodo" beam-data-index={i} beam-target="#todos">
1092
+ Delete
1093
+ </button>
1094
+ </li>
1095
+ ))}
1096
+ </ul>
1097
+ )
1098
+ }
1099
+ ```
1100
+
1101
+ ```html
1102
+ <form beam-action="addTodo" beam-target="#todos" beam-reset>
1103
+ <input name="text" placeholder="New todo" />
1104
+ <button type="submit">Add</button>
1105
+ </form>
1106
+ <div id="todos">
1107
+ <!-- Todo list renders here -->
1108
+ </div>
1109
+ ```
1110
+
1111
+ ### Live Search
1112
+
1113
+ ```tsx
1114
+ // actions/search.tsx
1115
+ export function search(c) {
1116
+ const query = c.req.query('q') || ''
1117
+ const results = searchDatabase(query)
1118
+
1119
+ return (
1120
+ <ul>
1121
+ {results.map(item => (
1122
+ <li>{item.name}</li>
1123
+ ))}
1124
+ </ul>
1125
+ )
1126
+ }
1127
+ ```
1128
+
1129
+ ```html
1130
+ <input
1131
+ beam-action="search"
1132
+ beam-target="#results"
1133
+ beam-watch="input"
1134
+ beam-debounce="300"
1135
+ name="q"
1136
+ placeholder="Search..."
1137
+ />
1138
+ <div id="results"></div>
1139
+ ```
1140
+
1141
+ ### Shopping Cart with Badge
1142
+
1143
+ ```tsx
1144
+ // actions/cart.tsx
1145
+ let cart = []
1146
+
1147
+ export function addToCart(c) {
1148
+ const product = c.req.query('product')
1149
+ cart.push(product)
1150
+
1151
+ return (
1152
+ <>
1153
+ <div>Added {product} to cart!</div>
1154
+ <span id="cart-badge">{cart.length}</span>
1155
+ </>
1156
+ )
1157
+ }
1158
+ ```
1159
+
1160
+ ```html
1161
+ <span id="cart-badge" beam-hungry>0</span>
1162
+
1163
+ <button beam-action="addToCart" beam-data-product="Widget" beam-target="#message">
1164
+ Add Widget
1165
+ </button>
1166
+ <div id="message"></div>
1167
+ ```
1168
+
1169
+ ---
1170
+
1171
+ ## Session Management
1172
+
1173
+ Beam provides automatic session management with a simple `ctx.session` API. No boilerplate middleware required.
1174
+
1175
+ ### Quick Start
1176
+
1177
+ 1. **Enable sessions in vite.config.ts:**
1178
+
1179
+ ```typescript
1180
+ beamPlugin({
1181
+ actions: '/app/actions/*.tsx',
1182
+ modals: '/app/modals/*.tsx',
1183
+ session: true, // Enable with defaults (cookie storage)
1184
+ })
1185
+ ```
1186
+
1187
+ 2. **Add SESSION_SECRET to wrangler.toml:**
1188
+
1189
+ ```toml
1190
+ [vars]
1191
+ SESSION_SECRET = "your-secret-key-change-in-production"
1192
+ ```
1193
+
1194
+ 3. **Use in actions:**
1195
+
1196
+ ```typescript
1197
+ // app/actions/cart.tsx
1198
+ export async function addToCart(ctx: BeamContext<Env>, data) {
1199
+ const cart = await ctx.session.get<CartItem[]>('cart') || []
1200
+ cart.push({ productId: data.productId, qty: data.qty })
1201
+ await ctx.session.set('cart', cart)
1202
+ return <CartBadge count={cart.length} />
1203
+ }
1204
+ ```
1205
+
1206
+ 4. **Use in routes:**
1207
+
1208
+ ```typescript
1209
+ // app/routes/products/index.tsx
1210
+ export default createRoute(async (c) => {
1211
+ const { session } = c.get('beam')
1212
+ const cart = await session.get<CartItem[]>('cart') || []
1213
+
1214
+ return c.html(
1215
+ <Layout cartCount={cart.length}>
1216
+ <ProductList />
1217
+ </Layout>
1218
+ )
1219
+ })
1220
+ ```
1221
+
1222
+ ### Session API
1223
+
1224
+ ```typescript
1225
+ // Get a value (returns null if not set)
1226
+ const cart = await ctx.session.get<CartItem[]>('cart')
1227
+
1228
+ // Set a value
1229
+ await ctx.session.set('cart', [{ productId: '123', qty: 1 }])
1230
+
1231
+ // Delete a value
1232
+ await ctx.session.delete('cart')
1233
+ ```
1234
+
1235
+ ### Storage Options
1236
+
1237
+ #### Cookie Storage (Default)
1238
+
1239
+ Session data is stored in a signed cookie. Good for small data (~4KB limit).
1240
+
1241
+ ```typescript
1242
+ beamPlugin({
1243
+ session: true, // Uses cookie storage
1244
+ })
1245
+
1246
+ // Or with custom options:
1247
+ beamPlugin({
1248
+ session: {
1249
+ secretEnvKey: 'MY_SECRET', // Default: 'SESSION_SECRET'
1250
+ cookieName: 'my_sid', // Default: 'beam_sid'
1251
+ maxAge: 86400, // Default: 1 year (in seconds)
1252
+ },
1253
+ })
1254
+ ```
1255
+
1256
+ #### KV Storage (For WebSocket Actions)
1257
+
1258
+ Cookie storage is read-only in WebSocket context. For actions that modify session data via WebSocket, use KV storage:
1259
+
1260
+ ```typescript
1261
+ // vite.config.ts
1262
+ beamPlugin({
1263
+ session: { storage: '/app/session-storage.ts' },
1264
+ })
1265
+ ```
1266
+
1267
+ ```typescript
1268
+ // app/session-storage.ts
1269
+ import { KVSession } from '@benqoder/beam'
1270
+
1271
+ export default (sessionId: string, env: { KV: KVNamespace }) =>
1272
+ new KVSession(sessionId, env.KV)
1273
+ ```
1274
+
1275
+ ```toml
1276
+ # wrangler.toml
1277
+ [[kv_namespaces]]
1278
+ binding = "KV"
1279
+ id = "your-kv-namespace-id"
1280
+
1281
+ [vars]
1282
+ SESSION_SECRET = "your-secret-key"
1283
+ ```
1284
+
1285
+ ### Architecture
1286
+
1287
+ ```
1288
+ Request comes in
1289
+ ↓
1290
+ Read session ID from signed cookie (beam_sid)
1291
+ ↓
1292
+ Create session adapter with sessionId + env
1293
+ ↓
1294
+ ctx.session.get('cart') → adapter.get()
1295
+ ctx.session.set('cart') → adapter.set()
1296
+ ```
1297
+
1298
+ **Two components:**
1299
+ - **Session ID**: Always stored in a signed cookie (`beam_sid`)
1300
+ - **Session data**: Configurable storage (cookies by default, KV optional)
1301
+
1302
+ ### Key Points
1303
+
1304
+ - **Zero boilerplate** - Just enable in vite.config.ts and use `ctx.session`
1305
+ - **Works in actions** - Use `ctx.session.get/set/delete`
1306
+ - **Works in routes** - Use `c.get('beam').session.get/set/delete`
1307
+ - **Cookie storage limit** - ~4KB total size
1308
+ - **WebSocket limitation** - Cookie storage is read-only in WebSocket (use KV for write operations)
1309
+ - **Signed cookies** - Session ID is cryptographically signed to prevent tampering
1310
+
1311
+ ---
1312
+
1313
+ ## Programmatic API
1314
+
1315
+ Call actions directly from JavaScript using `window.beam`:
1316
+
1317
+ ```javascript
1318
+ // Call action with RPC only (no DOM update)
1319
+ const response = await window.beam.logout()
1320
+
1321
+ // Call action and swap HTML into target
1322
+ await window.beam.getCartBadge({}, '#cart-count')
1323
+
1324
+ // With swap mode
1325
+ await window.beam.loadMoreProducts({ page: 2 }, { target: '#products', swap: 'append' })
1326
+
1327
+ // Full options object
1328
+ await window.beam.addToCart({ productId: 123 }, {
1329
+ target: '#cart-badge',
1330
+ swap: 'morph' // 'morph' | 'innerHTML' | 'append' | 'prepend'
1331
+ })
1332
+ ```
1333
+
1334
+ ### API Signature
1335
+
1336
+ ```typescript
1337
+ window.beam.actionName(data?, options?) → Promise<ActionResponse>
1338
+
1339
+ // data: Record<string, unknown> - parameters passed to the action
1340
+ // options: string | { target?: string, swap?: string }
1341
+ // - string shorthand: treated as target selector
1342
+ // - object: full options with target and swap mode
1343
+
1344
+ // ActionResponse: { html?: string, script?: string, redirect?: string }
1345
+ ```
1346
+
1347
+ ### Response Handling
1348
+
1349
+ The API automatically handles:
1350
+ - **HTML swapping**: If `options.target` is provided, swaps HTML into the target element
1351
+ - **Script execution**: If response contains a script, executes it
1352
+ - **Redirects**: If response contains a redirect URL, navigates to it
1353
+
1354
+ ```javascript
1355
+ // Server returns script - executed automatically
1356
+ await window.beam.showNotification({ message: 'Hello!' })
1357
+
1358
+ // Server returns redirect - navigates automatically
1359
+ await window.beam.logout() // ctx.redirect('/login')
1360
+
1361
+ // Server returns HTML + script - both handled
1362
+ await window.beam.addToCart({ id: 42 }, '#cart-badge')
1363
+ ```
1364
+
1365
+ ### Utility Methods
1366
+
1367
+ ```javascript
1368
+ window.beam.showToast(message, type?) // Show toast notification
1369
+ window.beam.closeModal() // Close current modal
1370
+ window.beam.closeDrawer() // Close current drawer
1371
+ window.beam.clearCache() // Clear action cache
1372
+ window.beam.isOnline() // Check connection status
1373
+ window.beam.getSession() // Get current session ID
1374
+ window.beam.clearScrollState() // Clear saved scroll state
1375
+ ```
1376
+
1377
+ ---
1378
+
1379
+ ## Server Redirects
1380
+
1381
+ Actions can trigger client-side redirects using `ctx.redirect()`:
1382
+
1383
+ ```typescript
1384
+ // app/actions/auth.tsx
1385
+ export function logout(ctx: BeamContext<Env>) {
1386
+ // Clear session, etc.
1387
+ return ctx.redirect('/login')
1388
+ }
1389
+
1390
+ export function requireAuth(ctx: BeamContext<Env>) {
1391
+ const user = ctx.session.get('user')
1392
+ if (!user) {
1393
+ return ctx.redirect('/login?next=' + encodeURIComponent(ctx.req.url))
1394
+ }
1395
+ // Continue with action...
1396
+ }
1397
+ ```
1398
+
1399
+ The redirect is handled automatically on the client side, whether triggered via:
1400
+ - Button/link click (`beam-action`)
1401
+ - Form submission
1402
+ - Programmatic call (`window.beam.actionName()`)
1403
+
1404
+ ---
1405
+
1406
+ ## State Preservation
1407
+
1408
+ ### Pagination URL State
1409
+
1410
+ For pagination, users expect the back button to return them to the same page. Use `beam-replace` to update the URL without a full page reload:
1411
+
1412
+ ```html
1413
+ <button
1414
+ beam-action="getProducts"
1415
+ beam-params='{"page": 2}'
1416
+ beam-target="#product-list"
1417
+ beam-replace="?page=2"
1418
+ >
1419
+ Page 2
1420
+ </button>
1421
+ ```
1422
+
1423
+ When the user navigates away and clicks back, the browser loads the URL with `?page=2`, and your page can read this to show the correct page.
1424
+
1425
+ ```tsx
1426
+ // In your route handler
1427
+ const page = parseInt(c.req.query('page') || '1')
1428
+ const products = await getProducts(page)
1429
+ ```
1430
+
1431
+ ### Infinite Scroll & Load More State
1432
+
1433
+ For infinite scroll and load more, Beam automatically preserves:
1434
+ - **Loaded content**: All items that were loaded
1435
+ - **Scroll position**: Where the user was on the page
1436
+
1437
+ This happens automatically when using `beam-infinite` or `beam-load-more`. The state is stored in the browser's `sessionStorage` with a 30-minute TTL.
1438
+
1439
+ #### Infinite Scroll (auto-trigger on visibility)
1440
+
1441
+ ```html
1442
+ <div id="product-list" class="product-grid">
1443
+ {products.map(p => <ProductCard product={p} />)}
1444
+ {hasMore && (
1445
+ <div
1446
+ class="load-more-sentinel"
1447
+ beam-infinite
1448
+ beam-action="loadMoreInfinite"
1449
+ beam-params='{"page": 2}'
1450
+ beam-target="#product-list"
1451
+ />
1452
+ )}
1453
+ </div>
1454
+ ```
1455
+
1456
+ #### Load More Button (click to trigger)
1457
+
1458
+ ```html
1459
+ <div id="product-list" class="product-grid">
1460
+ {products.map(p => <ProductCard product={p} />)}
1461
+ {hasMore && (
1462
+ <button
1463
+ class="load-more-btn"
1464
+ beam-load-more
1465
+ beam-action="loadMoreProducts"
1466
+ beam-params='{"page": 2}'
1467
+ beam-target="#product-list"
1468
+ >
1469
+ Load More
1470
+ </button>
1471
+ )}
1472
+ </div>
1473
+ ```
1474
+
1475
+ Both patterns work the same way:
1476
+ 1. User loads more content (scroll or click)
1477
+ 2. User navigates away (clicks a product)
1478
+ 3. User clicks the back button
1479
+ 4. Content and scroll position are restored
1480
+ 5. Fresh server data is morphed over cached items (using `beam-item-id`)
1481
+
1482
+ #### Keeping Items Fresh with `beam-item-id`
1483
+
1484
+ When restoring from cache, the server still renders fresh Page 1 data. To sync fresh data with cached items, add `beam-item-id` to your list items:
1485
+
1486
+ ```html
1487
+ <!-- Any list item with a unique identifier -->
1488
+ <div class="product-card" beam-item-id={product.id}>...</div>
1489
+ <div class="comment" beam-item-id={comment.id}>...</div>
1490
+ <article beam-item-id={post.slug}>...</article>
1491
+ ```
1492
+
1493
+ On back navigation:
1494
+ 1. Cache is restored (all loaded items + scroll position)
1495
+ 2. Fresh server items are morphed over matching cached items
1496
+ 3. Server data takes precedence for items in both
1497
+
1498
+ This ensures Page 1 items always have fresh data (prices, stock, etc.) while preserving the full scroll state.
1499
+
1500
+ #### Automatic Deduplication
1501
+
1502
+ When using `beam-item-id`, Beam automatically handles duplicate items when appending or prepending content:
1503
+
1504
+ 1. **Duplicates are replaced**: If an item with the same `beam-item-id` already exists, it's morphed with fresh data
1505
+ 2. **No double-insertion**: The duplicate is removed from incoming HTML after updating
1506
+
1507
+ This is useful when:
1508
+ - New items are inserted while the user is scrolling (causing offset shifts)
1509
+ - Real-time updates add items that might already exist
1510
+ - Race conditions between pagination requests
1511
+
1512
+ ```html
1513
+ <!-- Items with beam-item-id are automatically deduplicated -->
1514
+ <div id="feed" class="post-list">
1515
+ <article beam-item-id="post-123">...</article>
1516
+ <article beam-item-id="post-124">...</article>
1517
+ </div>
1518
+ ```
1519
+
1520
+ If incoming content contains `post-123` again, the existing one is updated with fresh data instead of adding a duplicate.
1521
+
1522
+ The action should return the new items plus the next trigger (sentinel or button):
1523
+
1524
+ ```tsx
1525
+ // app/actions/loadMore.tsx
1526
+ export async function loadMoreProducts(ctx, { page }) {
1527
+ const items = await getItems(page)
1528
+ const hasMore = await hasMoreItems(page)
1529
+
1530
+ return (
1531
+ <>
1532
+ {items.map(item => <ProductCard product={item} />)}
1533
+ {hasMore ? (
1534
+ <button
1535
+ beam-load-more
1536
+ beam-action="loadMoreProducts"
1537
+ beam-params={JSON.stringify({ page: page + 1 })}
1538
+ beam-target="#product-list"
1539
+ >
1540
+ Load More
1541
+ </button>
1542
+ ) : (
1543
+ <p>No more items</p>
1544
+ )}
1545
+ </>
1546
+ )
1547
+ }
1548
+ ```
1549
+
1550
+ #### Clearing Scroll State
1551
+
1552
+ To manually clear the saved scroll state:
1553
+
1554
+ ```javascript
1555
+ window.beam.clearScrollState()
1556
+ ```
1557
+
1558
+ This is useful when you want to force a fresh load, such as after a filter change or form submission.
1559
+
1560
+ ---
1561
+
1562
+ ## Browser Support
1563
+
1564
+ Beam requires modern browsers with WebSocket support:
1565
+ - Chrome 16+
1566
+ - Firefox 11+
1567
+ - Safari 7+
1568
+ - Edge 12+
1569
+
1570
+ ---
1571
+
1572
+ ## License
1573
+
1574
+ MIT