@orieljs/framework 0.1.0-alpha.6 → 0.1.0-alpha.7

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.
Files changed (86) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +10 -1
  3. package/TRADEMARKS.md +33 -0
  4. package/dist/{chunk-ZOEPKBVF.js → chunk-4MWG3B7G.js} +1347 -428
  5. package/dist/chunk-4MWG3B7G.js.map +1 -0
  6. package/dist/{chunk-POEP4B2M.js → chunk-7ZQZYDOC.js} +745 -105
  7. package/dist/chunk-7ZQZYDOC.js.map +1 -0
  8. package/dist/{chunk-I63ZFMX6.js → chunk-GVNLZZTN.js} +1 -1
  9. package/dist/chunk-GVNLZZTN.js.map +1 -0
  10. package/dist/{chunk-MFTZQMZM.js → chunk-VOYW3R5J.js} +4 -4
  11. package/dist/chunk-VOYW3R5J.js.map +1 -0
  12. package/dist/contracts.d.ts +515 -17
  13. package/dist/contracts.js +1 -1
  14. package/dist/index.d-WQpfdIbG.d.ts +630 -0
  15. package/dist/index.d.ts +964 -152
  16. package/dist/index.js +4279 -866
  17. package/dist/index.js.map +1 -1
  18. package/dist/{jsx-runtime-CBZQYYDS.d.ts → jsx-runtime-6um4Mwfb.d.ts} +96 -5
  19. package/dist/jsx-runtime.d.ts +2 -2
  20. package/dist/jsx-runtime.js +3 -3
  21. package/dist/{testing-BM481Hdz.d.ts → testing-CKwfWJGn.d.ts} +180 -15
  22. package/dist/testing.d.ts +2 -2
  23. package/dist/testing.js +3 -3
  24. package/docs/authentication.md +487 -0
  25. package/docs/authorization.md +435 -0
  26. package/docs/cache.md +647 -0
  27. package/docs/collections.md +2270 -0
  28. package/docs/configuration.md +334 -0
  29. package/docs/console.md +651 -0
  30. package/docs/container.md +430 -0
  31. package/docs/controllers.md +406 -0
  32. package/docs/csrf.md +126 -0
  33. package/docs/database-testing.md +144 -0
  34. package/docs/database.md +236 -0
  35. package/docs/deployment.md +276 -0
  36. package/docs/eloquent-collections.md +218 -0
  37. package/docs/eloquent-factories.md +255 -0
  38. package/docs/eloquent-mutators.md +254 -0
  39. package/docs/eloquent-observers.md +145 -0
  40. package/docs/eloquent-relationships.md +557 -0
  41. package/docs/eloquent.md +494 -0
  42. package/docs/encryption.md +104 -0
  43. package/docs/errors.md +318 -0
  44. package/docs/events.md +336 -0
  45. package/docs/facades.md +278 -0
  46. package/docs/hashing.md +118 -0
  47. package/docs/helpers.md +1281 -0
  48. package/docs/http-tests.md +386 -0
  49. package/docs/inertia.md +485 -0
  50. package/docs/installation.md +209 -0
  51. package/docs/lifecycle.md +234 -0
  52. package/docs/live-tests.md +226 -0
  53. package/docs/live.md +910 -0
  54. package/docs/logging.md +255 -0
  55. package/docs/mail.md +619 -0
  56. package/docs/middleware.md +384 -0
  57. package/docs/migrations.md +416 -0
  58. package/docs/mocking.md +264 -0
  59. package/docs/navigation.json +273 -0
  60. package/docs/notifications.md +225 -0
  61. package/docs/pagination.md +247 -0
  62. package/docs/passwords.md +207 -0
  63. package/docs/providers.md +187 -0
  64. package/docs/queries.md +600 -0
  65. package/docs/queues.md +330 -0
  66. package/docs/requests.md +533 -0
  67. package/docs/responses.md +444 -0
  68. package/docs/routing.md +738 -0
  69. package/docs/scheduling.md +277 -0
  70. package/docs/seeding.md +122 -0
  71. package/docs/session.md +409 -0
  72. package/docs/starter-kits.md +396 -0
  73. package/docs/storage.md +192 -0
  74. package/docs/structure.md +264 -0
  75. package/docs/testing.md +165 -0
  76. package/docs/time-travel.md +138 -0
  77. package/docs/validation.md +1792 -0
  78. package/docs/verification.md +152 -0
  79. package/docs/views.md +566 -0
  80. package/docs/vite.md +322 -0
  81. package/package.json +34 -32
  82. package/dist/chunk-I63ZFMX6.js.map +0 -1
  83. package/dist/chunk-MFTZQMZM.js.map +0 -1
  84. package/dist/chunk-POEP4B2M.js.map +0 -1
  85. package/dist/chunk-ZOEPKBVF.js.map +0 -1
  86. package/dist/index.d-BJyJx6U1.d.ts +0 -319
package/docs/live.md ADDED
@@ -0,0 +1,910 @@
1
+ # Live
2
+
3
+ Live components are classes on the server that render TSX and answer the browser's clicks and input. You write no
4
+ client code: Live sends each action to the server and swaps in the new HTML.
5
+
6
+ ## Introduction
7
+
8
+ A Live component keeps its state in public fields and its actions in public methods. Its page carries the state,
9
+ signed; when the user clicks a button with `o:click="increment"`, the browser sends the state and the call, the
10
+ server runs `increment()` and renders the component again, and the browser patches the page with the new HTML.
11
+
12
+ Live follows Livewire 3: its components, directives, hooks and events work as Livewire's do, with `o:` in place of
13
+ `wire:`. The Live starter kit (`npm create @orieljs@latest blog -- --live`) is built with it, and is a good place to see
14
+ whole pages.
15
+
16
+ Live is part of `@orieljs/framework`. Import everything from there: `@orieljs/live` is published on its own too, but a
17
+ separate copy doesn't share the framework's per-request state.
18
+
19
+ ### The client script
20
+
21
+ In the browser, `@orieljs/live-client` starts the components on the page. The Live kit starts it in
22
+ `resources/js/app.ts`, which the layout loads with `vite()`:
23
+
24
+ ```ts title="resources/js/app.ts"
25
+ import { Live } from '@orieljs/live-client'
26
+
27
+ Live.start()
28
+ ```
29
+
30
+ Updates go to `POST /oriel/live/update`, a route in the `web` middleware group, so your sessions, the signed-in user
31
+ and CSRF protection apply to them. The client sends the CSRF token from the `XSRF-TOKEN` cookie.
32
+
33
+ ## Components
34
+
35
+ ### Creating components
36
+
37
+ `make:live` writes a component in `app/Live`, and `--test` a test for it in `tests/Feature/Live`:
38
+
39
+ ```sh
40
+ node oriel make:live Counter
41
+
42
+ node oriel make:live Posts/CreatePost --test
43
+ ```
44
+
45
+ A component is the default export of its file. Give it state and actions:
46
+
47
+ ```tsx title="app/Live/Counter.tsx"
48
+ import { Component } from '@orieljs/framework'
49
+
50
+ export default class Counter extends Component {
51
+ count = 0
52
+
53
+ increment() {
54
+ this.count++
55
+ }
56
+
57
+ decrement() {
58
+ this.count--
59
+ }
60
+
61
+ render() {
62
+ return (
63
+ <div>
64
+ <h1>{this.count}</h1>
65
+
66
+ <button type="button" o:click="increment">+</button>
67
+ <button type="button" o:click="decrement">-</button>
68
+ </div>
69
+ )
70
+ }
71
+ }
72
+ ```
73
+
74
+ `render()` returns the component's HTML, with one root element. It can be async, to read the database.
75
+
76
+ Oriel registers the components in `app/Live` at boot, by their path: `app/Live/Posts/CreatePost.tsx` is named
77
+ `posts.create-post`. `Live.component('counter', Counter)` registers one that lives elsewhere.
78
+
79
+ ### Rendering components
80
+
81
+ `<Live>` renders a component in any view, or in another component:
82
+
83
+ ```tsx
84
+ // @filename: app/Live/Counter.tsx
85
+ import { Component } from '@orieljs/framework'
86
+ export default class Counter extends Component {
87
+ count = 0
88
+ render() {
89
+ return <p />
90
+ }
91
+ }
92
+ // ---cut---
93
+ import Counter from './app/Live/Counter'
94
+
95
+ const page = (
96
+ <main>
97
+ <Live component={Counter} />
98
+ </main>
99
+ )
100
+ ```
101
+
102
+ `component` also takes a registered name, `<Live component="counter" />`. Outside TSX, `await live(Counter)` gives the
103
+ component's first render: `(await live(Counter)).toHtml()` is its HTML.
104
+
105
+ #### Passing parameters
106
+
107
+ The other props are the component's mount parameters. Each one whose name is a field of the component sets that field,
108
+ and `mount()` receives them all:
109
+
110
+ ```tsx
111
+ // @filename: app/Live/Counter.tsx
112
+ import { Component } from '@orieljs/framework'
113
+ export default class Counter extends Component {
114
+ count = 0
115
+ render() {
116
+ return <p />
117
+ }
118
+ }
119
+ // ---cut---
120
+ import Counter from './app/Live/Counter'
121
+
122
+ const page = <Live component={Counter} count={3} />
123
+ ```
124
+
125
+ ### Full-page components
126
+
127
+ A component can be a whole page: give it to a route as its action.
128
+
129
+ ```ts title="routes/web.ts"
130
+ // @filename: app/Live/Counter.tsx
131
+ import { Component } from '@orieljs/framework'
132
+ export default class Counter extends Component {
133
+ render() {
134
+ return <p />
135
+ }
136
+ }
137
+ // ---cut---
138
+ import { Route } from '@orieljs/framework'
139
+ import Counter from '../app/Live/Counter'
140
+
141
+ Route.get('/counter', Counter)
142
+ ```
143
+
144
+ The page is the component inside a layout view, `components.layouts.app` unless `live.layout` names another. The
145
+ layout receives the component's HTML as `children`, and `static title` as `title`. `static layout` picks a layout for
146
+ one component:
147
+
148
+ ```tsx title="app/Live/Auth/Login.tsx"
149
+ import { Component } from '@orieljs/framework'
150
+
151
+ export default class Login extends Component {
152
+ static override layout = 'components.layouts.auth'
153
+ static override title = 'Log in'
154
+
155
+ render() {
156
+ return <form>...</form>
157
+ }
158
+ }
159
+ ```
160
+
161
+ The route's parameters are mount parameters. A parameter named after a model is [bound](routing.md#implicit-binding)
162
+ first, so `post` is the `Post`:
163
+
164
+ ```tsx title="app/Live/ShowPost.tsx"
165
+ import { Component } from '@orieljs/framework'
166
+ import type { Post } from '../Models/Post'
167
+
168
+ export default class ShowPost extends Component {
169
+ post!: Post
170
+
171
+ render() {
172
+ return (
173
+ <article>
174
+ <h1>{this.post.title}</h1>
175
+ </article>
176
+ )
177
+ }
178
+ }
179
+ ```
180
+
181
+ ```ts title="routes/web.ts"
182
+ // @filename: app/Live/ShowPost.tsx
183
+ import { Component } from '@orieljs/framework'
184
+ export default class ShowPost extends Component {
185
+ render() {
186
+ return <p />
187
+ }
188
+ }
189
+ // ---cut---
190
+ import { Route } from '@orieljs/framework'
191
+ import ShowPost from '../app/Live/ShowPost'
192
+
193
+ Route.get('/posts/{post}', ShowPost).name('posts.show')
194
+ ```
195
+
196
+ ## Properties
197
+
198
+ A component's fields are its state. The page keeps them between requests, and `render()` reads them:
199
+
200
+ ```tsx title="app/Live/TodoList.tsx"
201
+ import { Component } from '@orieljs/framework'
202
+
203
+ export default class TodoList extends Component {
204
+ todos: string[] = []
205
+ todo = ''
206
+
207
+ add() {
208
+ this.todos.push(this.todo)
209
+ this.reset('todo')
210
+ }
211
+
212
+ render() {
213
+ return (
214
+ <form o:submit="add">
215
+ <input type="text" o:model="todo" aria-label="Todo" />
216
+ <button type="submit">Add</button>
217
+
218
+ <ul>
219
+ {this.todos.map((todo) => (
220
+ <li>{todo}</li>
221
+ ))}
222
+ </ul>
223
+ </form>
224
+ )
225
+ }
226
+ }
227
+ ```
228
+
229
+ `reset('todo')` puts a field back to its first value, and `reset()` all of them.
230
+
231
+ A field holds a string, number, boolean, `null`, `Date`, an array or a plain object of those, a saved model, a
232
+ collection or an uploaded file. A model is kept as its class and key, and read from the database again on each update.
233
+ Anything else, such as an instance of your own class, throws when the component renders, naming the field.
234
+
235
+ > [!WARNING]
236
+ > The page carries the state as JSON. It is signed, so a changed value is refused, but anyone can read it: keep
237
+ > secrets out of fields. A `#private` field isn't state: it starts again on each request.
238
+
239
+ ### Locked properties
240
+
241
+ The browser can set a field: an input with `o:model`, or a crafted request. `@Locked` stops it, for a field your code
242
+ sets and the browser must not change, such as an ID:
243
+
244
+ ```tsx title="app/Live/EditPost.tsx"
245
+ import { Component, Locked } from '@orieljs/framework'
246
+
247
+ export default class EditPost extends Component {
248
+ @Locked
249
+ id = 0
250
+
251
+ render() {
252
+ return <div>{this.id}</div>
253
+ }
254
+ }
255
+ ```
256
+
257
+ A field whose name starts with `_`, and a field holding a model or a collection, can't be set from the browser either.
258
+ To edit a model's attributes, copy them to fields and save them in an action.
259
+
260
+ ### Computed properties
261
+
262
+ `@Computed` caches a getter for the rest of the request. It isn't state, and isn't sent to the browser:
263
+
264
+ ```tsx title="app/Live/ShowUser.tsx"
265
+ import { Component, Computed } from '@orieljs/framework'
266
+ import { User } from '../Models/User'
267
+
268
+ export default class ShowUser extends Component {
269
+ userId = 1
270
+
271
+ @Computed
272
+ get user() {
273
+ return User.findOrFail(this.userId)
274
+ }
275
+
276
+ async render() {
277
+ const user = await this.user
278
+
279
+ return <h1>{user.name}</h1>
280
+ }
281
+ }
282
+ ```
283
+
284
+ Decorators work in your app, under Bun and Node, and in your tests: a new app's `vitest.config.ts` has the
285
+ `decorators()` plugin that lowers them for Node. Without decorators, `static locked = ['id']`,
286
+ `static computed = ['user']` and `static listeners = { 'post-created': 'updatePostList' }` do the same.
287
+
288
+ ## Actions
289
+
290
+ A public method is an action, which `o:click`, `o:submit` or any other `o:<event>` calls:
291
+
292
+ ```tsx
293
+ <button type="button" o:click="save">Save</button>
294
+ ```
295
+
296
+ Hooks, getters, `@Locked` methods, and methods whose name starts with `_` or `#` aren't actions.
297
+
298
+ > [!WARNING]
299
+ > Every action can be called by anyone who can see the page, with any arguments. Check that the user may do what the
300
+ > action does, as you would in a controller.
301
+
302
+ ### Passing parameters
303
+
304
+ The call is written as the method's name, with its arguments in JSON (single quotes work too, as in `'Hello'`):
305
+
306
+ ```tsx
307
+ declare const post: { id: number }
308
+ // ---cut---
309
+ <button type="button" o:click={`delete(${post.id})`}>Delete</button>
310
+ ```
311
+
312
+ `this.$action()` writes it for you, typed against the method:
313
+
314
+ ```tsx title="app/Live/Drafts.tsx"
315
+ import { Component } from '@orieljs/framework'
316
+ import { Post } from '../Models/Post'
317
+
318
+ export default class Drafts extends Component {
319
+ async publish(id: number) {
320
+ const post = await Post.findOrFail(id)
321
+ post.published_at = new Date()
322
+ await post.save()
323
+ }
324
+
325
+ async render() {
326
+ const drafts = await Post.whereNull('published_at').get()
327
+
328
+ return (
329
+ <ul>
330
+ {drafts.map((post) => (
331
+ <li>
332
+ {post.title}
333
+ <button type="button" o:click={this.$action(this.publish, post.id)}>
334
+ Publish
335
+ </button>
336
+ </li>
337
+ ))}
338
+ </ul>
339
+ )
340
+ }
341
+ }
342
+ ```
343
+
344
+ For post 5, this renders `o:click="publish(5)"`; `this.$action(this.publish, String(post.id))` is a type error, since
345
+ `publish()` takes a number. Pass IDs and plain values: a model arrives as its attributes, and a date as its ISO string.
346
+ Rendering throws when the method isn't one of the component's actions.
347
+
348
+ > [!NOTE]
349
+ > Livewire writes the call in Blade, `wire:click="publish({{ $post->id }})"`. `$action()` is Oriel's: TSX can check
350
+ > the method and its arguments.
351
+
352
+ In TSX, the `o:` attributes are type-checked: [Views](views.md#live-components) shows what TypeScript catches.
353
+
354
+ ### Event modifiers
355
+
356
+ Modifiers follow the event, joined by `-`:
357
+
358
+ ```tsx
359
+ const form = (
360
+ <form o:submit="save">
361
+ <input type="text" o:keydown-enter="search" aria-label="Search" />
362
+
363
+ <a href="#" o:click-prevent="toggle">
364
+ Toggle
365
+ </a>
366
+ </form>
367
+ )
368
+ ```
369
+
370
+ | Modifier | What it does |
371
+ | --- | --- |
372
+ | `prevent` | Calls `preventDefault()` |
373
+ | `stop` | Calls `stopPropagation()` |
374
+ | `self` | Runs only when the event's target is the element itself |
375
+ | `once` | Runs once |
376
+ | `window`, `document` | Listens on the window, or the document |
377
+ | `passive`, `capture` | Accepted, and change nothing: Live listens once for each type of event |
378
+ | A key, such as `enter`, `escape`, `space`, `tab` or `down` | On a key event, runs only for that key |
379
+
380
+ `o:submit` always prevents the form's own submission.
381
+
382
+ > [!NOTE]
383
+ > In TSX, modifiers are joined by `-`, as in `o:click-prevent`; in plain HTML, Livewire's `.` works too
384
+ > (`o:click.prevent`). A DOM event whose name has a `-` in it can't be written as an `o:` action.
385
+
386
+ ### Magic actions
387
+
388
+ | Action | What it does |
389
+ | --- | --- |
390
+ | `$refresh` | Renders the component again |
391
+ | `$set('title', 'Hello')` | Sets a field |
392
+ | `$toggle('open')` | Flips a boolean field |
393
+ | `$dispatch('post-created', { 'id': 1 })` | Dispatches an event, in the browser |
394
+ | `$commit` | Sends the pending `o:model` values |
395
+
396
+ ```tsx
397
+ <button type="button" o:click="$toggle('open')">Menu</button>
398
+ ```
399
+
400
+ ### Confirming an action
401
+
402
+ `o:confirm` asks before the action runs:
403
+
404
+ ```tsx
405
+ declare const tag: { id: number }
406
+ // ---cut---
407
+ <button type="button" o:click={`delete(${tag.id})`} o:confirm="Delete this tag?">
408
+ Delete
409
+ </button>
410
+ ```
411
+
412
+ The browser's confirm dialog shows the message; an empty `o:confirm` asks "Are you sure?". `o:confirm-prompt` asks
413
+ for a typed answer, and runs the action only when it matches the text after the `|`:
414
+
415
+ ```tsx
416
+ <button type="button" o:click="destroy" o:confirm-prompt="Type DELETE to confirm|DELETE">
417
+ Delete account
418
+ </button>
419
+ ```
420
+
421
+ A dismissed dialog stops the event, so the actions around the element don't run either, and doesn't use up a `-once`
422
+ action. On a form, put `o:confirm` on the `<form>` with its `o:submit`. A button inside a form with its own `o:click`
423
+ needs `type="button"`, or the browser submits the form when the dialog is dismissed.
424
+
425
+ ## Data binding
426
+
427
+ `o:model` binds an input to a field. The value is sent with the component's next request:
428
+
429
+ ```tsx
430
+ const input = <input type="text" o:model="title" aria-label="Title" />
431
+ ```
432
+
433
+ | Directive | Sends the value |
434
+ | --- | --- |
435
+ | `o:model` | With the next action |
436
+ | `o:model-live` | As you type, after 150 ms without a key press |
437
+ | `o:model-live-debounce-500ms` | As you type, after 500 ms |
438
+ | `o:model-blur` | When the input loses focus |
439
+ | `o:model-change` | When the input's value changes (Livewire's `.lazy`, which works too) |
440
+ | `o:model-number` | With the next action, as a number |
441
+
442
+ Checkboxes, radio buttons and selects are bound the same way. A checkbox gives `true` or `false`, or with a field that
443
+ is an array, the values of the checked boxes.
444
+
445
+ A dotted name binds a nested value: `o:model="form.title"`.
446
+
447
+ ## Lifecycle hooks
448
+
449
+ | Hook | Runs |
450
+ | --- | --- |
451
+ | `boot()` | On every request, first |
452
+ | `mount(params)` | Once, when the component is first rendered |
453
+ | `hydrate()` | On every update, before anything else changes |
454
+ | `updating(name, value)`, `updated(name, value)` | Before and after the browser sets a field |
455
+ | `updatingTitle(value, key)`, `updatedTitle(value, key)` | Before and after the browser sets `title` |
456
+ | `rendering()`, `rendered(html)` | Before and after `render()` |
457
+ | `dehydrate()` | On every request, last |
458
+
459
+ A hook can be async. `updatedTitle()` is named after the field in StudlyCase, and on a nested name, such as
460
+ `form.title`, `updatedForm()` runs with `key` set to `'title'`.
461
+
462
+ ```tsx title="app/Live/Search.tsx"
463
+ import { Component } from '@orieljs/framework'
464
+
465
+ export default class Search extends Component {
466
+ search = ''
467
+
468
+ updatedSearch(value: string) {
469
+ this.search = value.trim()
470
+ }
471
+
472
+ render() {
473
+ return <input type="search" o:model-live="search" aria-label="Search" />
474
+ }
475
+ }
476
+ ```
477
+
478
+ > [!NOTE]
479
+ > Livewire's `booted()` and `exception()` hooks aren't called in Oriel.
480
+
481
+ ## Validation
482
+
483
+ `this.validate()` checks fields against rules, and returns the valid values, typed from the rules. A failed
484
+ validation stops the action, and the component renders with its errors in `this.errors`:
485
+
486
+ ```tsx title="app/Live/CreatePost.tsx"
487
+ import { Component, request, Str } from '@orieljs/framework'
488
+ import type { User } from '../Models/User'
489
+
490
+ export default class CreatePost extends Component {
491
+ title = ''
492
+ body = ''
493
+
494
+ async save() {
495
+ const data = await this.validate({
496
+ title: 'required|string|max:255',
497
+ body: 'required|string',
498
+ })
499
+
500
+ const user = (await request().user<User>())!
501
+
502
+ await user.posts().create({ ...data, slug: Str.slug(data.title) })
503
+
504
+ this.redirect('/posts')
505
+ }
506
+
507
+ render() {
508
+ return (
509
+ <form o:submit="save">
510
+ <input type="text" o:model="title" aria-label="Title" />
511
+ {this.errors.has('title') && <p class="error">{this.errors.first('title')}</p>}
512
+
513
+ <textarea o:model="body" aria-label="Body" />
514
+ {this.errors.has('body') && <p class="error">{this.errors.first('body')}</p>}
515
+
516
+ <button type="submit">Save</button>
517
+ </form>
518
+ )
519
+ }
520
+ }
521
+ ```
522
+
523
+ `addError(key, message)` adds an error of your own, and `resetErrorBag()` clears them, or those of the keys you give.
524
+ [Validation](validation.md) lists the rules.
525
+
526
+ A component's fields aren't a request's input, so an empty one is `''`, not `null`: `nullable|integer` passes an
527
+ empty `age` and returns `''`. Start such a field at `null` (`age: number | null = null`) when it is optional.
528
+
529
+ ## Events
530
+
531
+ `this.dispatch()` sends an event. Any component on the page with an `@On` method for it runs the method, with the
532
+ event's parameters:
533
+
534
+ ```tsx title="app/Live/CreatePost.tsx"
535
+ import { Component } from '@orieljs/framework'
536
+
537
+ export default class CreatePost extends Component {
538
+ title = ''
539
+
540
+ save() {
541
+ // ...
542
+
543
+ this.dispatch('post-created', { title: this.title })
544
+ }
545
+
546
+ render() {
547
+ return <form o:submit="save">...</form>
548
+ }
549
+ }
550
+ ```
551
+
552
+ ```tsx title="app/Live/Dashboard.tsx"
553
+ import { Component, On } from '@orieljs/framework'
554
+
555
+ export default class Dashboard extends Component {
556
+ status = ''
557
+
558
+ @On('post-created')
559
+ updatePostList({ title }: Record<string, unknown>) {
560
+ this.status = `Created ${String(title)}`
561
+ }
562
+
563
+ render() {
564
+ return <p>{this.status}</p>
565
+ }
566
+ }
567
+ ```
568
+
569
+ `dispatch(...).to(Dashboard)` sends it only to the components of that class, and `dispatch(...).self()` only to the
570
+ component that sent it. A component listening for an event re-renders after its method runs.
571
+
572
+ Events are browser events on `window`, so your scripts can send and receive them:
573
+
574
+ ```ts
575
+ import { Live } from '@orieljs/live-client'
576
+
577
+ Live.dispatch('post-created', { title: 'Hello' })
578
+
579
+ Live.on('post-created', ({ title }) => {
580
+ console.log(title)
581
+ })
582
+ ```
583
+
584
+ ## Loading states
585
+
586
+ `o:loading` shows an element while the component's request runs:
587
+
588
+ ```tsx
589
+ const form = (
590
+ <form o:submit="save">
591
+ <button type="submit">Save</button>
592
+
593
+ <span o:loading>Saving...</span>
594
+ </form>
595
+ )
596
+ ```
597
+
598
+ | Directive | While the request runs |
599
+ | --- | --- |
600
+ | `o:loading` | Shows the element, which is hidden otherwise |
601
+ | `o:loading-remove` | Hides the element |
602
+ | `o:loading-class="opacity-50"` | Adds the classes (`o:loading-class-remove` removes them) |
603
+ | `o:loading-attr="disabled"` | Sets the attribute |
604
+ | `o:loading-delay` | Waits 200 ms first, so a quick request shows nothing |
605
+
606
+ `o:target` limits it to some actions or fields: `o:target="save"`, or `o:target="save,title"`.
607
+
608
+ ### Polling
609
+
610
+ `o:poll` re-renders the component every 2.5 seconds, or calls a method:
611
+
612
+ ```tsx
613
+ const stats = (
614
+ <div o:poll-15s="refreshStats">
615
+ <p>Visitors today</p>
616
+ </div>
617
+ )
618
+ ```
619
+
620
+ `o:poll-500ms` and `o:poll-15s` change the interval. Polling pauses while the browser tab is in the background, unless
621
+ you add `-keep-alive`, and with `-visible` it runs only while the element is in view.
622
+
623
+ ### Offline state
624
+
625
+ `o:offline` shows an element while the browser is offline, and takes the same `-remove`, `-class` and `-attr` forms as
626
+ `o:loading`:
627
+
628
+ ```tsx
629
+ <p o:offline>You are offline.</p>
630
+ ```
631
+
632
+ ## Nesting components
633
+
634
+ A component can render other components with `<Live>`. Give each child in a list an `o:key`, so Live knows which is
635
+ which when the list changes:
636
+
637
+ ```tsx
638
+ // @filename: app/Live/TodoItem.tsx
639
+ import { Component } from '@orieljs/framework'
640
+ export default class TodoItem extends Component {
641
+ render() {
642
+ return <li />
643
+ }
644
+ }
645
+ // ---cut---
646
+ import TodoItem from './app/Live/TodoItem'
647
+
648
+ declare const todos: { id: number; title: string }[]
649
+
650
+ const list = (
651
+ <ul>
652
+ {todos.map((todo) => (
653
+ <Live component={TodoItem} o:key={todo.id} todo={todo} />
654
+ ))}
655
+ </ul>
656
+ )
657
+ ```
658
+
659
+ A child keeps its own state. When the parent renders again, a child that is already on the page isn't mounted again:
660
+ send it new values with an event.
661
+
662
+ ## Lazy loading
663
+
664
+ A slow component can render a placeholder first, and load once it scrolls into view:
665
+
666
+ ```tsx title="app/Live/PostCount.tsx"
667
+ import { Component } from '@orieljs/framework'
668
+ import { Post } from '../Models/Post'
669
+
670
+ export default class PostCount extends Component {
671
+ static override lazy = true
672
+ count = 0
673
+
674
+ async mount() {
675
+ this.count = await Post.count()
676
+ }
677
+
678
+ placeholder() {
679
+ return <p>Counting posts...</p>
680
+ }
681
+
682
+ render() {
683
+ return <p>{this.count} posts</p>
684
+ }
685
+ }
686
+ ```
687
+
688
+ `lazy` on one `<Live>` makes that one lazy, `lazy="on-load"` loads it as soon as the page has loaded, and
689
+ `lazy={false}` turns `static lazy` off. Without `placeholder()`, the placeholder is the `live.lazy_placeholder` view,
690
+ or an empty `<div>`. Give the placeholder the same root element as `render()`.
691
+
692
+ ## Pagination
693
+
694
+ `WithPagination` gives a component a page, which `paginate()` uses:
695
+
696
+ ```tsx title="app/Live/Posts.tsx"
697
+ import { Component, Pagination, WithPagination } from '@orieljs/framework'
698
+ import { Post } from '../Models/Post'
699
+
700
+ export default class Posts extends WithPagination(Component) {
701
+ search = ''
702
+
703
+ async updatedSearch() {
704
+ await this.resetPage()
705
+ }
706
+
707
+ async render() {
708
+ const posts = await Post.whereLike('title', `%${this.search}%`).latest().paginate(10)
709
+
710
+ return (
711
+ <div>
712
+ <input type="search" o:model-live="search" aria-label="Search" />
713
+
714
+ {posts.items().map((post) => (
715
+ <p>{post.title}</p>
716
+ ))}
717
+
718
+ <Pagination paginator={posts} />
719
+ </div>
720
+ )
721
+ }
722
+ }
723
+ ```
724
+
725
+ `<Pagination>` renders the page buttons. The actions `nextPage`, `previousPage`, `gotoPage(n)`, `setPage(n)` and
726
+ `resetPage` move the page; they are async, so `await` them in your own methods. The page starts at 1.
727
+
728
+ > [!NOTE]
729
+ > Livewire keeps the page in the URL's query string. Oriel keeps it in the component: a reload starts at page 1.
730
+
731
+ ## File uploads
732
+
733
+ In a `WithFileUploads` component, `o:model` on a file input uploads the file as soon as it is picked, and sets the
734
+ field to a `TemporaryUploadedFile`:
735
+
736
+ ```tsx title="app/Live/ProfilePhoto.tsx"
737
+ import { Component, TemporaryUploadedFile, WithFileUploads } from '@orieljs/framework'
738
+
739
+ export default class ProfilePhoto extends WithFileUploads(Component) {
740
+ photo: TemporaryUploadedFile | null = null
741
+
742
+ async save() {
743
+ const { photo } = await this.validate({ photo: 'required|image|max:1024' })
744
+
745
+ await photo.store('photos')
746
+
747
+ this.reset('photo')
748
+ }
749
+
750
+ render() {
751
+ return (
752
+ <form o:submit="save">
753
+ <input type="file" o:model="photo" aria-label="Photo" />
754
+
755
+ {this.photo?.isPreviewable() && <img src={this.photo.temporaryUrl()} alt="Preview" />}
756
+
757
+ <button type="submit">Save</button>
758
+ </form>
759
+ )
760
+ }
761
+ }
762
+ ```
763
+
764
+ With `multiple`, the field is an array of files. A temporary file has the methods of an [uploaded
765
+ file](requests.md#files), such as `store()` and `storeAs()`, and `temporaryUrl()`, a signed link that previews an image,
766
+ audio or video.
767
+
768
+ Temporary files go in `live-tmp` on your default disk, and [Storage](storage.md#live-uploads) explains how they are
769
+ cleaned up. `o:loading` shows while a file uploads, and these browser events follow it, on the input:
770
+ `live-upload-start`, `live-upload-progress` (with `detail.progress`, from 0 to 100), `live-upload-finish`,
771
+ `live-upload-error` and `live-upload-cancel`. Their names have a `-`, so listen for them with `addEventListener()`
772
+ rather than an `o:` directive.
773
+
774
+ > [!WARNING]
775
+ > Every upload must also pass `live.temporary_file_upload.rules`, which allow 12 MB. A request body larger than
776
+ > `http.max_body_size`, 8 MB, gets a 413, and the field gets "The photo failed to upload.". To accept files up to the
777
+ > rule's size, raise `max_body_size` in `config/http.ts`.
778
+
779
+ ## Navigating
780
+
781
+ `o:navigate` on a link shows the next page without a full reload. Live fetches the page, merges its `<head>`, swaps
782
+ the `<body>` and starts its components:
783
+
784
+ ```tsx
785
+ const nav = (
786
+ <nav>
787
+ <a href="/dashboard" o:navigate>
788
+ Dashboard
789
+ </a>
790
+ <a href="/posts" o:navigate-hover>
791
+ Posts
792
+ </a>
793
+ </nav>
794
+ )
795
+ ```
796
+
797
+ The fetch starts on `mousedown`, or with `-hover`, when the pointer rests on the link. The new page's scripts run,
798
+ except a `<head>` script the page already has, and a `<body>` script marked `data-navigate-once` that an earlier page
799
+ ran. Unlike a full load, an inline script runs as soon as it is inserted, without waiting for an external script above
800
+ it; write the URLs in `<head>` absolute or from the root (`/build/...`).
801
+
802
+ Some pages load in full instead: one that a redirect takes to another origin, and one whose tracked assets changed.
803
+ The tags `vite()` writes carry `data-navigate-track`, so after a deploy, the first page with the new build's files
804
+ loads in full, rather than run the new bundle beside the old one.
805
+
806
+ > [!WARNING]
807
+ > Under a Content Security Policy with a nonce per request, as `vite().useCspNonce()` makes, the scripts of a page
808
+ > reached with `o:navigate` carry that page's nonce, which the current page's policy doesn't allow, so they are
809
+ > blocked. In such an app, use `o:navigate` only for links to pages without scripts of their own.
810
+
811
+ ### Redirects
812
+
813
+ An action redirects with `this.redirect(url)`, or `this.redirectRoute(name, params)` for a named route. Add
814
+ `{ navigate: true }` to go there as `o:navigate` does:
815
+
816
+ ```ts
817
+ declare const component: Component
818
+ // ---cut---
819
+ component.redirect('/posts', { navigate: true })
820
+ ```
821
+
822
+ A message an action flashes, `session().flash('status', 'Saved.')`, is in that update's response only, unless the
823
+ action redirects: then it is there on the next page.
824
+
825
+ ## Middleware on updates
826
+
827
+ Each update runs the `auth`, `verified`, `guest` and `can` middleware of the route that rendered the page again, with
828
+ that route's parameters, before the component's actions. A user who signed out in another tab gets a 401, and one who
829
+ lost an ability a 403. The list is `live.persistent_middleware`.
830
+
831
+ ## Announcing status messages
832
+
833
+ Screen readers announce a status region, `role="status"`, when its text changes. When an action brings the same
834
+ message again, such as "Saved" after a second save, nothing changes and nothing is announced. `o:announce` makes Live
835
+ take the message out and put it back, so it is announced again:
836
+
837
+ ```tsx title="app/Live/Settings.tsx"
838
+ import { Component } from '@orieljs/framework'
839
+
840
+ export default class Settings extends Component {
841
+ name = ''
842
+ #saved = false
843
+
844
+ save() {
845
+ this.#saved = true
846
+ }
847
+
848
+ render() {
849
+ return (
850
+ <form o:submit="save">
851
+ <input type="text" o:model="name" aria-label="Name" />
852
+ <button type="submit">Save</button>
853
+
854
+ <p role="status" o:announce>
855
+ {this.#saved ? 'Saved' : ''}
856
+ </p>
857
+ </form>
858
+ )
859
+ }
860
+ }
861
+ ```
862
+
863
+ Only actions do this: an update that only sends `o:model` values, refreshes or delivers an event leaves the region
864
+ alone. Keep a status for one response, in a `#private` field or a flashed message, so the next update clears it. Keep
865
+ the region to text: its children are taken out for 100 ms, so a button in it would lose focus.
866
+
867
+ ## The JavaScript API
868
+
869
+ `window.Live` is the client, once `Live.start()` has run:
870
+
871
+ | Method | What it does |
872
+ | --- | --- |
873
+ | `Live.find(id)`, `Live.first()`, `Live.all()`, `Live.getByName(name)` | Finds components on the page |
874
+ | `Live.dispatch(name, params)`, `Live.dispatchTo(component, name, params)` | Dispatches an event |
875
+ | `Live.on(name, callback)` | Listens for an event; returns the function that stops listening |
876
+ | `Live.navigate(url)` | Goes to a page as `o:navigate` does |
877
+ | `Live.settled()` | Resolves once no request or upload is running |
878
+
879
+ A component has `get(name)`, `set(name, value)`, which sends the value with the next request, `call(method, params)`,
880
+ with the arguments in an array, `call('publish', [5])`, which resolves with the action's return value, and `commit()`.
881
+
882
+ ## Configuration
883
+
884
+ Live's settings are under `live`. A new app has no `config/live.ts`; add one to change them:
885
+
886
+ | Key | Default | What it is |
887
+ | --- | --- | --- |
888
+ | `class_path` | `app/Live` | The folder whose components are registered at boot |
889
+ | `layout` | `components.layouts.app` | The layout of full-page components |
890
+ | `lazy_placeholder` | none | The view lazy components show before they load |
891
+ | `persistent_middleware` | `auth`, `verified`, `guest`, `can` | The page route's middleware each update runs again |
892
+ | `temporary_file_upload.disk` | Your default disk | Where uploads wait until they are stored |
893
+ | `temporary_file_upload.directory` | `live-tmp` | The folder on that disk |
894
+ | `temporary_file_upload.rules` | `required`, `file`, `max:12288` | The rules every upload must pass |
895
+ | `temporary_file_upload.middleware` | `throttle:60,1` | The upload route's middleware |
896
+ | `temporary_file_upload.max_upload_time` | `5` | Minutes a signed upload URL stays valid |
897
+ | `temporary_file_upload.preview_mimes` | Images, audio and video | The types `temporaryUrl()` previews |
898
+ | `temporary_file_upload.cleanup` | `true` | Deletes temporary files older than a day |
899
+
900
+ ## Testing
901
+
902
+ `Live.test(Counter)` mounts a component in a test, calls its actions and checks its HTML and state. [Live
903
+ tests](live-tests.md) covers it.
904
+
905
+ ## Differences from Livewire
906
+
907
+ > [!NOTE]
908
+ > Live has no Alpine.js, `wire:stream`, `#[Url]` query-string binding, `#[Reactive]`, `#[Modelable]`, form objects or
909
+ > JavaScript hooks (`Livewire.hook()`). `temporaryUrl()` previews don't answer range requests, which Safari needs to
910
+ > play a `<video>`.