@jsenv/navi 0.29.82 → 0.29.84

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.
@@ -204,7 +204,9 @@ consistency across the app, not from any single call site.
204
204
  than a param, search params bound to signals, rendering with `<Route>`,
205
205
  tab rows (`Nav` / `Link` / `RouteTravel`), where a navigation lands
206
206
  (scroll: a push arrives at the top, a back or forward lands where the page
207
- was left), and the few cases where tabs are legitimately not URLs. Read it before writing any routing code — the
207
+ was left), a back arrow that stays inside the app (`useCanNavBack()` /
208
+ `navBack({ fallback })`), and the few cases where tabs are legitimately not
209
+ URLs. Read it before writing any routing code — the
208
210
  position of the user belongs in the URL by default, and that decision is
209
211
  not retrofittable.
210
212
  - Source code and demos on GitHub:
@@ -11,6 +11,7 @@ whose value is an object needs in its popup.
11
11
  - [`<Form>`: the shape, plus a send](#form-the-shape-plus-a-send)
12
12
  - [Naming, and what a nameless group does](#naming-and-what-a-nameless-group-does)
13
13
  - [A picker whose value is an object](#a-picker-whose-value-is-an-object)
14
+ - [A settings sheet](#a-settings-sheet)
14
15
  - [`Group` is not `ControlGroup`](#group-is-not-controlgroup)
15
16
 
16
17
  ## `<ControlGroup>`: the shape
@@ -113,6 +114,88 @@ Two things to get right:
113
114
  object lands on one control, which is how `"[object Object]"` ends up in a
114
115
  url.
115
116
 
117
+ ## A settings sheet
118
+
119
+ A popup that is not one choice but a handful of settings — four tabs, a select,
120
+ a field, a button that answers with a place — and ONE answer, which must reach
121
+ the app only when the popup closes. The list behind it must not move while it is
122
+ open, and Escape must leave things exactly as they were found.
123
+
124
+ Nothing new is needed for that: it is the object picker above, with the group in
125
+ its popup. A picker's `action` runs on close and only on close (its `uiAction`
126
+ follows every gesture, which is what the popup shows), so what is inside acts on
127
+ nothing until the user is done.
128
+
129
+ ```jsx
130
+ <Picker
131
+ type="object"
132
+ mode="dialog"
133
+ value={zone}
134
+ ui={<ZoneSummary zone={zone} />}
135
+ action={save}
136
+ >
137
+ <ControlGroup>
138
+ <Nav slideContainer="zone_slides" currentIndicator>
139
+ <Link slide="city">Ville</Link>…
140
+ </Nav>
141
+ <Input type="hidden" name="origin" signal={originSignal} />
142
+ <SlideContainer id="zone_slides" signal={originSignal}>
143
+ <Slide area="city">
144
+ <Input name="city" />
145
+ </Slide>
146
+
147
+ </SlideContainer>
148
+ </ControlGroup>
149
+ </Picker>
150
+ ```
151
+
152
+ ```js
153
+ // what `save` receives, on close and once
154
+ { origin: "city", city: "Antibes", radius: "30", department: "" }
155
+ ```
156
+
157
+ **Which tab is showing is part of the answer.** The same fields mean different
158
+ things depending on the tab they were filled on, so the tab is a key of the
159
+ value like the rest — a sheet handing back `{ city: "Antibes" }` without saying
160
+ which tab it was left on has not said what was chosen. A tab bar is a navigation
161
+ (`<Nav slideContainer>` + `<Link slide>`), so nothing in it is a field: the
162
+ current area reaches the value through an `<Input type="hidden">` bound to the
163
+ same signal as the container. That is what a hidden field is for — a
164
+ piece of the answer with no control to be read from, here because the tabs are
165
+ places rather than choices. A tab bar made of radios needs none: it is a field
166
+ already, under its own name.
167
+
168
+ That signal is written in both directions, which is what makes the sheet reopen
169
+ where it was left: a value handed DOWN to a control writes it too — on open, and
170
+ when Escape puts back what the picker held — so the tabs go back to the answer
171
+ rather than staying on the one that was being tried.
172
+
173
+ **Every field answers, including the ones the current tab does not use.** A
174
+ field on another tab stays mounted, keeps what was typed in it, and comes back
175
+ in the object. Which of them count is read from `origin` by whoever receives the
176
+ value — the group has no opinion about it, and does not need one. Deriving
177
+ something narrower (a stored string, an id) is that reader's business too:
178
+ `action` receives the object and stores whatever it wants, `value` hands the
179
+ object back.
180
+
181
+ A group CAN be worth a single value of its own — `aggregateChildStates` and
182
+ `distributeChildUIState`, see the top of this file — and a sheet is precisely
183
+ where that is the wrong reach: it throws away the state that makes the answer
184
+ readable, starting with the tab.
185
+
186
+ **A button inside is not a field.** The one asking for a position acts on the
187
+ press, like anywhere else; what it answers goes into a control that IS a field
188
+ (a read-only one showing the place it found), and the group picks it up from
189
+ there.
190
+
191
+ The façade stays on what is saved by reading the app's own state
192
+ (`value={zone}` + a `ui` rendered from `zone`), while the picker holds the
193
+ draft: the summary behind the open dialog then shows the answer, not the
194
+ attempt.
195
+
196
+ Seen working — the tabs, the geolocation button, the cancel — in
197
+ `control/demos/picker/9_picker_settings_sheet_demo.html`.
198
+
116
199
  ## `Group` is not `ControlGroup`
117
200
 
118
201
  Two names, two subjects, no relation:
@@ -46,6 +46,15 @@ const minutesSignal = useSignal(0);
46
46
  minutesSignal.value = 30;
47
47
  ```
48
48
 
49
+ "Every change" includes the ones the control did not decide: a group placing
50
+ its children, a picker filling its popup at open, and the same picker putting
51
+ back what it held when Escape cancels. The signal mirrors the control, so it
52
+ always says where the control actually is — which is what lets a settings sheet
53
+ reopen on the tab it was left on (see
54
+ [control_object.md](./control_object.md#a-settings-sheet)). What it does NOT
55
+ mirror is a picker's popup being played with: a picker's own signal is written
56
+ when the picker commits, on close.
57
+
49
58
  Both halves are worth knowing about, because each replaces a habit:
50
59
 
51
60
  - the write-back replaces `uiAction={(v) => (mySignal.value = v)}`;
@@ -115,6 +124,10 @@ All of them: `Input` (every type), `Picker`, `Select`, `Wheel`, `Spin`,
115
124
  that is a navi control goes through the same state controller, and the same
116
125
  `signal` prop.
117
126
 
127
+ `SlideContainer` takes one too, though it is layout rather than a control: the
128
+ area it shows is a piece of state like any other, and binding it is what lets
129
+ something else read where the slides are — or move them by writing it.
130
+
118
131
  Inside a `List selectable` you can bind the list, or give each `List.Item` its
119
132
  own `selected` — but not expect the two to arbitrate. An item that declares
120
133
  `selected` is answering for itself, and the list's signal does not reposition
@@ -243,6 +243,63 @@ gives it a `view-transition-name` of its own: the browser then moves it on the
243
243
  same clock as any transition playing — including a `RouteTravel` swipe, with no
244
244
  wiring between the two.
245
245
 
246
+ A row of tabs is a lateral move: the neighbour is one finger away, and going
247
+ there is not going one step deeper. `replace` says exactly that — the
248
+ destination takes the place of the current history entry instead of stacking
249
+ onto it, so the whole row weighs one entry and the back button (the arrow at the
250
+ top, the phone's own) leaves by where the reader came in:
251
+
252
+ ```jsx
253
+ <Link route={CANDIDATE_GAMES_ROUTE} variant="tab" replace>
254
+ Candidatures
255
+ </Link>
256
+ ```
257
+
258
+ The link stays a link — an address, a middle click, the keyboard, `aria-current`
259
+ — only the way there changes. It is the same word as `navTo(url, { replace:
260
+ true })` and `route.redirectTo()`.
261
+
262
+ A replaced entry inherits the state of the one it takes the place of (so does
263
+ `route.redirectTo()`): **an entry's state does not say how it arrived**. Only
264
+ the navigation being applied says that, and navi is the one applying it — see
265
+ the back arrow below, which is what that fact is usually needed for.
266
+
267
+ ## The back arrow: `navBack`
268
+
269
+ An arrow drawn inside the app promises the screen the reader came from — never
270
+ the page they were on before the app. Both cases are real for the same url: one
271
+ descends into a profile from a list, or one opens it cold from a shared link, a
272
+ bookmark, a notification. `history.back()` answers the first and, on the second,
273
+ gives back the conversation the link came from.
274
+
275
+ `window.history.length` cannot tell them apart — it counts the whole tab — and
276
+ neither can an entry's state, for the reason just above. What answers is a count
277
+ of how many entries of THIS document stand underneath, kept as the navigations
278
+ are applied and written into each entry so it survives a reload mid-stack. navi
279
+ keeps it: an app that kept its own would have to be told about every single
280
+ `replace` it performs, and the one it forgets shows up only on a cold-opened
281
+ screen after a precise gesture.
282
+
283
+ ```jsx
284
+ const BackButton = () => {
285
+ const canNavBack = useCanNavBack();
286
+ ...
287
+ };
288
+ ```
289
+
290
+ `useCanNavBack()` (or `canNavBackSignal` outside a component) is reactive: the
291
+ arrow appears and disappears as the stack moves, it is not decided at mount.
292
+
293
+ ```js
294
+ navBack({ fallback: USER_ME_ROUTE.buildUrl() });
295
+ ```
296
+
297
+ The `fallback` takes the place of the current entry rather than stacking on it:
298
+ pushed, it would put the screen just left one press ahead, and the phone's own
299
+ back button would walk straight back into it — a loop with no way out of the
300
+ app. Without a `fallback`, a `navBack()` with nothing of ours behind does
301
+ nothing.
302
+
246
303
  ## Tabs that travel: `RouteTravel`
247
304
 
248
305
  `<RouteTravel>` wraps the `<Route>` tree of a row of tabs and makes every change
@@ -276,9 +333,10 @@ place, not a step along the row, and it plays no movement. `axis="y"` lays the
276
333
  pages out as a column instead: forward is then the page rising and the next one
277
334
  coming up from below.
278
335
 
279
- A swipe **replaces** the current history entry (a gesture browses; a tab pressed
280
- aims at a place and pushes, which its `<Link>` already does). `onTravel` decides
281
- otherwise.
336
+ A swipe **replaces** the current history entry, and a tab pressed says the same
337
+ thing when its link asks for it (`<Link replace>`, see above) the two gestures
338
+ towards the same neighbour must not write two different histories. `onTravel`
339
+ decides otherwise.
282
340
 
283
341
  Several `RouteTravel` boxes may live on one page — a section of the path and a
284
342
  search param of the root route are two rows of tabs, both live — and only the one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.82",
3
+ "version": "0.29.84",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {