@benqoder/beam 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +520 -90
- package/dist/client.d.ts +12 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +837 -165
- package/dist/collect.d.ts +1 -47
- package/dist/collect.d.ts.map +1 -1
- package/dist/collect.js +0 -64
- package/dist/createBeam.d.ts +5 -19
- package/dist/createBeam.d.ts.map +1 -1
- package/dist/createBeam.js +74 -54
- package/dist/index.d.ts +2 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/types.d.ts +48 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/vite.d.ts +0 -12
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +4 -10
- package/package.json +1 -1
- package/src/beam.css +2 -0
- package/dist/DrawerFrame.d.ts +0 -16
- package/dist/DrawerFrame.d.ts.map +0 -1
- package/dist/DrawerFrame.js +0 -12
- package/dist/ModalFrame.d.ts +0 -12
- package/dist/ModalFrame.d.ts.map +0 -1
- package/dist/ModalFrame.js +0 -8
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ A lightweight, declarative UI framework for building interactive web application
|
|
|
11
11
|
- **Smart Loading** - Per-action loading indicators with parameter matching
|
|
12
12
|
- **DOM Morphing** - Smooth updates via Idiomorph
|
|
13
13
|
- **Real-time Validation** - Validate forms as users type
|
|
14
|
+
- **Input Watchers** - Trigger actions on input/change events with debounce/throttle
|
|
15
|
+
- **Conditional Triggers** - Only trigger when conditions are met (`beam-watch-if`)
|
|
16
|
+
- **Dirty Form Tracking** - Track unsaved changes with indicators and warnings
|
|
17
|
+
- **Conditional Fields** - Enable/disable/show/hide fields based on other values
|
|
14
18
|
- **Deferred Loading** - Load content when scrolled into view
|
|
15
19
|
- **Polling** - Auto-refresh content at intervals
|
|
16
20
|
- **Hungry Elements** - Auto-update elements across actions
|
|
@@ -28,6 +32,8 @@ A lightweight, declarative UI framework for building interactive web application
|
|
|
28
32
|
- **Dropdowns** - Click-outside closing, Escape key support (no server)
|
|
29
33
|
- **Collapse** - Expand/collapse with text swap (no server)
|
|
30
34
|
- **Class Toggle** - Toggle CSS classes on elements (no server)
|
|
35
|
+
- **Multi-Render** - Update multiple targets in a single action response
|
|
36
|
+
- **Async Components** - Full support for HonoX async components in `ctx.render()`
|
|
31
37
|
|
|
32
38
|
## Installation
|
|
33
39
|
|
|
@@ -47,8 +53,6 @@ export default defineConfig({
|
|
|
47
53
|
plugins: [
|
|
48
54
|
beamPlugin({
|
|
49
55
|
actions: './actions/*.tsx',
|
|
50
|
-
modals: './modals/*.tsx',
|
|
51
|
-
drawers: './drawers/*.tsx',
|
|
52
56
|
}),
|
|
53
57
|
],
|
|
54
58
|
})
|
|
@@ -123,57 +127,176 @@ export function greet(c) {
|
|
|
123
127
|
|
|
124
128
|
### Modals
|
|
125
129
|
|
|
126
|
-
|
|
130
|
+
Two ways to open modals:
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
// app/modals/confirm.tsx
|
|
130
|
-
import { ModalFrame } from '@benqoder/beam'
|
|
132
|
+
**1. `beam-modal` attribute** - Explicitly opens the action result in a modal, with optional placeholder:
|
|
131
133
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
```html
|
|
135
|
+
<!-- Shows placeholder while loading, then replaces with action result -->
|
|
136
|
+
<button beam-modal="confirmDelete" beam-data-id="123" beam-size="small"
|
|
137
|
+
beam-placeholder="<div>Loading...</div>">
|
|
138
|
+
Delete Item
|
|
139
|
+
</button>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**2. `beam-action` with `ctx.modal()`** - Action decides to return a modal:
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
// app/actions/confirm.tsx
|
|
146
|
+
export function confirmDelete(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
|
|
147
|
+
return ctx.modal(
|
|
148
|
+
<div>
|
|
149
|
+
<h2>Confirm Delete</h2>
|
|
136
150
|
<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>
|
|
151
|
+
<button beam-action="deleteItem" beam-data-id={id} beam-close>Delete</button>
|
|
140
152
|
<button beam-close>Cancel</button>
|
|
141
|
-
</
|
|
142
|
-
)
|
|
153
|
+
</div>
|
|
154
|
+
, { size: 'small' })
|
|
143
155
|
}
|
|
144
156
|
```
|
|
145
157
|
|
|
158
|
+
`ctx.modal()` accepts JSX directly - no wrapper function needed. Options: `size` ('small' | 'medium' | 'large'), `spacing` (padding in pixels).
|
|
159
|
+
|
|
146
160
|
```html
|
|
147
|
-
<button beam-
|
|
148
|
-
Delete Item
|
|
149
|
-
</button>
|
|
161
|
+
<button beam-action="confirmDelete" beam-data-id="123">Delete Item</button>
|
|
150
162
|
```
|
|
151
163
|
|
|
152
164
|
### Drawers
|
|
153
165
|
|
|
154
|
-
|
|
166
|
+
Two ways to open drawers:
|
|
167
|
+
|
|
168
|
+
**1. `beam-drawer` attribute** - Explicitly opens in a drawer:
|
|
169
|
+
|
|
170
|
+
```html
|
|
171
|
+
<button beam-drawer="openCart" beam-position="right" beam-size="medium"
|
|
172
|
+
beam-placeholder="<div>Loading cart...</div>">
|
|
173
|
+
Open Cart
|
|
174
|
+
</button>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**2. `beam-action` with `ctx.drawer()`** - Action returns a drawer:
|
|
155
178
|
|
|
156
179
|
```tsx
|
|
157
|
-
// app/
|
|
158
|
-
|
|
180
|
+
// app/actions/cart.tsx
|
|
181
|
+
export function openCart(ctx: BeamContext<Env>) {
|
|
182
|
+
return ctx.drawer(
|
|
183
|
+
<div>
|
|
184
|
+
<h2>Shopping Cart</h2>
|
|
185
|
+
<div class="cart-items">{/* Cart contents */}</div>
|
|
186
|
+
<button beam-close>Close</button>
|
|
187
|
+
</div>
|
|
188
|
+
, { position: 'right', size: 'medium' })
|
|
189
|
+
}
|
|
190
|
+
```
|
|
159
191
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
192
|
+
`ctx.drawer()` accepts JSX directly. Options: `position` ('left' | 'right'), `size` ('small' | 'medium' | 'large'), `spacing` (padding in pixels).
|
|
193
|
+
|
|
194
|
+
```html
|
|
195
|
+
<button beam-action="openCart">Open Cart</button>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Multi-Render Array API
|
|
199
|
+
|
|
200
|
+
Update multiple targets in a single action response using `ctx.render()` with arrays:
|
|
201
|
+
|
|
202
|
+
**1. Explicit targets (comma-separated)**
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
export function refreshDashboard(ctx: BeamContext<Env>) {
|
|
206
|
+
return ctx.render(
|
|
207
|
+
[
|
|
208
|
+
<div class="stat-card">Visits: {visits}</div>,
|
|
209
|
+
<div class="stat-card">Users: {users}</div>,
|
|
210
|
+
<div class="stat-card">Revenue: ${revenue}</div>,
|
|
211
|
+
],
|
|
212
|
+
{ target: '#stats, #users, #revenue' }
|
|
167
213
|
)
|
|
168
214
|
}
|
|
169
215
|
```
|
|
170
216
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
217
|
+
**2. Auto-detect by ID (no targets needed)**
|
|
218
|
+
|
|
219
|
+
```tsx
|
|
220
|
+
export function refreshDashboard(ctx: BeamContext<Env>) {
|
|
221
|
+
// Client automatically finds elements by id, beam-id, or beam-item-id
|
|
222
|
+
return ctx.render([
|
|
223
|
+
<div id="stats">Visits: {visits}</div>,
|
|
224
|
+
<div id="users">Users: {users}</div>,
|
|
225
|
+
<div id="revenue">Revenue: ${revenue}</div>,
|
|
226
|
+
])
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**3. Mixed approach**
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
export function updateDashboard(ctx: BeamContext<Env>) {
|
|
234
|
+
return ctx.render(
|
|
235
|
+
[
|
|
236
|
+
<div>Header content</div>, // Uses explicit target
|
|
237
|
+
<div id="content">Main content</div>, // Auto-detected by ID
|
|
238
|
+
],
|
|
239
|
+
{ target: '#header' } // Only first item gets explicit target
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Target Resolution Order:**
|
|
245
|
+
1. Explicit target from comma-separated list (by index)
|
|
246
|
+
2. ID from the HTML fragment's root element (`id`, `beam-id`, or `beam-item-id`)
|
|
247
|
+
3. Frontend fallback (`beam-target` on the triggering element)
|
|
248
|
+
4. Skip if no target found
|
|
249
|
+
|
|
250
|
+
**Exclusion:** Use `!selector` to explicitly skip an item:
|
|
251
|
+
```tsx
|
|
252
|
+
ctx.render(
|
|
253
|
+
[<Box1 />, <Box2 />, <Box3 />],
|
|
254
|
+
{ target: '#a, !#skip, #c' } // Box2 is skipped
|
|
255
|
+
)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Async Components
|
|
259
|
+
|
|
260
|
+
`ctx.render()` fully supports HonoX async components:
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
// Async component that fetches data
|
|
264
|
+
async function UserCard({ userId }: { userId: string }) {
|
|
265
|
+
const user = await db.getUser(userId) // Async data fetch
|
|
266
|
+
return (
|
|
267
|
+
<div class="user-card">
|
|
268
|
+
<h3>{user.name}</h3>
|
|
269
|
+
<p>{user.email}</p>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Use directly in ctx.render() - no wrapper needed
|
|
275
|
+
export function loadUser(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
|
|
276
|
+
return ctx.render(<UserCard userId={id as string} />, { target: '#user' })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Works with arrays too
|
|
280
|
+
export function loadUsers(ctx: BeamContext<Env>) {
|
|
281
|
+
return ctx.render([
|
|
282
|
+
<UserCard userId="1" />,
|
|
283
|
+
<UserCard userId="2" />,
|
|
284
|
+
<UserCard userId="3" />,
|
|
285
|
+
], { target: '#user1, #user2, #user3' })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Mixed sync and async
|
|
289
|
+
export function loadDashboard(ctx: BeamContext<Env>) {
|
|
290
|
+
return ctx.render([
|
|
291
|
+
<div>Static header</div>, // Sync
|
|
292
|
+
<UserCard userId="current" />, // Async
|
|
293
|
+
<StatsWidget />, // Async
|
|
294
|
+
])
|
|
295
|
+
}
|
|
175
296
|
```
|
|
176
297
|
|
|
298
|
+
Async components are awaited automatically - no manual `Promise.resolve()` or helper functions needed.
|
|
299
|
+
|
|
177
300
|
---
|
|
178
301
|
|
|
179
302
|
## Attribute Reference
|
|
@@ -194,21 +317,26 @@ export function shoppingCart(c) {
|
|
|
194
317
|
| `beam-push` | Push URL to browser history after action | `beam-push="/new-url"` |
|
|
195
318
|
| `beam-replace` | Replace current URL in history | `beam-replace="?page=2"` |
|
|
196
319
|
|
|
197
|
-
### Modals
|
|
320
|
+
### Modals & Drawers
|
|
198
321
|
|
|
199
322
|
| Attribute | Description | Example |
|
|
200
323
|
|-----------|-------------|---------|
|
|
201
|
-
| `beam-modal` |
|
|
202
|
-
| `beam-
|
|
324
|
+
| `beam-modal` | Action to call and display result in modal | `beam-modal="editUser"` |
|
|
325
|
+
| `beam-drawer` | Action to call and display result in drawer | `beam-drawer="openCart"` |
|
|
326
|
+
| `beam-size` | Size for modal/drawer: `small`, `medium`, `large` | `beam-size="large"` |
|
|
327
|
+
| `beam-position` | Drawer position: `left`, `right` | `beam-position="left"` |
|
|
328
|
+
| `beam-placeholder` | HTML to show while loading | `beam-placeholder="<p>Loading...</p>"` |
|
|
329
|
+
| `beam-close` | Close the current modal/drawer when clicked | `beam-close` |
|
|
203
330
|
|
|
204
|
-
|
|
331
|
+
Modals and drawers can also be returned from `beam-action` using context helpers:
|
|
205
332
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
333
|
+
```tsx
|
|
334
|
+
// Modal with options
|
|
335
|
+
return ctx.modal(render(<MyModal />), { size: 'large', spacing: 20 })
|
|
336
|
+
|
|
337
|
+
// Drawer with options
|
|
338
|
+
return ctx.drawer(render(<MyDrawer />), { position: 'left', size: 'medium' })
|
|
339
|
+
```
|
|
212
340
|
|
|
213
341
|
### Forms
|
|
214
342
|
|
|
@@ -235,6 +363,40 @@ export function shoppingCart(c) {
|
|
|
235
363
|
| `beam-watch` | Event to trigger validation: `input`, `change` | `beam-watch="input"` |
|
|
236
364
|
| `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
|
|
237
365
|
|
|
366
|
+
### Input Watchers
|
|
367
|
+
|
|
368
|
+
| Attribute | Description | Example |
|
|
369
|
+
|-----------|-------------|---------|
|
|
370
|
+
| `beam-watch` | Event to trigger action: `input`, `change` | `beam-watch="input"` |
|
|
371
|
+
| `beam-debounce` | Debounce delay in milliseconds | `beam-debounce="300"` |
|
|
372
|
+
| `beam-throttle` | Throttle interval in milliseconds (alternative to debounce) | `beam-throttle="100"` |
|
|
373
|
+
| `beam-watch-if` | Condition that must be true to trigger | `beam-watch-if="value.length >= 3"` |
|
|
374
|
+
| `beam-cast` | Cast input value: `number`, `integer`, `boolean`, `trim` | `beam-cast="number"` |
|
|
375
|
+
| `beam-loading-class` | Add class to input while request is in progress | `beam-loading-class="loading"` |
|
|
376
|
+
| `beam-keep` | Preserve input focus and cursor position after morph | `beam-keep` |
|
|
377
|
+
|
|
378
|
+
### Dirty Form Tracking
|
|
379
|
+
|
|
380
|
+
| Attribute | Description | Example |
|
|
381
|
+
|-----------|-------------|---------|
|
|
382
|
+
| `beam-dirty-track` | Enable dirty tracking on a form | `<form beam-dirty-track>` |
|
|
383
|
+
| `beam-dirty-indicator` | Show element when form is dirty | `beam-dirty-indicator="#my-form"` |
|
|
384
|
+
| `beam-dirty-class` | Toggle class instead of visibility | `beam-dirty-class="has-changes"` |
|
|
385
|
+
| `beam-warn-unsaved` | Warn before leaving page with unsaved changes | `<form beam-warn-unsaved>` |
|
|
386
|
+
| `beam-revert` | Button to revert form to original values | `beam-revert="#my-form"` |
|
|
387
|
+
| `beam-show-if-dirty` | Show element when form is dirty | `beam-show-if-dirty="#my-form"` |
|
|
388
|
+
| `beam-hide-if-dirty` | Hide element when form is dirty | `beam-hide-if-dirty="#my-form"` |
|
|
389
|
+
|
|
390
|
+
### Conditional Form Fields
|
|
391
|
+
|
|
392
|
+
| Attribute | Description | Example |
|
|
393
|
+
|-----------|-------------|---------|
|
|
394
|
+
| `beam-enable-if` | Enable field when condition is true | `beam-enable-if="#subscribe:checked"` |
|
|
395
|
+
| `beam-disable-if` | Disable field when condition is true | `beam-disable-if="#country[value='']"` |
|
|
396
|
+
| `beam-visible-if` | Show field when condition is true | `beam-visible-if="#source[value='other']"` |
|
|
397
|
+
| `beam-hidden-if` | Hide field when condition is true | `beam-hidden-if="#premium:checked"` |
|
|
398
|
+
| `beam-required-if` | Make field required when condition is true | `beam-required-if="#business:checked"` |
|
|
399
|
+
|
|
238
400
|
### Deferred Loading
|
|
239
401
|
|
|
240
402
|
| Attribute | Description | Example |
|
|
@@ -478,6 +640,308 @@ export function submitForm(c) {
|
|
|
478
640
|
|
|
479
641
|
---
|
|
480
642
|
|
|
643
|
+
## Input Watchers
|
|
644
|
+
|
|
645
|
+
Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.
|
|
646
|
+
|
|
647
|
+
### Basic Usage
|
|
648
|
+
|
|
649
|
+
```html
|
|
650
|
+
<!-- Live search with debounce -->
|
|
651
|
+
<input
|
|
652
|
+
name="q"
|
|
653
|
+
placeholder="Search..."
|
|
654
|
+
beam-action="search"
|
|
655
|
+
beam-target="#results"
|
|
656
|
+
beam-watch="input"
|
|
657
|
+
beam-debounce="300"
|
|
658
|
+
/>
|
|
659
|
+
<div id="results"></div>
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### Throttle vs Debounce
|
|
663
|
+
|
|
664
|
+
Use `beam-throttle` for real-time updates (like range sliders) where you want periodic updates:
|
|
665
|
+
|
|
666
|
+
```html
|
|
667
|
+
<!-- Range slider with throttle - updates every 100ms while dragging -->
|
|
668
|
+
<input
|
|
669
|
+
type="range"
|
|
670
|
+
name="price"
|
|
671
|
+
beam-action="updatePrice"
|
|
672
|
+
beam-target="#price-display"
|
|
673
|
+
beam-watch="input"
|
|
674
|
+
beam-throttle="100"
|
|
675
|
+
/>
|
|
676
|
+
|
|
677
|
+
<!-- Search with debounce - waits 300ms after user stops typing -->
|
|
678
|
+
<input
|
|
679
|
+
name="q"
|
|
680
|
+
beam-action="search"
|
|
681
|
+
beam-target="#results"
|
|
682
|
+
beam-watch="input"
|
|
683
|
+
beam-debounce="300"
|
|
684
|
+
/>
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
### Conditional Triggers
|
|
688
|
+
|
|
689
|
+
Only trigger action when a condition is met:
|
|
690
|
+
|
|
691
|
+
```html
|
|
692
|
+
<!-- Only search when 3+ characters are typed -->
|
|
693
|
+
<input
|
|
694
|
+
name="q"
|
|
695
|
+
placeholder="Type 3+ chars to search..."
|
|
696
|
+
beam-action="search"
|
|
697
|
+
beam-target="#results"
|
|
698
|
+
beam-watch="input"
|
|
699
|
+
beam-watch-if="value.length >= 3"
|
|
700
|
+
beam-debounce="300"
|
|
701
|
+
/>
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
The condition has access to `value` (current input value) and `this` (the element).
|
|
705
|
+
|
|
706
|
+
### Type Casting
|
|
707
|
+
|
|
708
|
+
Cast input values before sending to the server:
|
|
709
|
+
|
|
710
|
+
```html
|
|
711
|
+
<!-- Send as number instead of string -->
|
|
712
|
+
<input
|
|
713
|
+
type="range"
|
|
714
|
+
name="quantity"
|
|
715
|
+
beam-action="updateQuantity"
|
|
716
|
+
beam-cast="number"
|
|
717
|
+
beam-watch="input"
|
|
718
|
+
/>
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
Cast types:
|
|
722
|
+
- `number` - Parse as float
|
|
723
|
+
- `integer` - Parse as integer
|
|
724
|
+
- `boolean` - Convert "true"/"1"/"yes" to true
|
|
725
|
+
- `trim` - Trim whitespace
|
|
726
|
+
|
|
727
|
+
### Loading Feedback
|
|
728
|
+
|
|
729
|
+
Add a class to the input while the request is in progress:
|
|
730
|
+
|
|
731
|
+
```html
|
|
732
|
+
<input
|
|
733
|
+
name="q"
|
|
734
|
+
placeholder="Search..."
|
|
735
|
+
beam-action="search"
|
|
736
|
+
beam-target="#results"
|
|
737
|
+
beam-watch="input"
|
|
738
|
+
beam-loading-class="input-loading"
|
|
739
|
+
/>
|
|
740
|
+
|
|
741
|
+
<style>
|
|
742
|
+
.input-loading {
|
|
743
|
+
border-color: blue;
|
|
744
|
+
animation: pulse 1s infinite;
|
|
745
|
+
}
|
|
746
|
+
</style>
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### Preserving Focus
|
|
750
|
+
|
|
751
|
+
Use `beam-keep` to preserve focus and cursor position after the response morphs the DOM:
|
|
752
|
+
|
|
753
|
+
```html
|
|
754
|
+
<input
|
|
755
|
+
name="bio"
|
|
756
|
+
beam-action="validateBio"
|
|
757
|
+
beam-target="#bio-feedback"
|
|
758
|
+
beam-watch="input"
|
|
759
|
+
beam-keep
|
|
760
|
+
/>
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
### Auto-Save on Blur
|
|
764
|
+
|
|
765
|
+
Trigger action when the user leaves the field:
|
|
766
|
+
|
|
767
|
+
```html
|
|
768
|
+
<input
|
|
769
|
+
name="username"
|
|
770
|
+
beam-action="saveField"
|
|
771
|
+
beam-data-field="username"
|
|
772
|
+
beam-target="#save-status"
|
|
773
|
+
beam-watch="change"
|
|
774
|
+
beam-keep
|
|
775
|
+
/>
|
|
776
|
+
<div id="save-status">Not saved yet</div>
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
## Dirty Form Tracking
|
|
782
|
+
|
|
783
|
+
Track form changes and warn users before losing unsaved work.
|
|
784
|
+
|
|
785
|
+
### Basic Usage
|
|
786
|
+
|
|
787
|
+
```html
|
|
788
|
+
<form id="profile-form" beam-dirty-track>
|
|
789
|
+
<input name="username" value="johndoe" />
|
|
790
|
+
<input name="email" value="john@example.com" />
|
|
791
|
+
<button type="submit">Save</button>
|
|
792
|
+
</form>
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
The form gets a `beam-dirty` attribute when modified.
|
|
796
|
+
|
|
797
|
+
### Dirty Indicator
|
|
798
|
+
|
|
799
|
+
Show an indicator when the form has unsaved changes:
|
|
800
|
+
|
|
801
|
+
```html
|
|
802
|
+
<h2>
|
|
803
|
+
Profile Settings
|
|
804
|
+
<span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
|
|
805
|
+
</h2>
|
|
806
|
+
|
|
807
|
+
<form id="profile-form" beam-dirty-track>
|
|
808
|
+
<!-- form fields -->
|
|
809
|
+
</form>
|
|
810
|
+
|
|
811
|
+
<style>
|
|
812
|
+
[beam-dirty-indicator] { display: none; color: orange; }
|
|
813
|
+
</style>
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
### Revert Changes
|
|
817
|
+
|
|
818
|
+
Add a button to restore original values:
|
|
819
|
+
|
|
820
|
+
```html
|
|
821
|
+
<form id="profile-form" beam-dirty-track>
|
|
822
|
+
<input name="username" value="johndoe" />
|
|
823
|
+
<input name="email" value="john@example.com" />
|
|
824
|
+
|
|
825
|
+
<button type="button" beam-revert="#profile-form" beam-show-if-dirty="#profile-form">
|
|
826
|
+
Revert Changes
|
|
827
|
+
</button>
|
|
828
|
+
<button type="submit">Save</button>
|
|
829
|
+
</form>
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
The revert button only shows when the form is dirty.
|
|
833
|
+
|
|
834
|
+
### Unsaved Changes Warning
|
|
835
|
+
|
|
836
|
+
Warn users before navigating away with unsaved changes:
|
|
837
|
+
|
|
838
|
+
```html
|
|
839
|
+
<form beam-dirty-track beam-warn-unsaved>
|
|
840
|
+
<input name="important-data" />
|
|
841
|
+
<button type="submit">Save</button>
|
|
842
|
+
</form>
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
The browser will show a confirmation dialog if the user tries to close the tab or navigate away.
|
|
846
|
+
|
|
847
|
+
### Conditional Visibility
|
|
848
|
+
|
|
849
|
+
Show/hide elements based on dirty state:
|
|
850
|
+
|
|
851
|
+
```html
|
|
852
|
+
<form id="settings" beam-dirty-track>
|
|
853
|
+
<!-- Show when dirty -->
|
|
854
|
+
<div beam-show-if-dirty="#settings" class="warning">
|
|
855
|
+
You have unsaved changes
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
<!-- Hide when dirty -->
|
|
859
|
+
<div beam-hide-if-dirty="#settings">
|
|
860
|
+
All changes saved
|
|
861
|
+
</div>
|
|
862
|
+
</form>
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
---
|
|
866
|
+
|
|
867
|
+
## Conditional Form Fields
|
|
868
|
+
|
|
869
|
+
Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.
|
|
870
|
+
|
|
871
|
+
### Enable/Disable Fields
|
|
872
|
+
|
|
873
|
+
```html
|
|
874
|
+
<label>
|
|
875
|
+
<input type="checkbox" id="subscribe" name="subscribe" />
|
|
876
|
+
Subscribe to newsletter
|
|
877
|
+
</label>
|
|
878
|
+
|
|
879
|
+
<!-- Enabled only when checkbox is checked -->
|
|
880
|
+
<input
|
|
881
|
+
type="email"
|
|
882
|
+
name="email"
|
|
883
|
+
placeholder="Enter your email..."
|
|
884
|
+
beam-enable-if="#subscribe:checked"
|
|
885
|
+
disabled
|
|
886
|
+
/>
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
### Show/Hide Fields
|
|
890
|
+
|
|
891
|
+
```html
|
|
892
|
+
<select name="source" id="source">
|
|
893
|
+
<option value="">-- Select --</option>
|
|
894
|
+
<option value="google">Google</option>
|
|
895
|
+
<option value="friend">Friend</option>
|
|
896
|
+
<option value="other">Other</option>
|
|
897
|
+
</select>
|
|
898
|
+
|
|
899
|
+
<!-- Only visible when "other" is selected -->
|
|
900
|
+
<div beam-visible-if="#source[value='other']">
|
|
901
|
+
<label>Please specify</label>
|
|
902
|
+
<input type="text" name="source-other" />
|
|
903
|
+
</div>
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
### Required Fields
|
|
907
|
+
|
|
908
|
+
```html
|
|
909
|
+
<label>
|
|
910
|
+
<input type="checkbox" id="business" name="is-business" />
|
|
911
|
+
This is a business account
|
|
912
|
+
</label>
|
|
913
|
+
|
|
914
|
+
<!-- Required only when checkbox is checked -->
|
|
915
|
+
<input
|
|
916
|
+
type="text"
|
|
917
|
+
name="company"
|
|
918
|
+
placeholder="Company name"
|
|
919
|
+
beam-required-if="#business:checked"
|
|
920
|
+
/>
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
### Condition Syntax
|
|
924
|
+
|
|
925
|
+
Conditions support:
|
|
926
|
+
- `:checked` - Checkbox/radio is checked
|
|
927
|
+
- `:disabled` - Element is disabled
|
|
928
|
+
- `:empty` - Input has no value
|
|
929
|
+
- `[value='x']` - Input value equals 'x'
|
|
930
|
+
- `[value!='x']` - Input value not equals 'x'
|
|
931
|
+
- `[value>'5']` - Numeric comparison
|
|
932
|
+
|
|
933
|
+
```html
|
|
934
|
+
<!-- Enable when country is selected -->
|
|
935
|
+
<select beam-disable-if="#country[value='']" name="state">
|
|
936
|
+
|
|
937
|
+
<!-- Show when amount is over 100 -->
|
|
938
|
+
<div beam-visible-if="#amount[value>'100']">
|
|
939
|
+
Large order discount applied!
|
|
940
|
+
</div>
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
---
|
|
944
|
+
|
|
481
945
|
## Deferred Loading
|
|
482
946
|
|
|
483
947
|
Load content only when it enters the viewport:
|
|
@@ -969,44 +1433,10 @@ Creates a Beam instance with handlers:
|
|
|
969
1433
|
import { createBeam } from '@benqoder/beam'
|
|
970
1434
|
|
|
971
1435
|
const beam = createBeam<Env>({
|
|
972
|
-
actions: { increment, decrement },
|
|
973
|
-
modals: { editUser, confirmDelete },
|
|
974
|
-
drawers: { settings, cart },
|
|
1436
|
+
actions: { increment, decrement, openModal, openCart },
|
|
975
1437
|
})
|
|
976
1438
|
```
|
|
977
1439
|
|
|
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
1440
|
### render
|
|
1011
1441
|
|
|
1012
1442
|
Utility to render JSX to HTML string:
|
|
@@ -1023,10 +1453,8 @@ const html = render(<div>Hello</div>)
|
|
|
1023
1453
|
|
|
1024
1454
|
```typescript
|
|
1025
1455
|
beamPlugin({
|
|
1026
|
-
// Glob
|
|
1456
|
+
// Glob pattern for action handler files (must start with '/' for virtual modules)
|
|
1027
1457
|
actions: '/app/actions/*.tsx', // default
|
|
1028
|
-
modals: '/app/modals/*.tsx', // default
|
|
1029
|
-
drawers: '/app/drawers/*.tsx', // default
|
|
1030
1458
|
})
|
|
1031
1459
|
```
|
|
1032
1460
|
|
|
@@ -1037,18 +1465,22 @@ beamPlugin({
|
|
|
1037
1465
|
### Handler Types
|
|
1038
1466
|
|
|
1039
1467
|
```typescript
|
|
1040
|
-
import type { ActionHandler,
|
|
1468
|
+
import type { ActionHandler, ActionResponse, BeamContext } from '@benqoder/beam'
|
|
1469
|
+
import { render } from '@benqoder/beam'
|
|
1041
1470
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1471
|
+
// Action that returns HTML string
|
|
1472
|
+
const myAction: ActionHandler<Env> = async (ctx, params) => {
|
|
1473
|
+
return '<div>Hello</div>'
|
|
1044
1474
|
}
|
|
1045
1475
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1476
|
+
// Action that returns ActionResponse with modal
|
|
1477
|
+
const openModal: ActionHandler<Env> = async (ctx, params) => {
|
|
1478
|
+
return ctx.modal(render(<div>Modal content</div>), { size: 'medium' })
|
|
1048
1479
|
}
|
|
1049
1480
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1481
|
+
// Action that returns ActionResponse with drawer
|
|
1482
|
+
const openDrawer: ActionHandler<Env> = async (ctx, params) => {
|
|
1483
|
+
return ctx.drawer(render(<div>Drawer content</div>), { position: 'right' })
|
|
1052
1484
|
}
|
|
1053
1485
|
```
|
|
1054
1486
|
|
|
@@ -1179,7 +1611,6 @@ Beam provides automatic session management with a simple `ctx.session` API. No b
|
|
|
1179
1611
|
```typescript
|
|
1180
1612
|
beamPlugin({
|
|
1181
1613
|
actions: '/app/actions/*.tsx',
|
|
1182
|
-
modals: '/app/modals/*.tsx',
|
|
1183
1614
|
session: true, // Enable with defaults (cookie storage)
|
|
1184
1615
|
})
|
|
1185
1616
|
```
|
|
@@ -1342,7 +1773,6 @@ The auth token is tied to sessions:
|
|
|
1342
1773
|
// vite.config.ts
|
|
1343
1774
|
beamPlugin({
|
|
1344
1775
|
actions: '/app/actions/*.tsx',
|
|
1345
|
-
modals: '/app/modals/*.tsx',
|
|
1346
1776
|
session: true, // Uses env.SESSION_SECRET
|
|
1347
1777
|
})
|
|
1348
1778
|
```
|
|
@@ -1470,7 +1900,7 @@ window.beam.actionName(data?, options?) → Promise<ActionResponse>
|
|
|
1470
1900
|
// - string shorthand: treated as target selector
|
|
1471
1901
|
// - object: full options with target and swap mode
|
|
1472
1902
|
|
|
1473
|
-
// ActionResponse: { html?: string, script?: string, redirect?: string }
|
|
1903
|
+
// ActionResponse: { html?: string | string[], script?: string, redirect?: string, target?: string }
|
|
1474
1904
|
```
|
|
1475
1905
|
|
|
1476
1906
|
### Response Handling
|