@jsenv/navi 0.29.399 → 0.29.400

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.
@@ -71,9 +71,10 @@ to start when unsure which export solves a problem.
71
71
 
72
72
  - `navigation.md` — the position of the user belongs in the URL by default,
73
73
  and that decision is not retrofittable: routes, params, search params,
74
- `<Route>`, tab rows, `navBack`, `RouteTravel`, where a navigation lands, a
75
- `SlideContainer` read from the URL, and what a layer drawn over the screen may
76
- say in its address. Read before writing any routing code.
74
+ `<Route>`, tab rows, `navBack`, `RouteTravel`, where a navigation lands (a
75
+ `#id` landing on the element it names), a `SlideContainer` read from the URL,
76
+ and what a layer drawn over the screen may say in its address. Read before
77
+ writing any routing code, and before scrolling to an element on arrival.
77
78
  - `route_transitions.md` — a transition states a relation the user reads as a
78
79
  map; a pair of routes is animated by `RouteTravel` or by a transition, never
79
80
  both; a test waits for the page arriving, never for its address. Read before
@@ -137,7 +138,9 @@ to start when unsure which export solves a problem.
137
138
  `action`, `command` + `commandFor`). Read before writing any handler whose
138
139
  body only assigns state.
139
140
  - `control_value.md` — who holds a control's value; `--navi-update` for a
140
- button proposing a value; empty keeps the shape of the question; what a
141
+ button proposing a value; a `defaultValue` follows the record it was read
142
+ from (no `key` to remount); drawing what a picker holds (`ui={Component}`,
143
+ not a signal of your own); empty keeps the shape of the question; what a
141
144
  `stateSignal` brings; a time of day typed (`TimeSpin`) or turned
142
145
  (`TimeWheel`). Read before wiring a value with `value` + `uiAction`.
143
146
  - `control_object.md` — one value made of several controls: `ControlGroup`
@@ -259,6 +262,9 @@ to start when unsure which export solves a problem.
259
262
  - **Routing is signal-based**: URL state (including search params) two-way
260
263
  syncs with signals. Don't build parallel state for what a route/query
261
264
  signal already tracks.
265
+ - **A link lands on an element by its fragment**: give the element an `id` and
266
+ link to `#id`. Never a search param plus `scrollIntoView` in an effect (see
267
+ `navigation.md`).
262
268
  - **Actions** model async operations with a lifecycle (idle, running,
263
269
  completed, failed, aborted). Components read an action's state via
264
270
  `useAsyncData`, not by manually tracking loading/error booleans. Reading does
@@ -10,6 +10,8 @@ somewhere else in the app.
10
10
  - [A button that proposes a value is `--navi-update`](#a-button-that-proposes-a-value-is---navi-update)
11
11
  - [`--navi-update:smooth`: the control is seen answering](#--navi-updatesmooth-the-control-is-seen-answering)
12
12
  - [`signal` + `defaultValue`: the answer and where it starts](#signal--defaultvalue-the-answer-and-where-it-starts)
13
+ - [A `defaultValue` follows what it was read from](#a-defaultvalue-follows-what-it-was-read-from)
14
+ - [Drawing what a control holds](#drawing-what-a-control-holds)
13
15
  - [What a signal holds, control by control](#what-a-signal-holds-control-by-control)
14
16
  - [Empty keeps the shape of the question](#empty-keeps-the-shape-of-the-question)
15
17
  - [Which controls take a `signal`](#which-controls-take-a-signal)
@@ -22,12 +24,12 @@ somewhere else in the app.
22
24
 
23
25
  ## The three answers
24
26
 
25
- | what you pass | who holds the value | when the user acts |
26
- | ---------------------- | ------------------- | ------------------------------------------- |
27
- | nothing | the control | it keeps it; `uiAction` tells you |
28
- | `defaultValue` | the control | same — the default is only where it starts |
29
- | `signal` | the signal | the control writes it back, both ways |
30
- | `value` (or `checked`) | you | nothing moves until you hand a new one down |
27
+ | what you pass | who holds the value | when the user acts |
28
+ | ---------------------- | ------------------- | ------------------------------------------------------------------------------------- |
29
+ | nothing | the control | it keeps it; `uiAction` tells you |
30
+ | `defaultValue` | the control | same — the default is where it starts, and a new one is taken while nothing is edited |
31
+ | `signal` | the signal | the control writes it back, both ways |
32
+ | `value` (or `checked`) | you | nothing moves until you hand a new one down |
31
33
 
32
34
  A control given `value` and nothing to listen to it (`uiAction`, `action`, a
33
35
  `signal`, a surrounding form) is read-only, and says so in dev: it is showing
@@ -228,6 +230,50 @@ default rather than leaving it blank — which is what makes "nothing decided
228
230
  here, use the usual answer" expressible at all. Without a `defaultValue`, an
229
231
  emptied signal empties the control.
230
232
 
233
+ ## A `defaultValue` follows what it was read from
234
+
235
+ A field editing a record starts on that record, `defaultValue={user.first_name}`,
236
+ and the record moves after mount: the save comes back normalized (a trailing
237
+ space trimmed), another screen or device changed it, a refresh arrived. The
238
+ control takes the new default **while it holds no edit** — nothing typed, picked
239
+ or toggled since the value the outside last accepted. An edit is never undone:
240
+ the default waits, and lands once that edit is accepted (the action succeeded)
241
+ or taken back (the action failed, the popup was cancelled).
242
+
243
+ This holds for a control, for a group given a `defaultValue` (a selectable list,
244
+ a `ControlGroup`), and for everything inside a picker's popup, which stays
245
+ mounted while closed. So the record goes in as `defaultValue` and nothing
246
+ else: no `key` to remount the popup when it changes. A key is worse than
247
+ unnecessary here, since it also destroys whatever is being typed when the
248
+ answer to ANOTHER field arrives.
249
+
250
+ A picker's cancel follows the same line. It puts back what the picker held at
251
+ open, which undoes the person's edits, but what the outside moved during the
252
+ opening stays moved.
253
+
254
+ ## Drawing what a control holds
255
+
256
+ A picker's trigger usually shows the choice (a label for a code, a summary of
257
+ an object). Read it from the picker rather than keeping a copy:
258
+
259
+ ```jsx
260
+ const VisibilityLabel = ({ value }) => <Text>{labelOf(value)}</Text>;
261
+
262
+ <Picker name="visibility" defaultValue={user.visibility} ui={VisibilityLabel}>
263
+ …
264
+ </Picker>;
265
+ ```
266
+
267
+ `ui={Component}` is handed `value`, `loading` and `interactive`. A `ui` given as
268
+ an element reads the same three with `usePickerState()`.
269
+
270
+ What not to write: `const s = useSignal(defaultValue)` bound as `signal={s}`,
271
+ just to have something to read. `useSignal` reads its argument once, so the
272
+ signal never hears the record move, and a bound signal takes precedence over the
273
+ control's own `defaultValue`. The field is stuck on its first value. A signal is
274
+ for a value the app owns and writes itself (a url param, state shared between
275
+ screens), not a mirror kept for drawing.
276
+
231
277
  ## What a signal holds, control by control
232
278
 
233
279
  The signal holds what the control is ABOUT, which is not always its `value`
@@ -59,6 +59,10 @@ and are referenced from here rather than restated.
59
59
  because the picture alone decided. Only a release at rest, or a slow retreat,
60
60
  is judged by the picture — about a third of a box — because there the
61
61
  position is the only witness left.
62
+ - **A travel caught in flight and thrown on the way it was going asks for the
63
+ one after.** Two swipes the same way are two screens, however close
64
+ together: the second one is not a vote for the screen already arriving. See
65
+ [below](#a-hand-reaching-for-something-still-moving-is-reaching-for-that-thing).
62
66
  - **The bars those verdicts compare against sit well below the speed the
63
67
  fingertip actually has, and they must.** Velocity is averaged over a trailing
64
68
  window (pointer events arrive irregularly, and the last one before a release
@@ -449,6 +453,26 @@ Taking over means the pictures stop where they are and answer the finger again,
449
453
  from where they stand (`slack`) rather than from zero. Only one box is in hand,
450
454
  and walking out of either of its ends is a travel of its own — see below.
451
455
 
456
+ What letting go of it again means is read from the hand, three ways:
457
+
458
+ - **merely touched** — a press that moved less than a gesture's worth: nobody
459
+ asked the travel to stop, it was asked to wait, and it carries on to where it
460
+ was going;
461
+ - **thrown back** — against the way it was going, fast: everything goes back;
462
+ - **thrown on** — the way it was already going, fast, having moved by more than
463
+ a tremor: that is a second push, and it asks for the screen AFTER the one
464
+ arriving (`thrownOn`). Read as a verdict on the box in hand, it only confirms
465
+ what was already arriving — the travel stops at the press and goes on to the
466
+ same place, and the hand feels its second swipe swallowed. So the gesture
467
+ asks for the next box at the release (`onEdge` with `thrown`), with the
468
+ picture still short of the end of the one in hand, and nothing on screen
469
+ moves for it: the travel goes on from where the hand left it, to one screen
470
+ further. It is the wheel's rule (a second push over the tail is a screen, not
471
+ credit towards one) said for a hand.
472
+
473
+ A slow hand going the same way and stopping before it lets go is not thrown:
474
+ there the picture decides, and the screen arriving arrives.
475
+
452
476
  ### A hand that does not stop at the end of a page is asking for the next one
453
477
 
454
478
  One travel brings in one neighbour, but a gesture is not over because a travel
@@ -470,6 +494,14 @@ The two ends cost differently, and it is worth knowing which one is being felt:
470
494
  one instead. The travel turns around where it stands, on the same transition,
471
495
  and there is no gap at all.
472
496
 
497
+ That cost is a `RouteTravel` one: a `SlideContainer` has every slide mounted,
498
+ so handing over is only re-staging — the slide reached becomes the one dragged,
499
+ the one left stays beside it, the slide past it is put on the other side, and
500
+ nothing on screen moves. Which is also why only a `SlideContainer` answers a
501
+ throw (`thrown`, above): a `RouteTravel` would have to jump its pictures the
502
+ rest of the way before handing over, a page seen leaping, so it declines and
503
+ the page caught simply arrives.
504
+
473
505
  What the browser will not turn around with it is everything ELSE the
474
506
  transition carries — see [One gesture that bar cannot
475
507
  follow](#one-gesture-that-bar-cannot-follow).
@@ -16,6 +16,7 @@ linking to them, and turning them into tabs.
16
16
  - [The back arrow: `navBack`](#the-back-arrow-navback)
17
17
  - [Tabs that travel: `RouteTravel`](#tabs-that-travel-routetravel)
18
18
  - [Where a navigation lands: the scroll](#where-a-navigation-lands-the-scroll)
19
+ - [Landing on an element: the fragment](#landing-on-an-element-the-fragment)
19
20
  - [Creating something, then editing it](#creating-something-then-editing-it)
20
21
  - [Tabs that are not routes](#tabs-that-are-not-routes)
21
22
  - [A `SlideContainer` in the URL: a position that is not a place one came from](#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from)
@@ -694,7 +695,7 @@ the two, never both: see [route_transitions.md](./route_transitions.md).
694
695
 
695
696
  ## Where a navigation lands: the scroll
696
697
 
697
- Four cases, and they are not a policy to configure but four different facts:
698
+ Five cases, and they are not a policy to configure but five different facts:
698
699
 
699
700
  - **Going somewhere new** (a `<Link>`, anything that pushes to another path)
700
701
  lands at the top. It is an arrival: the offset one had elsewhere means nothing
@@ -704,6 +705,10 @@ Four cases, and they are not a policy to configure but four different facts:
704
705
  reader is scrolled in — a [layer](#a-layer-over-the-screen-what-its-address-may-say)
705
706
  opening, a [state whose values are places](#a-state-whose-values-are-places-history-push)
706
707
  written — and moves nothing, like a replace.
708
+ - **Going somewhere new, to an element** (`/places/le-set#tournaments`) lands
709
+ on the element whose `id` is the fragment, once it is rendered — even when it
710
+ arrives with the data a second later. See
711
+ [Landing on an element](#landing-on-an-element-the-fragment).
707
712
  - **Going back or forward** (the browser's buttons, `navBack()`,
708
713
  `history.back()`) lands where that page was left. navi keeps the position and
709
714
  puts it back once the page is really rendered, which is what the browser
@@ -732,6 +737,81 @@ in a browser with no Navigation API — is `navBack()`. Where there may be
732
737
  nothing to go back to (a shared link opened cold), decide what the arrow does
733
738
  from the history, not from the link.
734
739
 
740
+ ### Landing on an element: the fragment
741
+
742
+ A link meant to bring one element under the reader's eyes — a notification
743
+ pointing at a section several screens down, a shared link to a comment, a row
744
+ in a list — is an `id` on the element and a `#id` in the link. Nothing else:
745
+
746
+ ```jsx
747
+ <Box id="tournaments">…</Box>
748
+
749
+ // anywhere else
750
+ <Link href={`${PLACE_ROUTE.buildUrl({ slug })}#tournaments`}>…</Link>
751
+ ```
752
+
753
+ `<Link route>` builds no fragment, so the link spells its `href`.
754
+
755
+ The browser alone is not enough in an app. It answers a fragment when the
756
+ document finishes loading and on a fragment navigation within the page — both
757
+ before the data has drawn the element — and a navigation navi routes is not a
758
+ fragment navigation at all. So navi answers every URL carrying a hash itself:
759
+
760
+ - **It waits for the element, and for the page.** The element is reached when
761
+ it exists and shows something (`checkVisibility`): one still loading, or
762
+ rendered inside a closed tab or a folded `<details>`, has not arrived yet.
763
+ navi opens nothing to reach it, and reaches it as soon as it shows — the
764
+ reader opening the `<details>`, switching to the tab — while the wait lasts.
765
+ And it is reached once no route or action is loading: until then, what sits
766
+ above it may still be drawn and push it down — and a page reusing the node of
767
+ the page being left (`/places/a#tournaments` → `/places/b#tournaments`) holds
768
+ it before its own data has come.
769
+ - **It gives up.** Once the document has had no route or action loading for
770
+ `graceAfterIdle` (1 s). A link to an element that is gone lands where a link
771
+ without a fragment would — at the top of a page one arrives at — with no
772
+ mark. A document that never stops working is answered after `maxWait`
773
+ (10 s), with the element as it stands.
774
+ - **A return is not an arrival.** Back, forward and a reload land where the
775
+ page was left, as they do without a fragment; the fragment then only puts
776
+ `:target` on its element — no scroll, no ring, no focus.
777
+ - **It answers once per arrival.** What is watched is the path and the hash: a
778
+ search param written while the reader is there (a filter, a page) does not
779
+ throw them back to the element. Pressing the very link one is on answers
780
+ again.
781
+ - **What the reader gets**: the element against the top edge, instantly (what
782
+ the browser does with a fragment); the keyboard focus on it when it is itself
783
+ focusable — an `id` on the link or the button, not on a box around one; and a
784
+ fading ring, the element carrying `data-url-target` for `markDuration` (2 s).
785
+ The ring is coloured by `--navi-url-target-color` and lives in `@layer navi`:
786
+ an unlayered `[data-url-target] { … }` replaces it, `animation: none` removes
787
+ it. Its length is `markDuration`, which also publishes
788
+ `--navi-url-target-duration` to CSS — setting only the variable makes the ring
789
+ and the attribute disagree.
790
+ - **Adjusting it**: `setUrlTargetOptions()`, once, at the app's start — the
791
+ alignment (`block`), `behavior`, `markDuration`, `graceAfterIdle`, `maxWait`.
792
+
793
+ **`:target` is the lasting "this one"**, and `data-url-target` the moment it
794
+ arrived. navi sets `:target` on the element as it lands on it — the browser
795
+ alone never would after a navigation navi routes, nor for an element that
796
+ arrived after the load — and takes it off once the URL stops naming the
797
+ element, which the browser alone would not either: a search param written on
798
+ the page rebuilds the address without its fragment, a back returns to the page
799
+ without one. It stays on the element navi landed on; a node rendered in its
800
+ place later is another one. In JS, `useUrlTargetId()` is the id the hash
801
+ designates, `""` when there is none, re-rendering when it changes.
802
+
803
+ #### A fragment, or a search param
804
+
805
+ A fragment says where to look; a search param says what to show. When the page
806
+ draws something differently for the value — a filter, a selected row, an open
807
+ panel — it is a [search param](#search-params). When the page is the same and
808
+ only the reader's eyes should go somewhere, it is a fragment, down to a single
809
+ row (`#tournament_42`).
810
+
811
+ A search param plus a `scrollIntoView` in an effect rebuilds this, less well:
812
+ the effect fires at mount, before what sits above the element has laid out,
813
+ and nothing ever tells it to stop waiting.
814
+
735
815
  ### A row of tabs, where a replace IS an arrival
736
816
 
737
817
  The tabs of a `<RouteTravel>` navigate by replacing, and yet each one is another
@@ -320,12 +320,6 @@ So a dialog whose content is seeded once — an uncontrolled field on a
320
320
  first render. Learning it afterwards would mean mounting on the previous subject
321
321
  and correcting it, which is a flicker at best and stale fields at worst.
322
322
 
323
- One opening does not follow that order: a `mount="while-opened"` popup pressed on
324
- its own `anchor` (a picker's trigger, an expandable's UI part) has its content
325
- built by the press, ahead of the click (see
326
- [what the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)). A popup opened ON something is
327
- opened by a command pressed elsewhere, and keeps the order above.
328
-
329
323
  The two other places one could listen are not that moment, and it is worth
330
324
  knowing why:
331
325
 
@@ -1169,9 +1163,13 @@ off it, fields a surrounding form submits, a size measured from outside.
1169
1163
 
1170
1164
  Whatever the value but `"while-opened"`, intent on the anchor — a pointer
1171
1165
  entering it, focus landing in it — builds the content ahead of the click that
1172
- will open it. A `"while-opened"` popup is warmed by a press on its anchor only,
1173
- and a press that ends without opening it — a scroll, a drag, a release
1174
- elsewhere — throws the content away: there is still one content at a time.
1166
+ will open it. A `"while-opened"` popup is warmed only by a press on what opens
1167
+ it — a `--navi-open` button, a picker's trigger when a press opens it, an
1168
+ expandable's UI part — and a press that ends without opening it (a scroll, a
1169
+ drag, a release elsewhere) throws the content away: there is still one content
1170
+ at a time. A popup with an `onOpen` is never warmed, so the content is always
1171
+ built after it (see
1172
+ [`onOpen` runs before the popup has built anything](#onopen-runs-before-the-popup-has-built-anything)).
1175
1173
 
1176
1174
  `"while-opened"` is what an uncontrolled field seeded from a `defaultValue`
1177
1175
  needs: without it, a popup reopened after the underlying value changed still
package/docs/scroll.md CHANGED
@@ -3,9 +3,9 @@
3
3
  Where scrolling happens in a navi app, and how the pieces that live inside a
4
4
  scrolling area (`Box header/body/footer`, `List`, a popup) are told about it.
5
5
 
6
- Where a navigation LANDS is another subject — a push at the top, a back or
7
- forward where the page was left, a row of tabs giving each tab back the offset
8
- it was read at: see
6
+ Where a navigation LANDS is another subject — a push at the top, a push with a
7
+ `#id` on the element it names, a back or forward where the page was left, a row
8
+ of tabs giving each tab back the offset it was read at: see
9
9
  [navigation.md](./navigation.md#where-a-navigation-lands-the-scroll).
10
10
 
11
11
  - [What makes header/body/footer work: the overflow](#what-makes-headerbodyfooter-work-the-overflow)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.399",
3
+ "version": "0.29.400",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "preact": "^11.0.0-rc.2"
35
35
  },
36
36
  "dependencies": {
37
- "@jsenv/dom": "0.17.75",
37
+ "@jsenv/dom": "0.17.76",
38
38
  "@jsenv/humanize": "1.8.5",
39
39
  "@jsenv/validity": "0.4.5"
40
40
  },