@jsenv/navi 0.29.142 → 0.29.144

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.
@@ -11,7 +11,8 @@ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
11
11
  - [A press that opens a popup and acts on it](#a-press-that-opens-a-popup-and-acts-on-it)
12
12
  - [Reacting to open and close](#reacting-to-open-and-close)
13
13
  - [Escape cancels, the other gestures keep](#escape-cancels-the-other-gestures-keep)
14
- - [When `open` is the right answer, and what it costs](#when-open-is-the-right-answer-and-what-it-costs)
14
+ - [When the app holds the open state](#when-the-app-holds-the-open-state)
15
+ - [A popup that loads data](#a-popup-that-loads-data)
15
16
  - [What the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)
16
17
 
17
18
  Where the focus goes once it is open is its own subject — see
@@ -38,13 +39,15 @@ Escape, the backdrop, a close button — all of them ask, and the busy control
38
39
  answers, the same way it would answer anyone else.
39
40
 
40
41
  So the question is never "should this popup be controlled?" but "what triggers
41
- the opening?":
42
+ the opening?" — and, when the answer is the application rather than a gesture,
43
+ where that state lives:
42
44
 
43
- | what opens it | how |
44
- | ---------------------------------- | ---------------------------------------------- |
45
- | a button | `command` / `commandfor` attributes, no `open` |
46
- | a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
47
- | it is a piece of application state | `open` — see the cost below |
45
+ | what opens it | how |
46
+ | ---------------------------------- | ------------------------------------------------------ |
47
+ | a button | `command` / `commandfor` attributes, no `open` |
48
+ | a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
49
+ | a state the app holds | `signal` — [below](#when-the-app-holds-the-open-state) |
50
+ | where the user is | `navState`, or a route `stateSignal` given to `signal` |
48
51
 
49
52
  ## A button opens it: the attributes
50
53
 
@@ -459,24 +462,121 @@ A dialog picker's cancel also goes back in history, so anything written to the
459
462
  url while it was open (a route `stateSignal`, a search param) goes back with it
460
463
  — one more reason Escape and the click outside are not interchangeable.
461
464
 
462
- ## When `open` is the right answer, and what it costs
465
+ ## When the app holds the open state
463
466
 
464
- `open` is for a popup whose being-open is a fact about the application, not
465
- about the user's last gesture: a route that _is_ a dialog, an error the app
466
- decides to show. Use it there, and know what it changes.
467
+ A popup whose being-open is a fact about the application rather than about the
468
+ user's last gesture — a sheet an address can be reloaded into, an error the app
469
+ decides to show — needs somewhere to keep that fact. Three props say it, and
470
+ what separates them is where the state lives, not how hard the popup is driven:
471
+ all three go through the same `requestClose`, so a busy control inside still
472
+ refuses to be left mid-action. What changes is whether anyone hears the refusal.
467
473
 
468
- The busy arbitration still runs — `open={false}` goes through the same
469
- `requestClose` — but nobody hears the refusal. The parent's state says closed,
470
- the popup stayed open, and the two disagree from then on: the effect only reacts
471
- to `open` _changing_, so setting it back to `true` matches the popup's real
472
- state and does nothing, and the popup can no longer be closed by the prop at
473
- all until it closes on its own.
474
+ ### `signal` — the app holds it, both ways
475
+
476
+ ```jsx
477
+ <Dialog signal={groupSheetOpenSignal} />
478
+ ```
479
+
480
+ The popup opens and closes to match the signal, and writes into it whenever it
481
+ opens or closes on its own: Escape, the backdrop, a `--navi-close` command — and
482
+ the close a busy control denied, after which the signal says "open", because
483
+ that is what is true. One binding both drives the popup and knows where it is,
484
+ which is what every navi control does with its value (see
485
+ [state_binding.md](./state_binding.md)); `Dialog`, `Popover` and what is built
486
+ on them (`SidePanel`) all take it. A signal already `true` at mount means the
487
+ popup was already open when the page appeared: no entrance plays.
488
+
489
+ ### `navState` — the history entry holds it
490
+
491
+ ```jsx
492
+ <Dialog id="group-sheet" navState={{ type: "push" }} />
493
+ ```
494
+
495
+ The open state goes into the history entry, so a screen left and come back to
496
+ finds the popup as it was, and so does a reload. `true` stores it under the
497
+ popup's own `id`; a string names the key instead. `{ type: "push" }` makes the
498
+ opening an entry of its own — the back button then closes the popup rather than
499
+ leaving the screen, and the cancel takes back with it whatever was written to
500
+ the url while it was open. A `Picker` needs none of this: its popup's open state
501
+ is nav state by construction.
502
+
503
+ The two meet when the signal IS a route's: a search-param `stateSignal` given to
504
+ `signal` puts the open state in the address itself, where a link can point at
505
+ it.
506
+
507
+ ### `open`, and what it costs
508
+
509
+ `open` is the one-way half of `signal` — the parent re-renders with a boolean
510
+ and the popup follows — and the refusal is what it costs. `open={false}` goes
511
+ through the same `requestClose`, and when a busy control denies it, the parent's
512
+ state says closed while the popup stayed open. The two disagree from then on:
513
+ the effect only reacts to `open` _changing_, so setting it back to `true`
514
+ matches the popup's real state and does nothing, and the popup can no longer be
515
+ closed by the prop at all until it closes on its own. A `signal` has no such
516
+ gap, since the popup writes back what really happened.
474
517
 
475
518
  `defaultOpen` is the middle ground: mount-only, and the popup owns everything
476
519
  afterwards. `defaultOpen="interaction"` means the mount _is_ the opening (the
477
520
  entrance animation plays); any other truthy value means it was already open when
478
521
  the page appeared (no entrance).
479
522
 
523
+ ## A popup that loads data
524
+
525
+ Being open is where the user is, and what a popup draws belongs to the screen
526
+ exactly the way a page's own data does. So a popup that loads something binds
527
+ its open state (above) and asks for its data with a `routeAction` whose params
528
+ are `false` while it is closed:
529
+
530
+ ```js
531
+ const groupSheetOpenSignal = stateSignal(false, {
532
+ id: "group_sheet",
533
+ type: "boolean",
534
+ });
535
+
536
+ export const GROUP_MEMBERS = routeAction(GAME_ROUTE, USER.GET_MANY, () => {
537
+ if (!groupSheetOpenSignal.value) {
538
+ return false;
539
+ }
540
+ return { group: groupSignal.value };
541
+ });
542
+ ```
543
+
544
+ ```jsx
545
+ <Dialog signal={groupSheetOpenSignal}>
546
+ <GroupMembers />
547
+ </Dialog>;
548
+
549
+ const GroupMembers = () => {
550
+ const [members] = useAsyncData(GROUP_MEMBERS); // reads, never runs
551
+ …
552
+ };
553
+ ```
554
+
555
+ What the popup gains is everything a page has:
556
+
557
+ - the request leaves **with the screen**, in parallel with the rest of what the
558
+ address needs, instead of behind the gesture that opens the popup;
559
+ - `useAsyncData` reads it — the wait delegated to `<Loading>`, the failure to
560
+ `<ErrorBoundary>`, `onLoad` for what is seeded once (see
561
+ [actions.md](./actions.md#reading-an-action));
562
+ - the action layer's rerun rules, its dependencies
563
+ ([resource_dependencies.md](./resource_dependencies.md)) and the
564
+ aborted-not-reset treatment a route action gets when the screen is left
565
+ ([offline.md](./offline.md));
566
+ - a reload keeps the open state, so it keeps the request too;
567
+ - and the routes keep saying what an address needs, which is why they declare
568
+ it.
569
+
570
+ The fallback — the popup owning its request, `useAsyncData(action, { run: true })`
571
+ — is for the parameter chosen inside the popup and dying with it: a filter the
572
+ sheet itself holds and nothing else remembers. It cannot do better than the
573
+ gesture that opened the popup, since it starts with the component that draws it;
574
+ a route action left with the screen. Hand-written (`bindParams` in the component,
575
+ `run()` from a `useEffect`, `data === undefined` as the loading flag) it looks
576
+ harmless, and that is the problem: nothing at the call site says which of the two
577
+ it is, so the day the parameter comes from the address, screen data has quietly
578
+ become a popup's private request — asked for late, and asked for alone.
579
+
480
580
  ## What the popup holds while it is closed
481
581
 
482
582
  A closed popup builds nothing: `children` are mounted on the first open, and
@@ -0,0 +1,142 @@
1
+ # Bound, not wired
2
+
3
+ Every piece of state navi shows already has a way in and a way out: the value in
4
+ a control, the area a `SlideContainer` stands on, whether a popup is open, where
5
+ the user is in the application. A callback whose whole body copies that state
6
+ into a variable of your own is a second road beside one that exists — and from
7
+ then on there are two owners for one truth.
8
+
9
+ - [The three shapes](#the-three-shapes)
10
+ - [The callback that only remembers](#the-callback-that-only-remembers)
11
+ - [Why it is not merely shorter](#why-it-is-not-merely-shorter)
12
+ - [What is left for the callback](#what-is-left-for-the-callback)
13
+ - [What binds itself](#what-binds-itself)
14
+ - [When there is no binding](#when-there-is-no-binding)
15
+
16
+ ## The three shapes
17
+
18
+ Almost everything an application has to say about a component is one of three
19
+ things, and each has its own prop:
20
+
21
+ | what the code has to say | how it is said |
22
+ | --------------------------------------------- | ------------------------------------------ |
23
+ | this state is the app's, both ways | `signal` |
24
+ | something HAPPENS when it changes | `action` (`uiAction` for what cannot fail) |
25
+ | a press asks something of a component near it | `command` + `commandFor` |
26
+
27
+ Reaching for a handler that is none of the three — an `onClick`, an
28
+ `onCurrentChange`, a `uiAction` writing a signal — is the sign that one of them
29
+ was missed.
30
+
31
+ ## The callback that only remembers
32
+
33
+ The same mistake, in four places:
34
+
35
+ ```jsx
36
+ // ✗ the app is told where the slides went, and writes it back down
37
+ <SlideContainer
38
+ current={step}
39
+ onCurrentChange={(area) => {
40
+ stepSignal.value = area;
41
+ }}
42
+ />
43
+ // ✓
44
+ <SlideContainer signal={stepSignal} />
45
+
46
+ // ✗ the control is told what it now holds, and the app writes it back down
47
+ <Picker value={side} uiAction={(value) => (sideSignal.value = value)} />
48
+ // ✓
49
+ <Picker signal={sideSignal} />
50
+
51
+ // ✗ a button proposing a value to a control, straight into the signal
52
+ <Button onClick={() => (durationSignal.value = ninetyMinutes)}>1h30</Button>
53
+ // ✓
54
+ <Button command="--navi-update" commandFor="duration" value={ninetyMinutes}>
55
+ 1h30
56
+ </Button>
57
+
58
+ // ✗ the popup is told it opened, and the app writes it back down
59
+ <Dialog
60
+ open={isOpen}
61
+ onOpen={() => (openSignal.value = true)}
62
+ onClose={() => (openSignal.value = false)}
63
+ />
64
+ // ✓
65
+ <Dialog signal={openSignal} />
66
+ ```
67
+
68
+ In each pair the second line is not a tidier spelling of the first: it is the
69
+ binding the component already implements, and the first is a partial
70
+ re-implementation of it.
71
+
72
+ ## Why it is not merely shorter
73
+
74
+ A binding is written by **everything that moves the state**, not only by the
75
+ gesture the caller thought of. A `SlideContainer`'s signal is written by a drag,
76
+ by an arrow key, by a `--navi-right` command sent from a chevron outside the
77
+ box, by the address when the signal is a route's own, by a step refusing to be
78
+ left. A control's
79
+ signal is written by the user typing, by a group placing its children, by a
80
+ picker committing on close, by a `--navi-update` from a shortcut button. A
81
+ hand-written callback catches the cases it was written for and silently misses
82
+ the rest — and the drift only shows on the screen where a second gesture was
83
+ added.
84
+
85
+ It reads in the other direction too, which no callback gives at all: writing the
86
+ signal MOVES the component. The wheels roll, the slides travel, the popup opens.
87
+ That is what replaces the `key` or the `value` prop pushed down to force a
88
+ component into a state.
89
+
90
+ And it goes through navi's interaction gate. A `command` on a read-only,
91
+ disabled or busy control is refused and says why; an `onClick` doing the same
92
+ write is plain DOM, fires on a greyed-out button and rewrites a value nobody was
93
+ allowed to change.
94
+
95
+ ## What is left for the callback
96
+
97
+ Binding the state does not remove the callback — it removes one job from it:
98
+
99
+ - **`onCurrentChange` with a `signal` bound still fires**, and is still worth
100
+ writing: `cause` says whether the slide was pressed, dragged, or asked for by
101
+ the address — which is what tells a history push from a replace — and
102
+ returning `false` REFUSES the travel. Neither of those is state.
103
+ - **`uiAction`** is for what moves with the value without being it: logging,
104
+ something else on screen following along. Not "remember it".
105
+ - **`action`** is for what can fail or take time — it carries the busy state and
106
+ the error, which a `uiAction` does not (see
107
+ [actions.md](./actions.md#action-or-uiaction)).
108
+ - **`onClick`** is for imperative work with nothing to send, nothing to open and
109
+ nothing to propose.
110
+
111
+ ## What binds itself
112
+
113
+ | the state | how it binds | where it is written up |
114
+ | --------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
115
+ | a control's value | `signal` (every control, every group) | [control_value.md](./control_value.md) |
116
+ | the area a `SlideContainer` is on | `signal` (a route's search param signal puts it in the URL) | its JSDoc, [navigation.md](./navigation.md#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from) |
117
+ | a popup being open | `signal`, `open`, `navState` | [popup_open.md](./popup_open.md) |
118
+ | where the user is | route + search-param signals | [navigation.md](./navigation.md) |
119
+ | a value proposed to a control | `command="--navi-update"` | [control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update) |
120
+ | the state of an async run | an `action`, read by `useAsyncData` | [actions.md](./actions.md) |
121
+
122
+ ## When there is no binding
123
+
124
+ Sometimes the state being wired by hand has no prop for it. That is not a
125
+ licence to wire it — it is a finding, and it is one of two:
126
+
127
+ - the state is already held somewhere and the app is keeping a parallel copy of
128
+ it (the URL, the control itself, an action's own state) — the fix is to read
129
+ it where it lives;
130
+ - or navi is genuinely missing a binding, and should gain one.
131
+
132
+ Say which, rather than leaving a hand-wired callback behind: it is the version
133
+ the next screen copies, and by then it is the app's pattern.
134
+
135
+ ## See also
136
+
137
+ - [control_value.md](./control_value.md) — who holds a control's value, and what
138
+ a bound signal holds control by control
139
+ - [actions.md](./actions.md#action-or-uiaction) — `action` or `uiAction`
140
+ - [popup_open.md](./popup_open.md) — who owns the fact that a popup is open
141
+ - [interactions.md](./interactions.md) — a gesture is named, not read by hand:
142
+ the same rule, one layer down
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.142",
3
+ "version": "0.29.144",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@jsenv/dom": "0.17.34",
32
+ "@jsenv/dom": "0.17.35",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.3"
35
35
  },