@hozu/cli 0.21.1 → 0.23.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/bin/hozu.js +2 -2
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +10 -7
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/app.d.ts +3 -0
- package/dist/commands/app.d.ts.map +1 -1
- package/dist/commands/app.js +23 -0
- package/dist/commands/app.js.map +1 -1
- package/dist/commands/browse-page.d.ts.map +1 -1
- package/dist/commands/browse-page.js +6 -1
- package/dist/commands/browse-page.js.map +1 -1
- package/dist/commands/browse-tab.d.ts +4 -2
- package/dist/commands/browse-tab.d.ts.map +1 -1
- package/dist/commands/browse-tab.js +36 -5
- package/dist/commands/browse-tab.js.map +1 -1
- package/dist/commands/browse-world.d.ts +3 -0
- package/dist/commands/browse-world.d.ts.map +1 -1
- package/dist/commands/browse-world.js +2 -1
- package/dist/commands/browse-world.js.map +1 -1
- package/dist/commands/browse.d.ts +10 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +107 -13
- package/dist/commands/browse.js.map +1 -1
- package/dist/commands/check.d.ts.map +1 -1
- package/dist/commands/check.js +6 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/gen.d.ts +6 -0
- package/dist/commands/gen.d.ts.map +1 -0
- package/dist/commands/gen.js +56 -0
- package/dist/commands/gen.js.map +1 -0
- package/dist/commands/request.d.ts +6 -1
- package/dist/commands/request.d.ts.map +1 -1
- package/dist/commands/request.js +60 -6
- package/dist/commands/request.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +11 -3
- package/dist/commands/serve.js.map +1 -1
- package/dist/contract.d.ts +50 -3
- package/dist/contract.d.ts.map +1 -1
- package/dist/gen/go.d.ts +10 -0
- package/dist/gen/go.d.ts.map +1 -0
- package/dist/gen/go.js +443 -0
- package/dist/gen/go.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +28 -2
- package/dist/main.js.map +1 -1
- package/dist/migrate/steps.d.ts.map +1 -1
- package/dist/migrate/steps.js +24 -0
- package/dist/migrate/steps.js.map +1 -1
- package/dist/remote.d.ts +15 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +66 -0
- package/dist/remote.js.map +1 -0
- package/package.json +8 -8
- package/schema/browse.schema.json +77 -5
- package/schema/check.schema.json +2 -1
- package/schema/gen.schema.json +78 -0
- package/schema/inspect.schema.json +20 -0
- package/schema/migrate.schema.json +2 -1
- package/schema/render.schema.json +2 -1
- package/schema/request.schema.json +35 -1
- package/skill/SKILL.md +2 -2
- package/skill/example/features/bookmarks/views.ts +1 -2
- package/skill/topics/data.md +77 -0
- package/skill/topics/diagnostics.md +1 -0
- package/skill/topics/http.md +2 -1
- package/skill/topics/machine.md +4 -5
- package/skill/topics/pages.md +1 -1
- package/skill/topics/recipes.md +23 -0
- package/skill/topics/testing.md +10 -4
- package/skill/topics/views.md +15 -3
|
@@ -75,9 +75,8 @@ export const Board = ui.view({
|
|
|
75
75
|
ui.a(
|
|
76
76
|
{
|
|
77
77
|
href: ui.link(home, null, { show: s.value }),
|
|
78
|
-
'aria-current': search.show === s.value,
|
|
79
78
|
class:
|
|
80
|
-
'rounded-full border px-3 py-1 aria-[current=
|
|
79
|
+
'rounded-full border px-3 py-1 aria-[current=page]:bg-indigo-600 aria-[current=page]:text-white',
|
|
81
80
|
},
|
|
82
81
|
[s.label],
|
|
83
82
|
),
|
package/skill/topics/data.md
CHANGED
|
@@ -50,6 +50,7 @@ export default app({ resolvers: resolvers(project, (implement) => [
|
|
|
50
50
|
shows it, also from the browser. `'live'` is for data your own mutations change. A refresh the visitor controls
|
|
51
51
|
(a button, Pause / Resume) is `refresh: () => [tag()]` on a machine transition (`hozu docs machine`).
|
|
52
52
|
- Call a `fn` from views or machines: `ui.each(visible({ items, show: ctx.show }), 'id', …)`.
|
|
53
|
+
- **A database** (pool, migrations, numeric ids): see --more.
|
|
53
54
|
|
|
54
55
|
<!-- more -->
|
|
55
56
|
|
|
@@ -73,3 +74,79 @@ export default app({ resolvers: resolvers(project, (implement) => [
|
|
|
73
74
|
defaults and transforms applied.
|
|
74
75
|
- Every mutation also has `Invalid` = `{ message, fields }` (input failing its schema, or
|
|
75
76
|
`fail('Invalid', { message, fields: { title: 'Taken' } })`); never declare `Invalid` or `Unexpected` yourself.
|
|
77
|
+
|
|
78
|
+
## Resolvers in another language (Go)
|
|
79
|
+
Only when the person asks for it or the service already exists in Go: TypeScript resolvers are the default and need
|
|
80
|
+
no second process. `remote()` sends server effects to a service over HTTP; everything else (access, caching, tags,
|
|
81
|
+
the output schema check) stays in the Hozu server, so a wrong answer is `Unexpected`, never a wrong page.
|
|
82
|
+
```ts
|
|
83
|
+
import { remote, resolvers } from '@hozu/data'
|
|
84
|
+
export default app({
|
|
85
|
+
resolvers: resolvers(project, (implement) => [
|
|
86
|
+
implement(me, (_, { session }) => ({ name: session?.user ?? '' })),
|
|
87
|
+
...remote(
|
|
88
|
+
{
|
|
89
|
+
url: { env: 'NOTES_SERVICE_URL' },
|
|
90
|
+
secret: { env: 'NOTES_SERVICE_SECRET' },
|
|
91
|
+
contract: new URL('./service/hozu/contract.go', import.meta.url),
|
|
92
|
+
},
|
|
93
|
+
[listNotes, addNote],
|
|
94
|
+
),
|
|
95
|
+
]),
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
- The loop: change the declaration in TypeScript → `npx hozu gen` (writes the contract: types, the `Resolvers`
|
|
99
|
+
interface, `Handler`) → implement the interface until `go test ./...` passes → restart the service → `hozu check`.
|
|
100
|
+
A contract older than the declarations is HZ093 naming the changed effects; each effect has its own fingerprint, so
|
|
101
|
+
the service answers 409 only to calls of an effect that changed.
|
|
102
|
+
- In Go: return a declared error as the error value (`hozu.NotesAddNoteDuplicate{Text: t}`), `hozu.Invalid{…}` for
|
|
103
|
+
input problems; `ctx.Session` is nil when signed out; `ctx.SetSession(…)` / `ctx.SignOut()` in mutations. Public
|
|
104
|
+
queries never receive the session. `ctx.File(token)` reads an upload, `ctx.Header` an endpoint's request headers
|
|
105
|
+
(no cookie), `ctx.Preview` preview mode. The secret is required (16+ characters, the same value on both sides,
|
|
106
|
+
HZ093): the service trusts the session it is sent, so serve `hozu.Handler(r, hozu.Options{Secret: …})` on a private
|
|
107
|
+
address (it refuses to start without one).
|
|
108
|
+
- Any other Go error answers 500 with its first line, which reaches `onError` and `Unexpected` as a thrown TypeScript
|
|
109
|
+
error does, with the call's id (`x-hozu-call`) that the service's log line names
|
|
110
|
+
(`hozu: notes.listNotes (call 3fa2c1d0): …`). A service that is not running is `no service answers at <url>`.
|
|
111
|
+
- `.meta({ title: 'Note' })` on a schema makes it one Go type wherever it appears; `z.int()` is `int64`, a plain
|
|
112
|
+
number `float64` (`hozu gen` notes number fields named like ids or counts); a string `z.enum` is a named type with
|
|
113
|
+
one constant per member (`hozu.OrderStatusPending`, named by its title or its field). Only `runs: 'server'` effects
|
|
114
|
+
and JSON endpoints can be remote (HZ093).
|
|
115
|
+
- [`examples/notes-go`](https://github.com/olevatorr/Hozu/tree/main/examples/notes-go) is the reference: the notes
|
|
116
|
+
app with every resolver in Go. Its `service/main.go` is all a service needs besides the resolvers (`go.mod` is
|
|
117
|
+
yours; the contract's folder is the `hozu` package):
|
|
118
|
+
```go
|
|
119
|
+
func main() {
|
|
120
|
+
secret := os.Getenv("NOTES_SERVICE_SECRET") // the app's remote() secret, 16+ characters
|
|
121
|
+
addr := os.Getenv("NOTES_SERVICE_ADDR") // the app's NOTES_SERVICE_URL is http://<addr>/effect
|
|
122
|
+
mux := http.NewServeMux()
|
|
123
|
+
mux.Handle("/effect", hozu.Handler(newResolvers(), hozu.Options{Secret: secret}))
|
|
124
|
+
server := &http.Server{Addr: addr, Handler: mux}
|
|
125
|
+
go func() {
|
|
126
|
+
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
|
127
|
+
log.Fatal(err)
|
|
128
|
+
}
|
|
129
|
+
}()
|
|
130
|
+
stop, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
131
|
+
defer cancel()
|
|
132
|
+
<-stop.Done() // finish the calls in flight, then exit
|
|
133
|
+
ctx, done := context.WithTimeout(context.Background(), 5*time.Second)
|
|
134
|
+
defer done()
|
|
135
|
+
server.Shutdown(ctx)
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## A database
|
|
140
|
+
- One pool per process, made in `app.ts`; close it in `app({ dispose: () => pool.end() })` so `hozu get`, `call` and
|
|
141
|
+
`browse` exit and `hozu serve` stops cleanly. Transactions belong in one mutation resolver (`BEGIN` … `COMMIT`).
|
|
142
|
+
- Migrations and seed data are scripts in `package.json` (`"db:migrate": "node db/migrate.ts"`), idempotent, run
|
|
143
|
+
before `npm start`; the URL and password are server env (`project({ env: { server } })`, `hozu docs env`).
|
|
144
|
+
- Route params and form fields are strings: a numeric id is `z.coerce.number()` in the query or mutation input
|
|
145
|
+
(a query input that fails its schema is a program error, reported to `onError` with the field).
|
|
146
|
+
- Data the whole staff shares but only staff may read is `scope: 'user'` with `access` (it is never cached across
|
|
147
|
+
requests). Share one rule: `const staffOnly = part(({ session }) => session.role !== 'editor')`, then
|
|
148
|
+
`access: { allow: staffOnly }` on each effect.
|
|
149
|
+
- A resolver may answer `fail('Forbidden', { message })` (a row deleted meanwhile, a check `access` cannot make);
|
|
150
|
+
with `access: 'signedIn'` its `session` is never null.
|
|
151
|
+
- Another app writing the same database: give the reading app a signed `endpoint` that `invalidates` the tags, and
|
|
152
|
+
call it after a write (`hozu docs endpoints`).
|
|
@@ -97,3 +97,4 @@ around the rule. `npx hozu docs HZ083` prints one code: its cause, its fix and t
|
|
|
97
97
|
| HZ090 (warning) | `access: 'anyone'` on a `scope: 'user'` query: every visitor, signed in or not, may read it | say who may read it (`'signedIn'`, `{ owner: { row, session } }`), or accept the warning with a reason |
|
|
98
98
|
| HZ091 | a query with `owner` access returned rows the visitor does not own (reported at run time) | read only the visitor's rows in the resolver (filter by the session); production drops the extra rows and logs this |
|
|
99
99
|
| HZ092 | a preview in `project({ previews })` no longer fits the app: data off its query output schema, an error the query does not declare, a route without a page, or a component use that does not build | update the preview to the current schema, error, page or component (previews are for people: they never ship) |
|
|
100
|
+
| HZ093 | a `remote()` resolver that cannot answer: its generated contract is missing or stale, or it lists an effect the browser runs or a non-JSON endpoint | run `hozu gen` and rebuild the service; implement browser-run effects in fetch.ts and non-JSON endpoints in TypeScript |
|
package/skill/topics/http.md
CHANGED
|
@@ -18,4 +18,5 @@ http: {
|
|
|
18
18
|
},
|
|
19
19
|
```
|
|
20
20
|
Server options live in the app module: `app({ resolvers, session?, components?, onError?, csp?, og?, preview? })`.
|
|
21
|
-
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: {
|
|
21
|
+
A strict CSP, `nosniff` and a cross-site POST check are on by default; `csp: { img: ['https://…'] }` adds sources
|
|
22
|
+
(`script style img font connect frame media`).
|
package/skill/topics/machine.md
CHANGED
|
@@ -68,8 +68,7 @@ export const m = machine({
|
|
|
68
68
|
}),
|
|
69
69
|
})
|
|
70
70
|
```
|
|
71
|
-
-
|
|
72
|
-
seeds come from the address).
|
|
71
|
+
- Calm state follows the visitor to a page that shows the same view, and back to the same address.
|
|
73
72
|
- **assign** values are event (`e`), result (`r`) or error fields, context, literals, operators and `fn()` calls.
|
|
74
73
|
- **guard** conditions: a field (`() => ctx.auto`), comparisons, `&&`, `||`, `!`, or a boolean `fn()`.
|
|
75
74
|
- **navigate** sends the browser to `ui.link(route, params, search?)` after the transition. It returns one link: to
|
|
@@ -79,9 +78,9 @@ export const m = machine({
|
|
|
79
78
|
- **Shared transitions:** `machine({ on })` entries are copied into every state that has no `invoke`, is not final,
|
|
80
79
|
and neither handles nor ignores the event itself. Without `target` they stay in the state they fire in; one
|
|
81
80
|
contract covers every copy.
|
|
82
|
-
- **Start from the URL:** a view with a `route` may declare
|
|
83
|
-
|
|
84
|
-
per page may seed a machine (HZ048).
|
|
81
|
+
- **Start from the URL or server data:** a view with a `route` may declare
|
|
82
|
+
`seed: ({ params, search, query }) => ({ q: search.q, email: query(me, {}).email })` (server render, hydration and
|
|
83
|
+
no-JS posts alike; a failed query leaves `initialContext`). One view per page may seed a machine (HZ048).
|
|
85
84
|
- In an app with `site.locales`, machines never hold
|
|
86
85
|
translated text (HZ041): store a code (`ctx.error = 'duplicate'`) and choose the message in the view. The
|
|
87
86
|
scaffold does this in every app.
|
package/skill/topics/pages.md
CHANGED
|
@@ -11,7 +11,7 @@ export const docs = route({ path: '/docs/:path+', params: z.object({ path: z.arr
|
|
|
11
11
|
- **Pages** go in `project({ routes: { home, itemPage }, pages: [...] })` (the whole config: see --more):
|
|
12
12
|
`ui.page(home, { views: [Board], head: { render: () => ({ title: 'Items' }) } })`.
|
|
13
13
|
- **Head from a query:** `head: { query: getItem, input: (params, locale) => ({ id: params.id }), render: (item) => ({ title:
|
|
14
|
-
item.title }), failed: { NotFound: 404 } }`. `failed` maps every declared error of the query (HZ051) to a route
|
|
14
|
+
item.title }), failed: { NotFound: 404 } }`. Both also get `search`. `failed` maps every declared error of the query (HZ051) to a route
|
|
15
15
|
without params (303) or to `403`, `404` or `410`.
|
|
16
16
|
- `head.render` fields: `title`, `description`, `type` (`'website' | 'article'`), `image`, `published`, `noindex`;
|
|
17
17
|
any other is HZ014 (Open Graph, `twitter:card` and the JSON-LD are derived from these).
|
package/skill/topics/recipes.md
CHANGED
|
@@ -81,3 +81,26 @@ Run a fresh scaffold into a scratch app with `--with detail`, and copy the parts
|
|
|
81
81
|
- Following the system needs no code: Tailwind's `dark:` classes (`bg-white dark:bg-slate-900`).
|
|
82
82
|
- A switch the visitor chooses: a client component (`hozu docs components`) puts `dark` on `<html>` and keeps the
|
|
83
83
|
choice in `localStorage`; `app.css` adds `@custom-variant dark (&:where(.dark, .dark *));`.
|
|
84
|
+
|
|
85
|
+
## A shell shared by many pages (a back office)
|
|
86
|
+
Pages are config, so a helper is the layout:
|
|
87
|
+
```ts
|
|
88
|
+
const staffHead = { query: me, render: (m) => ({ title: `${m.name} · Admin` }), failed: { Forbidden: signIn } }
|
|
89
|
+
const staff = (route, View) => ui.page(route, { views: [Sidebar, View], head: staffHead })
|
|
90
|
+
export default project({ /* … */ pages: [staff(orders, OrderList), staff(orderDetail, OrderPage), …] })
|
|
91
|
+
```
|
|
92
|
+
The sidebar's links mark the page shown with `aria-current` by themselves (`aria-[current]:font-bold`).
|
|
93
|
+
|
|
94
|
+
## Screens with different state
|
|
95
|
+
One machine per feature: an order list (filters, selection) and an order page (shipping, refund) are two features,
|
|
96
|
+
`orders` and `order`, sharing declarations through `exports`. Each machine stays small and its contracts few.
|
|
97
|
+
|
|
98
|
+
## A multi-step checkout that also works without JavaScript
|
|
99
|
+
Each step is a state and each step's form posts only its own fields: after a native post the server renders the next
|
|
100
|
+
step, and every form on that page carries the machine's state in a signed hidden field, so the next post continues
|
|
101
|
+
from it (going back to edit a step too). Prefill from the member with
|
|
102
|
+
`seed: ({ query }) => ({ email: query(me, {}).email })`.
|
|
103
|
+
|
|
104
|
+
## A notice after saving
|
|
105
|
+
A `notice` context field set in `done` and cleared by `after: [{ ms: 4000, target: 'idle' }]` on a `saved` state;
|
|
106
|
+
the view shows `ctx.notice !== null && ui.p({ role: 'status' }, [ctx.notice])`. There is no global toast store.
|
package/skill/topics/testing.md
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
`hozu call api.who --input '{"room":"a"}' --header 'Authorization: Bearer t'` (a POST needs `--write`).
|
|
8
8
|
- **Drive the app in a real browser, still without a server:**
|
|
9
9
|
`hozu browse / --session '{"user":"ada"}' --do 'fill New note=Milk' --do 'press Enter' --do 'click Pin in "Milk"'`.
|
|
10
|
-
- Steps: `fill <label>=<value
|
|
11
|
-
`submit "<form>"`, `press <key>`, `wait <ms>`, `goto <path>`, `post <path> a=1&b=2`,
|
|
10
|
+
- Steps: `fill <label>=<value>` (`\n`, `\t` work), `select <label>=<option>`, `check` / `uncheck <label>`,
|
|
11
|
+
`click <name>`, `submit "<form>"`, `press <key>`, `wait <ms>`, `goto <path>`, `post <path> a=1&b=2`,
|
|
12
12
|
`remember <name> from url|<selector> [@attr]` (later steps read `$name`); a target may end with `in "<text>"`
|
|
13
13
|
(for fill and select, before or after `=value`).
|
|
14
14
|
- Labels are what `hozu get <page> --forms` lists; a missing one prints `Did you mean "…"?`. One `--do` may hold
|
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
|
|
30
30
|
<!-- more -->
|
|
31
31
|
|
|
32
|
-
- **
|
|
33
|
-
|
|
32
|
+
- **Server errors:** what the app's `onError` receives (a resolver that threw, an invalid input) is listed under the
|
|
33
|
+
step or the `get` request that caused it, `server error: <message> (<feature.effect>)`; `--json` `serverErrors`.
|
|
34
|
+
- **A calm page:** a step that rebuilds elements unchanged says `N elements rebuilt unchanged (a flash: main > form >
|
|
35
|
+
button[type=submit])`, naming up to five (`--json` `flashes.elements` has all; a control hidden while busy: disable
|
|
36
|
+
it instead). Equal means tag, class, text, `name`, `id`, `href`, `src`, `type` and parent path; a node that moved is
|
|
37
|
+
no flash. Layout that moves without input says `layout shift X`. Both are
|
|
34
38
|
problems to fix; a calm step prints neither. An address changed with `replace` stays `in place`.
|
|
35
39
|
- **`hozu get`** prints the status, redirect, `set-cookie` attributes (`HttpOnly`, `SameSite`), title, alerts and
|
|
36
40
|
visible text.
|
|
@@ -65,6 +69,8 @@
|
|
|
65
69
|
navigation prints `→ <path>` and the new page's lines; a live update on another actor's page prints under the step
|
|
66
70
|
(`bob: + Milk`).
|
|
67
71
|
- `≠ DIFFERS` marks a step where both modes made a request and the resulting text differs: a no-JS/JS parity bug.
|
|
72
|
+
It names the differing words (`≠ DIFFERS (on vs off): "#1307" vs "#1306"`): the two modes write twice to the same
|
|
73
|
+
data, so a new row per mode (an order number, a count) differs without a bug.
|
|
68
74
|
- Errors: uncaught exceptions, `console.error`s, CSP violations and failed requests, each with the page, the
|
|
69
75
|
resource type and the mode. A 400 re-render of an invalid native post is not an error, and a page answering
|
|
70
76
|
401, 403, 404 or 410 is the step's status (`→ /notes/n1 (403)`), so an access check exits 0.
|
package/skill/topics/views.md
CHANGED
|
@@ -23,10 +23,10 @@ export const Board = ui.view({
|
|
|
23
23
|
- **Events:** `on: { click: ui.send(Event, payload) }`; payload fields are literals, data, `ui.dom.value`,
|
|
24
24
|
`ui.dom.form('name')` (submit; `hozu docs forms`). A control that only sets a context field:
|
|
25
25
|
`on: { click: ui.set(ctx.open, !ctx.open) }`, `on: { input: ui.set(ctx.q, ui.dom.value) }` (no event to declare).
|
|
26
|
-
- **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032).
|
|
26
|
+
- **Links:** `ui.a({ href: ui.link(itemPage, { id: item.id }) }, [...])`; never a string path (HZ032). Menus,
|
|
27
|
+
dialogs, plurals: --more.
|
|
27
28
|
- **Data:** `ui.query(listItems, input, { ready: (items) => …, failed: { NotFound: () => …, Unexpected: () => … } })`;
|
|
28
|
-
`failed` lists every declared error plus `Unexpected`.
|
|
29
|
-
parent) and update by key; `pending` shows only before the first answer.
|
|
29
|
+
`failed` lists every declared error plus `Unexpected`.
|
|
30
30
|
- **Shared UI** (buttons, inputs, fields): `ui.use(Button, { variant, props, on }, ['Save'])` of a kit component
|
|
31
31
|
(`hozu docs components`).
|
|
32
32
|
|
|
@@ -63,3 +63,15 @@ export const Board = ui.view({
|
|
|
63
63
|
(`hozu docs components`; `examples/showcase` has a Chart.js one).
|
|
64
64
|
- **Also:** `ui.html(post.html)` (trusted HTML from query data only, HZ030), `ui.asset(new URL('./x.png',
|
|
65
65
|
import.meta.url))`, `ui.window({ on })` / `ui.document({ on })`, `ui.embed(OtherView)`.
|
|
66
|
+
|
|
67
|
+
## Menus, dialogs, counting
|
|
68
|
+
- When a query's input changes, the rows stay (`aria-busy` on the parent) and update by key; `pending` shows only
|
|
69
|
+
before the first answer.
|
|
70
|
+
- A link to the page shown gets `aria-current="page"`, a link to a section above it (`/orders` on `/orders/7`)
|
|
71
|
+
`"true"`; the same path with another search (a next page) gets nothing. An `aria-current` you set wins.
|
|
72
|
+
- `ui.dialog({ open: is(['editing']), on: { close: ui.send(Cancel, {}) } }, [...])` opens as a modal and closes with
|
|
73
|
+
the machine; Escape sends `close`. It needs JavaScript: a dialog that must open without it uses the native
|
|
74
|
+
`commandfor` button (the short form) and closes when the data that shows it changes.
|
|
75
|
+
- `ui.format.plural(n, { one: '# item', other: '# items' })` picks the case for the page's language (`=0` works).
|
|
76
|
+
- `null` and `false` render nothing, also inside a constant list:
|
|
77
|
+
`ui.ul({}, [...kinds.map((k) => (k === 'draft' ? null : ui.li({}, [k])))])`.
|