@orieljs/live 0.1.0-alpha.4 → 0.1.0-alpha.6

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/NOTICE CHANGED
@@ -7,3 +7,6 @@ This product includes software developed by the Oriel project
7
7
  Oriel is licensed under the Apache License, Version 2.0 (see LICENSE).
8
8
  The Oriel name and logo are not covered by that license; see TRADEMARKS.md
9
9
  (https://github.com/orieljs/framework/blob/main/TRADEMARKS.md).
10
+
11
+ @orieljs/support (and @orieljs/framework, which bundles it) includes transliteration data from
12
+ voku/portable-ascii (https://github.com/voku/portable-ascii), Copyright (C) 2019 Lars Moelleken, MIT License.
package/README.md CHANGED
@@ -33,7 +33,106 @@ Route.get('/counter', Counter)
33
33
 
34
34
  Embed a component in any view with `<Live component={Counter} />`. In the browser, components are driven by `@orieljs/live-client`.
35
35
 
36
- Known limitations:
36
+ ## Typed actions
37
+
38
+ `this.$action(method, ...arguments)` writes an action for an `o:` directive, typed against the method:
39
+
40
+ ```tsx
41
+ <button type="button" o:click={this.$action(this.revokeAdmin, user.id)}>
42
+ Revoke admin
43
+ </button>
44
+ ```
45
+
46
+ This renders `o:click="revokeAdmin(5)"`, and when `revokeAdmin(id: number)` takes a number,
47
+ `this.$action(this.revokeAdmin, String(user.id))` is a type error. The arguments are written as JSON, so pass ids and
48
+ plain values (strings, numbers, booleans, `null`, arrays and plain objects): a model arrives as its attributes and a
49
+ date as its ISO string, so pass `post.id`, not `post`. The action contains double quotes, so use it as a TSX attribute
50
+ value, which escapes them, not inside an HTML string you write by hand. Rendering throws when the method isn't one of
51
+ the component's actions (a hook, or a `#private` or `@Locked` method). The types check what your pages send; a crafted
52
+ request can still send anything, so an action checks what it is given, as a controller does.
53
+
54
+ ## Confirming an action
55
+
56
+ `o:confirm` asks before an action runs, as Livewire's `wire:confirm`:
57
+
58
+ ```tsx
59
+ <button type="button" class="btn btn-danger" o:click={`delete(${tag.id})`} o:confirm="Delete this tag?">
60
+ Delete
61
+ </button>
62
+ ```
63
+
64
+ The browser's confirm dialog shows the message (an empty `o:confirm` asks "Are you sure?", and `\n` is a line break),
65
+ and the action runs only when it is accepted. `o:confirm-prompt="Type DELETE to confirm|DELETE"` (`o:confirm.prompt`
66
+ in plain HTML) asks with a prompt instead, and runs the action only when the answer is exactly the text after the `|`.
67
+ It works on any element with an `o:` action: `o:click`, `o:submit` on a form, `o:keydown-enter`, and the others. A
68
+ dismissed dialog stops the event, so the actions of the elements around it don't run either. `o:confirm` guards the
69
+ actions of the element it sits on: on a form, put it on the `<form>` with the `o:submit`. A button inside a form with
70
+ its own `o:click` needs `type="button"` or `-prevent` (`o:click-prevent`), or the browser still submits the form when
71
+ the dialog is dismissed.
72
+
73
+ ## Pagination
74
+
75
+ ```tsx
76
+ import { Component, Pagination, WithPagination } from '@orieljs/framework'
77
+ import { Post } from '../Models/Post'
78
+
79
+ export default class Posts extends WithPagination(Component) {
80
+ search = ''
81
+
82
+ updatedSearch() {
83
+ this.resetPage()
84
+ }
85
+
86
+ async render() {
87
+ const posts = await Post.whereLike('title', `%${this.search}%`).latest().paginate(10)
88
+ return (
89
+ <div>
90
+ <input o:model-live="search" aria-label="Search" />
91
+ {posts.items().map((post) => <p>{post.title}</p>)}
92
+ <Pagination paginator={posts} class="flex gap-2" />
93
+ </div>
94
+ )
95
+ }
96
+ }
97
+ ```
98
+
99
+ `paginate()` in the component (in `render()`, in `mount()`, in actions and the update hooks, or in a `@Computed`
100
+ getter any of them reads) shows the component's `page`, and its links are built on the page's URL. The actions
101
+ `nextPage`, `previousPage`, `gotoPage(n)`, `setPage(n)` and `resetPage` move it; `<Pagination>` renders them as
102
+ buttons with the kits' `btn` classes. The page starts at 1 and isn't kept in the address bar.
103
+
104
+ ## Middleware on updates
105
+
106
+ Each update re-runs the `auth`, `verified`, `guest` and `can` middleware of the route that rendered the page
107
+ (Livewire's persistent middleware), with that route's parameters bound again, so `can:update,post` sees the post. A
108
+ signed-out tab gets a 401 and a user who lost the ability a 403, before the component's actions run. On a page behind
109
+ one of these middleware, a model deleted since the page rendered gets a 404 too; a page with none binds nothing on
110
+ updates. The list is `live.persistent_middleware` in `config/live.ts`.
111
+
112
+ ## Flash data in updates
113
+
114
+ A status an action flashes (`session().flash('status', 'Saved.')`) shows in that update's response only, unless the
115
+ action redirects or navigates: then it is there on the next page. Component state is unaffected.
116
+
117
+ ## Status messages announced again
118
+
119
+ Screen readers announce a status region (`role="status"`) when its text changes. When an action brings the same
120
+ message again ("Saved" after a second save), the page doesn't change and nothing is announced. Add `o:announce` to the
121
+ region and Live takes the message out and puts it back a moment later, so it is announced again:
122
+
123
+ ```tsx
124
+ <p role="status" o:announce>
125
+ {this.#saved ? 'Saved' : ''}
126
+ </p>
127
+ ```
128
+
129
+ Only actions do this, and an `o:poll` that calls a method counts as one: an update that only syncs `o:model` values,
130
+ refreshes or delivers an event leaves the region alone. Keep a status for one response (a `#private` field, or a
131
+ flashed `status`), so that any other update clears it; a status kept in a public property is announced again by every
132
+ action until it changes, which in a polled component is every tick. Keep an `o:announce` region to text: its children
133
+ are taken out for 100 ms, so a button or input inside it would lose focus.
134
+
135
+ ## Known limitations
37
136
 
38
137
  - `o:navigate` does not run scripts in the new page.
39
138
  - A DOM event whose name contains `-` can't be written as `o:<event>` in TSX, because modifiers are `-` segments (for example `o:model-live`).
package/dist/index.d.ts CHANGED
@@ -74,6 +74,13 @@ declare abstract class Component {
74
74
  reset(...properties: string[]): void;
75
75
  /** Keeps the current HTML: the response carries the new state but no `html` effect. */
76
76
  skipRender(): void;
77
+ /**
78
+ * An action for an `o:` directive, typed against its method: `o:click={this.$action(this.delete, tag.id)}` writes
79
+ * `delete(5)`, and a string where `delete` takes a number is a type error. The arguments are written as JSON, the
80
+ * trailing `undefined` ones left out so the method's defaults apply. The method must be one of this component's
81
+ * actions (a public method of its class, not a hook and not @Locked); anything else throws while rendering.
82
+ */
83
+ $action<P extends unknown[]>(method: (...params: P) => unknown, ...params: NoInfer<P>): string;
77
84
  /** A full-page component as a route action: Route.get('/counter', Counter). Route parameters go to mount(). */
78
85
  __invoke(): Promise<unknown>;
79
86
  }
@@ -125,6 +132,9 @@ declare class Testable<C extends Component = Component> {
125
132
  assertDontSee(...values: string[]): this;
126
133
  /** Each value appears in the rendered HTML as written (not escaped). */
127
134
  assertSeeHtml(...values: string[]): this;
135
+ /** Each value, HTML-escaped, appears in the rendered text: the HTML with its tags stripped (Livewire's assertSeeText). */
136
+ assertSeeText(...values: string[]): this;
137
+ assertDontSeeText(...values: string[]): this;
128
138
  assertDispatched(name: string, params?: Record<string, unknown>): this;
129
139
  assertNotDispatched(name: string): this;
130
140
  /** The error bag has errors for every key given (or any error at all with no keys). */
@@ -189,9 +199,11 @@ declare class LiveComponentManager implements LiveManager {
189
199
  update(request: LiveUpdateRequest): Promise<LiveUpdateResponse>;
190
200
  updateComponent(snapshotJson: string, updates: Record<string, unknown>, calls: Call[]): Promise<ComponentResult>;
191
201
  /**
192
- * The route action of POST /oriel/live/update. Each component's update runs behind the auth middleware of the route
193
- * that rendered its page (Livewire's persistent middleware): a signed-out tab gets a 401, never the action. When a
194
- * middleware answers instead (a redirect), that is the response.
202
+ * The route action of POST /oriel/live/update. Each component's update runs behind the persistent middleware of the
203
+ * route that rendered its page (Livewire's): `auth`, `verified`, `guest` and `can`, with the page route's parameters
204
+ * bound again. A signed-out tab gets a 401, a revoked ability a 403 and a deleted model a 404, before the component
205
+ * hydrates. When a middleware answers instead (a redirect), that is the response. A key first flashed during the
206
+ * update shows in this response only, unless the update redirects.
195
207
  */
196
208
  handle(request: HttpRequest): Promise<LiveUpdateResponse | Response>;
197
209
  /** A full-page component (Route.get('/counter', Counter)): mounted with the route parameters, inside the layout. */
@@ -206,8 +218,14 @@ declare class LiveComponentManager implements LiveManager {
206
218
  private applyUpdate;
207
219
  /** Runs one call from the browser: a magic action, an event, or a public method. Returns its return value. */
208
220
  private call;
209
- /** Renders the component (rendering/rendered hooks, nested children tracked) and checks for a single root. */
221
+ /**
222
+ * Renders the component (rendering/rendered hooks, nested children tracked) and checks for a single root. A
223
+ * WithPagination component's page and page URL are what paginate() reads while it renders, as in its mount() and in
224
+ * its update's hooks and actions.
225
+ */
210
226
  private render;
227
+ /** The absolute URL of the page a memo path belongs to, on the current request's origin ('/' + path without one). */
228
+ private pageUrl;
211
229
  /** Dispatches, redirects: the effects every response carries. */
212
230
  private effects;
213
231
  /** dehydrate() hook, then the state as snapshot data, the error bag, and the checksum over both. */
@@ -217,8 +235,9 @@ declare class LiveComponentManager implements LiveManager {
217
235
  /** The URI the client runtime posts updates to. */
218
236
  declare const UPDATE_URI = "/oriel/live/update";
219
237
  /**
220
- * Binds the LiveManager, merges the `live` config, registers POST /oriel/live/update (named `live.update`, in the `web`
221
- * group: sessions and CSRF apply), the make:live command, and the components under app/Live.
238
+ * Binds the LiveManager and the PaginationState that WithPagination components answer, merges the `live` config,
239
+ * registers POST /oriel/live/update (named `live.update`, in the `web` group: sessions and CSRF apply), the make:live
240
+ * command, and the components under app/Live.
222
241
  */
223
242
  declare class LiveServiceProvider extends ServiceProvider {
224
243
  register(): void;
@@ -277,10 +296,57 @@ interface PersistentMiddleware {
277
296
  readonly parameters: readonly string[];
278
297
  }
279
298
  /**
280
- * Livewire's persistent middleware: the route that rendered the page (the component's memo path and method) is
281
- * matched again, and the middleware among `persistent` (aliases) that it ran are returned, to run before an update.
282
- * A path no route matches has none.
299
+ * Livewire's persistent middleware: the middleware among `persistent` (aliases) that the page route (the component's
300
+ * memo path and method) ran, in the order an update runs them. A path no route matches has none.
283
301
  */
284
302
  declare function persistentMiddleware(router: Router, persistent: readonly string[], method: string, path: string): PersistentMiddleware[];
285
303
 
286
- export { Component, type ComponentResult, Computed, CorruptComponentPayloadError, ErrorBag, InvalidLiveRequestError, Live, LiveComponentManager, LiveComponentNotFoundError, LiveError, type LiveProps, LiveServiceProvider, Locked, MethodNotCallableError, On, PERSISTENT_MIDDLEWARE, PendingEvent, type PersistentMiddleware, PropertyNotUpdatableError, TestRequestScope, Testable, UPDATE_URI, componentName, live, persistentMiddleware };
304
+ type ComponentConstructor = abstract new (...args: any[]) => Component;
305
+ /** What WithPagination() adds to a component: its page and the actions that move it. */
306
+ interface Paginated {
307
+ /** The current page. The browser may set it; Live reads it as a page number. */
308
+ page: number;
309
+ nextPage(): Promise<void>;
310
+ previousPage(): Promise<void>;
311
+ gotoPage(page: number | string): Promise<void>;
312
+ resetPage(): Promise<void>;
313
+ setPage(page: number | string): Promise<void>;
314
+ }
315
+ /**
316
+ * Livewire's WithPagination: `class Posts extends WithPagination(Component)`. The public `page` (1 to start; there
317
+ * is no address-bar sync) is what `paginate()` shows in the component (in `render()`, `mount()`, `hydrate()`, the
318
+ * update hooks and actions, or in a `@Computed` getter any of them reads), and its page links are built on the
319
+ * page's URL. The actions move it: `o:click="nextPage"`, `previousPage`, `gotoPage(3)`, `setPage(3)`,
320
+ * `resetPage` (call it from `updatedSearch()` so a new search starts on page 1). `updatingPage(page)` and
321
+ * `updatedPage(page)` run around every change. One paginator per component: other page names read the request.
322
+ */
323
+ declare function WithPagination<TBase extends ComponentConstructor>(Base: TBase): TBase & (abstract new (...args: any[]) => Paginated);
324
+
325
+ /**
326
+ * What <Pagination> reads from a paginator. Structural, since Live can't import Database: paginate()'s
327
+ * LengthAwarePaginator (with lastPage(), so page buttons) and simplePaginate()'s Paginator (Previous and Next only).
328
+ */
329
+ interface PaginationSource {
330
+ currentPage(): number;
331
+ lastPage?(): number;
332
+ hasMorePages(): boolean;
333
+ onFirstPage(): boolean;
334
+ /** Page buttons on each side of the current one (the paginator's onEachSide(), 3 by default). */
335
+ readonly onEachSideCount?: number;
336
+ /** Not cursorPaginate(): its pages are cursors, which a component's page number can't follow. */
337
+ readonly nextCursor?: never;
338
+ }
339
+ interface PaginationProps {
340
+ paginator: PaginationSource;
341
+ /** Classes for the <nav>, such as its layout ("flex gap-2"); in your file, so Tailwind generates them. */
342
+ class?: string;
343
+ }
344
+ /**
345
+ * Livewire's pagination links for a WithPagination component: `<Pagination paginator={posts} />` in render(). A
346
+ * <nav aria-label="Pagination"> with « Previous, the page buttons and Next » as Live actions (previousPage,
347
+ * gotoPage(n), nextPage), the current page marked aria-current="page", the ends disabled, the kits' theme classes
348
+ * (btn, btn-secondary). Nothing when everything fits on one page. The address bar is left alone.
349
+ */
350
+ declare function Pagination({ paginator, class: className }: PaginationProps): Htmlable;
351
+
352
+ export { Component, type ComponentResult, Computed, CorruptComponentPayloadError, ErrorBag, InvalidLiveRequestError, Live, LiveComponentManager, LiveComponentNotFoundError, LiveError, type LiveProps, LiveServiceProvider, Locked, MethodNotCallableError, On, PERSISTENT_MIDDLEWARE, type Paginated, Pagination, type PaginationProps, type PaginationSource, PendingEvent, type PersistentMiddleware, PropertyNotUpdatableError, TestRequestScope, Testable, UPDATE_URI, WithPagination, componentName, live, persistentMiddleware };