@jsenv/navi 0.29.170 → 0.29.172
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 +617 -156
- package/dist/jsenv_navi.js.map +21 -19
- package/docs/AI_INSTRUCTIONS.md +25 -2
- package/docs/actions.md +34 -0
- package/docs/interactions.md +6 -0
- package/docs/list_action.md +121 -0
- package/docs/popup_open.md +70 -6
- package/package.json +1 -1
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -69,6 +69,13 @@ consistency across the app, not from any single call site.
|
|
|
69
69
|
collection (searching it is that collection's own `GET_MANY`). Companion
|
|
70
70
|
files: `docs/actions.md`, `docs/resource_with_params.md`,
|
|
71
71
|
`docs/resource_dependencies.md`.
|
|
72
|
+
- `docs/actions.md` — an action and its params: when to call the action versus
|
|
73
|
+
bind an instance, what a failing run does, the four ways to run one. Read the
|
|
74
|
+
debounce part before putting a delay on params — a debounced binding follows
|
|
75
|
+
where its signal SETTLES and never a value it only passed through, settling is
|
|
76
|
+
a comparison and not a countdown, and the in-between states a gesture writes
|
|
77
|
+
are real params that `batch()` or a better-shaped question must handle, not
|
|
78
|
+
the delay.
|
|
72
79
|
- `docs/data_states.md` — what a screen draws while its data is loading,
|
|
73
80
|
missing, stale or failed: the three questions `data`, `loading` and `error`
|
|
74
81
|
answer separately, the four combinations of the first two and what each one
|
|
@@ -90,6 +97,19 @@ consistency across the app, not from any single call site.
|
|
|
90
97
|
Read it before adding verbs to `rerunOn`, hiding a list on `loading`,
|
|
91
98
|
remounting a list with a `key` to refresh it, or reporting that a page
|
|
92
99
|
stopped refreshing since a transition was defined on its pair.
|
|
100
|
+
- `docs/list_action.md` — a list that acts on what it holds, and the one thing
|
|
101
|
+
that decides how it waits: where the `action` lives. On the `<List>` it is the
|
|
102
|
+
selection being sent and the whole list waits; on a row's own button it is a
|
|
103
|
+
call about that row and the other rows stay live. Also who draws the wait and
|
|
104
|
+
answers a press when a row carries a control (the control, always — the row is
|
|
105
|
+
the fallback for a row that holds nothing but text), why such a row is
|
|
106
|
+
`readOnly` and never `loading`, and the one that works by luck: a list held
|
|
107
|
+
read-only refuses the `--navi-select` its own button fires on success, and
|
|
108
|
+
whether it lands comes down to a tick — clearing the flag in a `finally` saves
|
|
109
|
+
it, clearing it a moment later loses the selection silently. Read it before
|
|
110
|
+
putting `loading` on a row whose button is working, before
|
|
111
|
+
`<List readOnly={pending}>`, and before writing a second spinner next to the
|
|
112
|
+
one a control already draws.
|
|
93
113
|
- `docs/error_handling.md` — the two kinds of error and how navi keeps them
|
|
94
114
|
apart: where an error is shown depending on where it came from (a control's
|
|
95
115
|
action shows it on what was clicked, a route action replaces the page, a
|
|
@@ -160,8 +180,11 @@ consistency across the app, not from any single call site.
|
|
|
160
180
|
RESORT for a decision that is not a press — with the rule that decides most
|
|
161
181
|
reviews: the `event` it takes is the gesture you were handed, threaded down to
|
|
162
182
|
the call, never a `new CustomEvent()` built on the spot (that is for a timer,
|
|
163
|
-
an action settling, a signal changing, and nothing else).
|
|
164
|
-
|
|
183
|
+
an action settling, a signal changing, and nothing else). Also the close
|
|
184
|
+
cross: `<Dialog.Close />` exists, it is the way out and so the one affordance
|
|
185
|
+
the surrounding read-only/disabled does not reach, and it asks rather than
|
|
186
|
+
forces. Read it before calling `triggerNaviCommand`, before passing `open` to
|
|
187
|
+
a popup, before writing a close button by hand, and before an
|
|
165
188
|
`onOpen`/`onClose` writing a signal.
|
|
166
189
|
- `docs/autofocus.md` — who gets the keyboard when a popup opens or a slide
|
|
167
190
|
arrives, and whether a ring shows on it: the ladder navi walks, what
|
package/docs/actions.md
CHANGED
|
@@ -102,6 +102,40 @@ is drawing it. During the delay the instance is still the previous one, holding
|
|
|
102
102
|
the previous answer — `loading` is what says a newer one is coming, don't
|
|
103
103
|
compare params by hand to find out.
|
|
104
104
|
|
|
105
|
+
### A debounced signal asks where it settles, not where it passed
|
|
106
|
+
|
|
107
|
+
The delay publishes where the signal **settles**, never a value it only passed
|
|
108
|
+
through on the way. A question that goes `A → B → A` inside the delay is the
|
|
109
|
+
question `A`: no request goes out for `B`, and `loading` never went up. Only a
|
|
110
|
+
signal that is somewhere new once the delay elapses moves the binding.
|
|
111
|
+
|
|
112
|
+
That is load-bearing rather than a nicety, because settling is a **comparison,
|
|
113
|
+
not a countdown**: `paramsSettlingSignal` is true while the signal and the
|
|
114
|
+
binding differ and false when they agree. Move the binding to a value the signal
|
|
115
|
+
is not on and nothing is left that could make them agree again — the screen is
|
|
116
|
+
told a newer answer is coming, with no delay still running to bring one, and
|
|
117
|
+
only another write to the source frees it. So a value the source never settled
|
|
118
|
+
on does not cost a stale request, it costs a screen stuck on "loading".
|
|
119
|
+
|
|
120
|
+
### The in-between states of a gesture are real params
|
|
121
|
+
|
|
122
|
+
The delay decides _when_ a question goes out, never _which_ one is coherent: it
|
|
123
|
+
sees every write, including the ones a gesture only passes through. Moving an
|
|
124
|
+
item from one slot to another by filling the new slot and then emptying the old
|
|
125
|
+
one is two writes, and between them the item is in both — a state nobody meant
|
|
126
|
+
to ask about, and a question the delay would happily send if it landed there.
|
|
127
|
+
|
|
128
|
+
Make the intermediate not matter, rather than counting on the delay to hide it.
|
|
129
|
+
Two ways, both worth having on their own:
|
|
130
|
+
|
|
131
|
+
- **`batch()` the writes that belong to one gesture**, so the intermediate state
|
|
132
|
+
never exists at all — not for the delay, not for a render, not for a
|
|
133
|
+
validation rule reading the same signals.
|
|
134
|
+
- **Shape the question so states that are equivalent compare equal.** Params are
|
|
135
|
+
compared deeply, so a list whose order carries nothing asks a new question
|
|
136
|
+
every time it is rearranged; sent deduplicated and sorted, rearranging stops
|
|
137
|
+
changing the question — better than asking it late.
|
|
138
|
+
|
|
105
139
|
## Running: `run`, `rerun`, `prerun`, `reset`
|
|
106
140
|
|
|
107
141
|
| Method | Does |
|
package/docs/interactions.md
CHANGED
|
@@ -590,6 +590,12 @@ about a value the affordance does not write, so answering "read-only" to a
|
|
|
590
590
|
gesture that was never going to write anything says nothing true. Use it only
|
|
591
591
|
when that is really the case.
|
|
592
592
|
|
|
593
|
+
navi's own `<Dialog.Close />` is the second canonical case: a read-only picker
|
|
594
|
+
still opens, and leaving what it opened writes nothing to it — so the cross is
|
|
595
|
+
`"ignore"` and a hand-written one must say the same, or it refuses the press
|
|
596
|
+
aimed at the way out (see
|
|
597
|
+
[popup_open.md](./popup_open.md#the-close-cross)).
|
|
598
|
+
|
|
593
599
|
Busy is not on the list because busy does not block: it is the read-only a
|
|
594
600
|
running action sets on its way that does.
|
|
595
601
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# A list that acts on what it holds
|
|
2
|
+
|
|
3
|
+
Two different screens are written with the same components: a list that answers
|
|
4
|
+
one question (which seat, which side, which city) and a list that is a place to
|
|
5
|
+
act, row by row (invite this person, archive that row). They look alike and they
|
|
6
|
+
wait very differently, and what decides it is one thing:
|
|
7
|
+
|
|
8
|
+
**Where the action lives decides who waits.**
|
|
9
|
+
|
|
10
|
+
- [On the list: one answer, and the list waits](#on-the-list-one-answer-and-the-list-waits)
|
|
11
|
+
- [On a row's control: that row waits alone](#on-a-rows-control-that-row-waits-alone)
|
|
12
|
+
- [Who draws the wait, and who answers a press](#who-draws-the-wait-and-who-answers-a-press)
|
|
13
|
+
- [Read-only, not loading](#read-only-not-loading)
|
|
14
|
+
- [Hold the rows, not the list](#hold-the-rows-not-the-list)
|
|
15
|
+
|
|
16
|
+
Live examples: the **Button** section of
|
|
17
|
+
`src/control/demos/13_list_selectable_demo.html` shows both, side by side, over
|
|
18
|
+
a backend answered by hand.
|
|
19
|
+
|
|
20
|
+
## On the list: one answer, and the list waits
|
|
21
|
+
|
|
22
|
+
`action` on the `<List>` is the selection being sent. The list holds one value,
|
|
23
|
+
so there is one run, and the whole list is busy until it comes back — every row
|
|
24
|
+
refuses in the meantime, which is what you want when the rows are alternatives.
|
|
25
|
+
|
|
26
|
+
```jsx
|
|
27
|
+
<List selectable action={(seat) => putSeat(seat)}>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A press on another row while it runs is refused and says so, in the selection's
|
|
31
|
+
own words ("la sélection est en cours d'enregistrement") rather than in a row's.
|
|
32
|
+
|
|
33
|
+
## On a row's control: that row waits alone
|
|
34
|
+
|
|
35
|
+
`action` on the button inside the row is a call about that row. The list is not
|
|
36
|
+
part of it: it holds no action, nothing about it is busy, and the other rows
|
|
37
|
+
stay live. Two rows can be in flight at once.
|
|
38
|
+
|
|
39
|
+
```jsx
|
|
40
|
+
<List selectable multiple>
|
|
41
|
+
<List.Item
|
|
42
|
+
selectable
|
|
43
|
+
value={person}
|
|
44
|
+
readOnly={pending}
|
|
45
|
+
selectableArea="manual"
|
|
46
|
+
>
|
|
47
|
+
<Text expandX>{person}</Text>
|
|
48
|
+
<Button
|
|
49
|
+
action={() => invite(person)}
|
|
50
|
+
command="--navi-select"
|
|
51
|
+
commandFor="the_list"
|
|
52
|
+
command-value={rowId}
|
|
53
|
+
>
|
|
54
|
+
Invite
|
|
55
|
+
</Button>
|
|
56
|
+
</List.Item>
|
|
57
|
+
</List>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The command is what marks the row, and it runs **only if the action succeeded**
|
|
61
|
+
— a button given both runs its action first and lets its command follow (see
|
|
62
|
+
[actions.md](./actions.md)). So a refused invitation leaves the row unmarked
|
|
63
|
+
without anything to undo.
|
|
64
|
+
|
|
65
|
+
`selectableArea="manual"` is what makes the button the interaction surface: the
|
|
66
|
+
row stops claiming presses that land anywhere in it, so the only way in is the
|
|
67
|
+
button.
|
|
68
|
+
|
|
69
|
+
## Who draws the wait, and who answers a press
|
|
70
|
+
|
|
71
|
+
**Whatever control is on the row carries the wait and the refusal.** A button,
|
|
72
|
+
a checkbox, a picker — it has a place to draw a spinner and an anchor to hang a
|
|
73
|
+
callout on, and it is what the user pressed. The row itself is the fallback,
|
|
74
|
+
for a row that holds nothing but text: it draws a loading outline, swallows
|
|
75
|
+
presses (its buttons included) and opens the sentence itself.
|
|
76
|
+
|
|
77
|
+
That is why a selectable row does not answer a press twice. It swallows the
|
|
78
|
+
press so nothing inside it acts, then asks the control it carries to explain —
|
|
79
|
+
one callout, one sentence, the same on the pointer and on the keyboard, and the
|
|
80
|
+
caller's `readOnlyMessage` / `busyMessage` respected either way.
|
|
81
|
+
|
|
82
|
+
## Read-only, not loading
|
|
83
|
+
|
|
84
|
+
A row whose button is working is **not loading** — the button is. The row is in
|
|
85
|
+
use, and what says that is `readOnly`:
|
|
86
|
+
|
|
87
|
+
```jsx
|
|
88
|
+
<List.Item readOnly={pending} readOnlyMessage="Invitation en cours." />
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`loading` on a row means the ROW is waiting on something — being added to the
|
|
92
|
+
list, removed from it, saved where it is (see the `loading` values on
|
|
93
|
+
`List.Item`). Putting it there for a control's run draws a second wait next to
|
|
94
|
+
the one the button is already drawing, and says the row is being changed when it
|
|
95
|
+
is only being used.
|
|
96
|
+
|
|
97
|
+
Give the row a `readOnlyMessage` when it is held for a reason that will pass:
|
|
98
|
+
the default sentence is about availability ("this option is not available"),
|
|
99
|
+
which is the wrong thing to say about a wait.
|
|
100
|
+
|
|
101
|
+
## Hold the rows, not the list
|
|
102
|
+
|
|
103
|
+
It is tempting to write `<List readOnly={pending}>` to keep a second row from
|
|
104
|
+
being pressed while one is in flight. It works, and it works by luck: the
|
|
105
|
+
command the button fires on success (`--navi-select`) is aimed at the list, and
|
|
106
|
+
a list still read-only at that moment refuses it — the call goes through and the
|
|
107
|
+
row is never marked, silently.
|
|
108
|
+
|
|
109
|
+
Whether it lands comes down to one tick. Clearing the flag in a `finally` clears
|
|
110
|
+
it before the command runs, so the selection arrives; clearing it a moment later
|
|
111
|
+
— a transition, a debounce, one more `await` on the way out — loses it. Nothing
|
|
112
|
+
on screen says which of the two you wrote.
|
|
113
|
+
|
|
114
|
+
Hold the rows instead. `readOnly` on every row is what "one at a time" looks
|
|
115
|
+
like, it says the same thing on screen, and it leaves the list free to take the
|
|
116
|
+
answer whenever the answer comes:
|
|
117
|
+
|
|
118
|
+
```jsx
|
|
119
|
+
<List.Item readOnly={pending !== null} /> // all of them: one at a time
|
|
120
|
+
<List.Item readOnly={pending.includes(name)} /> // this one: each on its own
|
|
121
|
+
```
|
package/docs/popup_open.md
CHANGED
|
@@ -12,6 +12,7 @@ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
|
|
|
12
12
|
- [A press that opens a popup and acts on it](#a-press-that-opens-a-popup-and-acts-on-it)
|
|
13
13
|
- [Reacting to open and close](#reacting-to-open-and-close)
|
|
14
14
|
- [Escape cancels, the other gestures keep](#escape-cancels-the-other-gestures-keep)
|
|
15
|
+
- [The close cross](#the-close-cross)
|
|
15
16
|
- [When the app holds the open state](#when-the-app-holds-the-open-state)
|
|
16
17
|
- [A popup that loads data](#a-popup-that-loads-data)
|
|
17
18
|
- [What the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)
|
|
@@ -423,12 +424,12 @@ that fails does so behind a closed popup**.
|
|
|
423
424
|
The gestures that close a popup do not all mean the same thing, and that is on
|
|
424
425
|
purpose:
|
|
425
426
|
|
|
426
|
-
| gesture
|
|
427
|
-
|
|
|
428
|
-
| Escape
|
|
429
|
-
| a click outside
|
|
430
|
-
| a close
|
|
431
|
-
| `--navi-cancel` on a button
|
|
427
|
+
| gesture | what it means | who decides |
|
|
428
|
+
| ------------------------------ | ------------- | --------------------------------------------------- |
|
|
429
|
+
| Escape | cancel | `escapeEffect="cancel"` (default) |
|
|
430
|
+
| a click outside | close, keep | `pointerInteractionOutsideEffect="close"` (default) |
|
|
431
|
+
| a close cross (`--navi-close`) | close, keep | [the cross](#the-close-cross) |
|
|
432
|
+
| `--navi-cancel` on a button | cancel | the button |
|
|
432
433
|
|
|
433
434
|
Escape says "forget it". It is the one gesture that has meant that everywhere,
|
|
434
435
|
for as long as there have been dialogs, and navi keeps it that way. **A popup
|
|
@@ -500,6 +501,69 @@ A dialog picker's cancel also goes back in history, so anything written to the
|
|
|
500
501
|
url while it was open (a route `stateSignal`, a search param) goes back with it
|
|
501
502
|
— one more reason Escape and the click outside are not interchangeable.
|
|
502
503
|
|
|
504
|
+
## The close cross
|
|
505
|
+
|
|
506
|
+
The cross is navi's own: `<Dialog.Close />`, `<Popover.Close />`,
|
|
507
|
+
`<Popup.Close />` — one component under three names, reached from whichever
|
|
508
|
+
popup is being written. The caller places it, and nothing else:
|
|
509
|
+
|
|
510
|
+
```jsx
|
|
511
|
+
<Dialog id="duration">
|
|
512
|
+
<Box header flex="x" alignY="center" padding="s">
|
|
513
|
+
<Box expandX>Durée de la partie</Box>
|
|
514
|
+
<Dialog.Close />
|
|
515
|
+
</Box>
|
|
516
|
+
…
|
|
517
|
+
</Dialog>
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
It comes with the things a hand-written cross has to remember: the `aria-label`
|
|
521
|
+
in the active language (`label` overrides it), and the padding that turns a
|
|
522
|
+
three-millimetre glyph into a target a thumb can hit.
|
|
523
|
+
|
|
524
|
+
### It is the way out, so the state around it does not reach it
|
|
525
|
+
|
|
526
|
+
**A close button written by hand refuses the press inside a read-only control.**
|
|
527
|
+
A read-only `Picker` still opens — what it holds is often a shape only its popup
|
|
528
|
+
draws, and reading it changes nothing — and it hands its popup that same
|
|
529
|
+
read-only so every control in there refuses on its own terms. The cross is not
|
|
530
|
+
one of those controls: closing writes nothing to the picker. `<Dialog.Close />`
|
|
531
|
+
says so (`whenSelfInteractionsBlocked="ignore"`, see
|
|
532
|
+
[interactions.md](./interactions.md)); a `<Button command="--navi-close" />`
|
|
533
|
+
does not, wears the read-only it was handed, and answers a press aimed at the
|
|
534
|
+
way out with "this action is not available right now".
|
|
535
|
+
|
|
536
|
+
The same holds for a disabled zone, and for a form busy sending: the way out of
|
|
537
|
+
a popup is never held by the state of what the popup belongs to.
|
|
538
|
+
|
|
539
|
+
If a cross really has to be hand-written — it is drawn into a bigger affordance
|
|
540
|
+
of the app's own, say — it carries the claim itself:
|
|
541
|
+
|
|
542
|
+
```jsx
|
|
543
|
+
<Button command="--navi-close" icon whenSelfInteractionsBlocked="ignore">
|
|
544
|
+
…
|
|
545
|
+
</Button>
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
### It asks, it does not force
|
|
549
|
+
|
|
550
|
+
Being exempt from the state around it is not being exempt from the popup's
|
|
551
|
+
answer. The cross sends `--navi-close`, which reaches the popup as a close
|
|
552
|
+
REQUEST — the same one Escape and the click outside make — and the popup
|
|
553
|
+
decides:
|
|
554
|
+
|
|
555
|
+
- a control inside it is mid-action → the close is refused, and that control
|
|
556
|
+
says why ([above](#the-popup-owns-its-open-state));
|
|
557
|
+
- `onRequestClose` calls `preventDefault()` → the popup stays open;
|
|
558
|
+
- a `Picker` takes the close as an answer, so it validates what its popup holds
|
|
559
|
+
and runs its action on the way out — and an invalid value keeps it open.
|
|
560
|
+
|
|
561
|
+
The cross therefore lands exactly where a click outside lands: same request,
|
|
562
|
+
same refusals, same "close, keep". A popup that must not be dismissed at a given
|
|
563
|
+
moment says so in its own `onRequestClose`, where Escape and the backdrop are
|
|
564
|
+
refused too — not by hiding or disabling the cross, which only removes the one
|
|
565
|
+
way out that is visible.
|
|
566
|
+
|
|
503
567
|
## When the app holds the open state
|
|
504
568
|
|
|
505
569
|
A popup whose being-open is a fact about the application rather than about the
|