@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.
- package/dist/jsenv_navi.js +6376 -9151
- package/dist/jsenv_navi.js.map +44 -52
- package/dist/jsenv_navi_side_effects.js +129 -491
- package/dist/jsenv_navi_side_effects.js.map +2 -4
- package/docs/AI_INSTRUCTIONS.md +20 -3
- package/docs/actions.md +44 -4
- package/docs/control_value.md +7 -1
- package/docs/css_architecture.md +219 -1
- package/docs/navigation.md +122 -35
- package/docs/popup_open.md +117 -17
- package/docs/state_binding.md +142 -0
- package/package.json +2 -2
package/docs/popup_open.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
|
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
|
|
465
|
+
## When the app holds the open state
|
|
463
466
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
decides to show
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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.
|
|
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.
|
|
32
|
+
"@jsenv/dom": "0.17.35",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.3"
|
|
35
35
|
},
|