@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/LICENSE +21 -0
- package/README.md +1574 -0
- package/dist/DrawerFrame.d.ts +16 -0
- package/dist/DrawerFrame.d.ts.map +1 -0
- package/dist/DrawerFrame.js +12 -0
- package/dist/ModalFrame.d.ts +12 -0
- package/dist/ModalFrame.d.ts.map +1 -0
- package/dist/ModalFrame.js +8 -0
- package/dist/client.d.ts +41 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1847 -0
- package/dist/collect.d.ts +68 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/collect.js +90 -0
- package/dist/createBeam.d.ts +104 -0
- package/dist/createBeam.d.ts.map +1 -0
- package/dist/createBeam.js +421 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/render.d.ts +7 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +7 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/vite.d.ts +72 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +79 -0
- package/package.json +62 -0
- package/src/beam.css +288 -0
- package/src/virtual-beam.d.ts +4 -0
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
|