@jsenv/navi 0.29.155 → 0.29.157

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.
@@ -65,8 +65,10 @@ consistency across the app, not from any single call site.
65
65
  routing, actions, layout (`Box`), typography, icons, forms/validation,
66
66
  `Table`, dialogs, popovers.
67
67
  - `docs/resource.md` — REST state: `resource()`, relationships, callback return
68
- contracts, autorerun rules. Companion files: `docs/actions.md`,
69
- `docs/resource_with_params.md`, `docs/resource_dependencies.md`.
68
+ contracts, autorerun rules, and the second question a screen asks over one
69
+ collection (searching it is that collection's own `GET_MANY`). Companion
70
+ files: `docs/actions.md`, `docs/resource_with_params.md`,
71
+ `docs/resource_dependencies.md`.
70
72
  - `docs/list_refresh.md` — what a write sends back to the network and what stays
71
73
  on screen meanwhile: stale data returned by `useAsyncData({ loading: true })`,
72
74
  what updates from a response without any request, `rerunOn` and its defaults,
@@ -342,7 +344,10 @@ consistency across the app, not from any single call site.
342
344
  sub-resource (`/games/:id/candidates`) as an `op`/`type` discriminator inside
343
345
  one verb's callback. Read `docs/resource.md` before writing a resource — the
344
346
  callback return contracts differ per relationship method and are not
345
- guessable.
347
+ guessable. Searching a collection is that collection's `GET_MANY` with one
348
+ more param, never a resource or an action of its own: two `GET_MANY` on one
349
+ resource do not compete (each holds its own ids, the store is shared), and
350
+ rows a search returns are then the same objects as everywhere else.
346
351
  - **`Box`** is the layout primitive (Flexbox wrapper: `flex`, `flex="y"` for
347
352
  column, `grid`, `alignX`/`alignY`). Prefer it over raw CSS for layout inside
348
353
  Navi apps.
package/docs/actions.md CHANGED
@@ -63,6 +63,28 @@ const userAction = getUser.bindParams({ id: userIdSignal });
63
63
  // a new params value reruns it
64
64
  ```
65
65
 
66
+ `{ debounce }` puts a delay between the signal and the instance, for params that
67
+ move faster than a request should — a search box, a wheel, a slider:
68
+
69
+ ```js
70
+ const searchAction = USER.GET_MANY.bindParams(questionSignal, {
71
+ debounce: 300,
72
+ });
73
+ const [found, searching] = useAsyncData(searchAction, {
74
+ run: true,
75
+ loading: true,
76
+ });
77
+ ```
78
+
79
+ There is no effect here and nothing to own: the delay lives in the binding, and
80
+ two call sites passing the same signal and the same delay get the same instance,
81
+ so the four components of one screen reading the same question ask it once. That
82
+ is the shape to reach for whenever the screen owns its params signal.
83
+ `actionRunEffect` is the other one, and what it adds is the run: use it when the request must go out whether or not something
84
+ is drawing it. During the delay the instance is still the previous one, holding
85
+ the previous answer — `loading` is what says a newer one is coming, don't
86
+ compare params by hand to find out.
87
+
66
88
  ## Running: `run`, `rerun`, `prerun`, `reset`
67
89
 
68
90
  | Method | Does |
package/docs/resource.md CHANGED
@@ -119,6 +119,64 @@ screen until the rows are asked for again. Give the screen its own way to ask
119
119
  It reads a collection, so it lives on the resource itself (or on a
120
120
  `withParams()` of it), not on a relation.
121
121
 
122
+ ## Searching the same collection
123
+
124
+ A screen showing a collection asks a second question about it soon enough: the
125
+ list draws a page, a search box goes looking in the whole base for what the page
126
+ does not hold. Same entity, same ids — so it is the same `GET_MANY`, with one
127
+ more param:
128
+
129
+ ```js
130
+ const USER = resource("user", {
131
+ GET_MANY: ({ scope, search }, { signal }) =>
132
+ fetchJson(`/users?${new URLSearchParams({ scope, search })}`, { signal }),
133
+ });
134
+
135
+ const pageAction = USER.GET_MANY.bindParams({ scope: "seatable" });
136
+ const searchAction = USER.GET_MANY.bindParams({
137
+ scope: "seatable",
138
+ search: searchSignal,
139
+ });
140
+ ```
141
+
142
+ Not a resource of its own, and not a `createAction` sitting beside the resource.
143
+ Those answer with **detached copies**: the user a search found is a different
144
+ object from the same user everywhere else on screen — it does not follow an
145
+ update, and a mutation writes to the one it is not. The resource's own
146
+ `GET_MANY` upserts what comes back like any other read, so one object per user
147
+ answers both questions.
148
+
149
+ **Two `GET_MANY` on one resource do not compete.** Each bound instance keeps its
150
+ own array of ids and resolves it against the shared store, so answering the
151
+ search leaves the page's list as it was — clear the search box and every row it
152
+ had is still there. ["A `*_MANY` callback replaces the collection
153
+ wholesale"](#when-the-backend-answers-a-sub-route-with-the-whole-parent), below,
154
+ is about the rows the store keeps for one owner — not about what some other
155
+ action reading the same resource is showing.
156
+
157
+ It is `bindParams`, not `withParams`: a `withParams()` carves an autorerun scope
158
+ (a POST at the root does not reach into it), and a word someone typed is not a
159
+ scope — the search must refresh after a write like the list does
160
+ ([resource_with_params.md](./resource_with_params.md)).
161
+
162
+ The lifecycle a search wants comes with being an action: `loading` while the
163
+ word is in flight, the run before it aborted through its `signal`, typing
164
+ debounced by the effect driving it —
165
+ `actionRunEffect(searchAction, () => …, { debounce: 300 })` where the app owns
166
+ the request, a `stateSignal` when the word is in the address. A word already
167
+ asked is answered by the instance that asked it, with no second request
168
+ ([actions.md](./actions.md#reading-an-action)).
169
+
170
+ What stays the app's is what to KEEP of a search once the word changes — the
171
+ rows someone picked out of it and expects to still see under the next word.
172
+ Keep their **ids**, not the objects: an object is a snapshot, and it stops
173
+ following its row the moment anything writes to it. `RESOURCE.useAllByIds(ids)`
174
+ reads those rows as they are now — a kept id turned back into something that
175
+ stays fresh, and the ids the store no longer holds simply drop out.
176
+ `RESOURCE.useById(id)` is the singular, `RESOURCE.useArray()` the whole store;
177
+ all three subscribe the render that calls them, and none is a hook (a `.map()`
178
+ over them is fine).
179
+
122
180
  ## Relations: pick one of the four methods
123
181
 
124
182
  A backend sub-route (`/games/:id/candidates`, `/games/:id/candidates/:userId/seen`)
@@ -282,7 +340,9 @@ This is the common REST shape, and it is the reason `op` dispatch feels
282
340
  attractive: a full-parent response absorbs into a parent `PATCH` with no
283
341
  thinking. Model the relation anyway and absorb the response in the callback —
284
342
  `resource()` gives a plural callback for exactly this: **any `*_MANY` callback
285
- replaces the collection wholesale**.
343
+ replaces the collection wholesale** — the rows the store keeps for that owner,
344
+ not what some other action reading the same resource is showing (see
345
+ [Searching the same collection](#searching-the-same-collection)).
286
346
 
287
347
  ```js
288
348
  const GAME_CANDIDATES = GAME.scopedMany("candidates", {
@@ -60,7 +60,11 @@ Two recommendations that matter more than the individual choices:
60
60
  - **Keep reciprocity.** The way back being the same movement reversed is what
61
61
  makes the map hold together; it is the default, and breaking it (a relation
62
62
  written for the exact way travelled wins over being the reverse of another)
63
- should answer a real asymmetry in the app, not a styling whim.
63
+ should answer a real asymmetry in the app, not a styling whim. Write the way
64
+ back only to say something DIFFERENT — another movement, or `"none"`. Written
65
+ with the same one, both crossings find their own relation and both play
66
+ forward, and the pair can never say "back" again, the back button included;
67
+ navi warns when it sees that pair defined.
64
68
 
65
69
  ## A default transition — when
66
70
 
@@ -103,7 +107,7 @@ and no other**:
103
107
  <Link
104
108
  route={GAME_ROUTE}
105
109
  routeParams={{ id }}
106
- transition={{ direction: "back" }}
110
+ routeTransition={{ direction: "back" }}
107
111
  >
108
112
  {badge.gameName}
109
113
  </Link>
package/docs/testid.md CHANGED
@@ -78,6 +78,45 @@ than to the box around it (`id`, `name`, `type`, `value`, `tabIndex`,
78
78
  so a `data-test-id` or a `data-qa` of your own would name the wrapper, which is
79
79
  the second reason to keep the standard spelling.
80
80
 
81
+ `aria-label` and `aria-labelledby` take the same road, and for the reason the
82
+ section above gives: the name has to be where the role is. The root box has no
83
+ role — it is a wrapper — so a name left on it would be announced by nothing and
84
+ found by nothing, while the host is what the user focuses and what
85
+ `getByRole(role, { name })` and `getByLabel()` read.
86
+
87
+ ```jsx
88
+ <Picker variant="icon" aria-label="Aide" ui={<Icon>…</Icon>} />
89
+ ```
90
+
91
+ ```html
92
+ <div class="navi_picker" navi-control="picker" aria-expanded="false">
93
+ <input navi-control-host="picker" aria-label="Aide" readonly />
94
+ </div>
95
+ ```
96
+
97
+ Which role that host answers to is what the control really is, not what it is
98
+ drawn like: a button's host is a `<button>`, and a picker's host is an
99
+ `<input>` — a `textbox`, because it holds text the user chose.
100
+
101
+ A picker that picks nothing is the exception, and answers to `button`: its
102
+ popup is a question (`type="confirm"`) or something to read
103
+ (`mode="callout"`), so it is a door, and a door is pressed. Its name is the
104
+ label it draws — the `ui` you gave it, or its placeholder — unless you name it
105
+ yourself:
106
+
107
+ ```jsx
108
+ <Picker type="confirm" ui="Delete" action={deleteFile} />
109
+ <Picker mode="callout" aria-label="Aide" ui={<Icon>…</Icon>}>…</Picker>
110
+ ```
111
+
112
+ ```js
113
+ await page.getByRole("button", { name: "Delete" }).click();
114
+ await page.getByRole("button", { name: "Aide" }).click();
115
+ ```
116
+
117
+ A trigger drawn as an icon draws no words, so `aria-label` is the only thing
118
+ that can name it — for the test and for a screen reader alike.
119
+
81
120
  When the wrapper IS what the test wants — a whole field with its label and its
82
121
  error message, a section, a row — put the testid on the surrounding component
83
122
  (`<Field data-testid="email-field">`, `<Box data-testid="cart-row">`); anything
@@ -97,7 +136,32 @@ Most tests do not need it — a testid on what the popup holds (`<Box
97
136
  data-testid="place-pick">` among the children) names the screen, not the frame,
98
137
  and is the better name for a test that reads or clicks the content. Reach for
99
138
  `popupTestId` when the frame IS what the test looks at: a screenshot of the
100
- popup's surface, its position, its size, its backdrop.
139
+ popup's surface, its position, its size, its backdrop. It names the popup in
140
+ every mode, including `mode="callout"`, where the frame is a callout rather
141
+ than a sheet.
142
+
143
+ ## A callout is a role first
144
+
145
+ A callout — a control's `readOnlyMessage` or failing constraint, a
146
+ `mode="callout"` picker, a direct `openCallout()` — is drawn by navi from end to
147
+ end: the application supplies the sentence and nothing else, so there is often
148
+ nothing of its own to name. It carries a role for that, and the role is the
149
+ contract: `alert` when it says something went wrong (`warning`, `error`),
150
+ `status` otherwise (`info`, `success`, or no status at all).
151
+
152
+ ```js
153
+ await page.getByRole("button", { name: "Publier" }).click();
154
+ await expect(page.getByRole("alert")).toHaveText("Il manque le lieu");
155
+ ```
156
+
157
+ That is not a testing convenience: a message appearing without the user asking
158
+ for it is what a live region is for, and the role is what makes a screen reader
159
+ read it out. A test targeting it asserts that.
160
+
161
+ When the role is not enough — two callouts up at once, a screenshot of one
162
+ particular surface — `openCallout` takes a `testId`, and a picker's
163
+ `popupTestId` covers the one it opens. Waiting on `.navi_callout` is the thing
164
+ to replace: it is a navi class like any other.
101
165
 
102
166
  `<ControlSwap.Side>` answers the same situation the other way: the cap at the
103
167
  end of the row is drawn by navi, and every prop the side does not use for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.155",
3
+ "version": "0.29.157",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {