@jsenv/navi 0.29.171 → 0.29.173
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 +326 -12
- package/dist/jsenv_navi.js.map +12 -10
- package/docs/AI_INSTRUCTIONS.md +20 -2
- package/docs/interactions.md +6 -0
- package/docs/list_action.md +165 -0
- package/docs/popup_open.md +70 -6
- package/package.json +1 -1
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -97,6 +97,21 @@ consistency across the app, not from any single call site.
|
|
|
97
97
|
Read it before adding verbs to `rerunOn`, hiding a list on `loading`,
|
|
98
98
|
remounting a list with a `key` to refresh it, or reporting that a page
|
|
99
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. Also `parallelGuard` — how many of a list's rows
|
|
113
|
+
may be acting at once, four by default, `Infinity` to lift it: it counts runs
|
|
114
|
+
where `maxLengthGuard` counts values.
|
|
100
115
|
- `docs/error_handling.md` — the two kinds of error and how navi keeps them
|
|
101
116
|
apart: where an error is shown depending on where it came from (a control's
|
|
102
117
|
action shows it on what was clicked, a route action replaces the page, a
|
|
@@ -167,8 +182,11 @@ consistency across the app, not from any single call site.
|
|
|
167
182
|
RESORT for a decision that is not a press — with the rule that decides most
|
|
168
183
|
reviews: the `event` it takes is the gesture you were handed, threaded down to
|
|
169
184
|
the call, never a `new CustomEvent()` built on the spot (that is for a timer,
|
|
170
|
-
an action settling, a signal changing, and nothing else).
|
|
171
|
-
|
|
185
|
+
an action settling, a signal changing, and nothing else). Also the close
|
|
186
|
+
cross: `<Dialog.Close />` exists, it is the way out and so the one affordance
|
|
187
|
+
the surrounding read-only/disabled does not reach, and it asks rather than
|
|
188
|
+
forces. Read it before calling `triggerNaviCommand`, before passing `open` to
|
|
189
|
+
a popup, before writing a close button by hand, and before an
|
|
172
190
|
`onOpen`/`onClose` writing a signal.
|
|
173
191
|
- `docs/autofocus.md` — who gets the keyboard when a popup opens or a slide
|
|
174
192
|
arrives, and whether a ring shows on it: the ladder navi walks, what
|
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,165 @@
|
|
|
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
|
+
- [How many at once: `parallelGuard`](#how-many-at-once-parallelguard)
|
|
16
|
+
|
|
17
|
+
Live examples: the **Button** section of
|
|
18
|
+
`src/control/demos/13_list_selectable_demo.html` shows both, side by side, over
|
|
19
|
+
a backend answered by hand.
|
|
20
|
+
|
|
21
|
+
## On the list: one answer, and the list waits
|
|
22
|
+
|
|
23
|
+
`action` on the `<List>` is the selection being sent. The list holds one value,
|
|
24
|
+
so there is one run, and the whole list is busy until it comes back — every row
|
|
25
|
+
refuses in the meantime, which is what you want when the rows are alternatives.
|
|
26
|
+
|
|
27
|
+
```jsx
|
|
28
|
+
<List selectable action={(seat) => putSeat(seat)}>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A press on another row while it runs is refused and says so, in the selection's
|
|
32
|
+
own words ("la sélection est en cours d'enregistrement") rather than in a row's.
|
|
33
|
+
|
|
34
|
+
## On a row's control: that row waits alone
|
|
35
|
+
|
|
36
|
+
`action` on the button inside the row is a call about that row. The list is not
|
|
37
|
+
part of it: it holds no action, nothing about it is busy, and the other rows
|
|
38
|
+
stay live. Two rows can be in flight at once.
|
|
39
|
+
|
|
40
|
+
```jsx
|
|
41
|
+
<List selectable multiple>
|
|
42
|
+
<List.Item
|
|
43
|
+
selectable
|
|
44
|
+
value={person}
|
|
45
|
+
readOnly={pending}
|
|
46
|
+
selectableArea="manual"
|
|
47
|
+
>
|
|
48
|
+
<Text expandX>{person}</Text>
|
|
49
|
+
<Button
|
|
50
|
+
action={() => invite(person)}
|
|
51
|
+
command="--navi-select"
|
|
52
|
+
commandFor="the_list"
|
|
53
|
+
command-value={rowId}
|
|
54
|
+
>
|
|
55
|
+
Invite
|
|
56
|
+
</Button>
|
|
57
|
+
</List.Item>
|
|
58
|
+
</List>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The command is what marks the row, and it runs **only if the action succeeded**
|
|
62
|
+
— a button given both runs its action first and lets its command follow (see
|
|
63
|
+
[actions.md](./actions.md)). So a refused invitation leaves the row unmarked
|
|
64
|
+
without anything to undo.
|
|
65
|
+
|
|
66
|
+
`selectableArea="manual"` is what makes the button the interaction surface: the
|
|
67
|
+
row stops claiming presses that land anywhere in it, so the only way in is the
|
|
68
|
+
button.
|
|
69
|
+
|
|
70
|
+
## Who draws the wait, and who answers a press
|
|
71
|
+
|
|
72
|
+
**Whatever control is on the row carries the wait and the refusal.** A button,
|
|
73
|
+
a checkbox, a picker — it has a place to draw a spinner and an anchor to hang a
|
|
74
|
+
callout on, and it is what the user pressed. The row itself is the fallback,
|
|
75
|
+
for a row that holds nothing but text: it draws a loading outline, swallows
|
|
76
|
+
presses (its buttons included) and opens the sentence itself.
|
|
77
|
+
|
|
78
|
+
That is why a selectable row does not answer a press twice. It swallows the
|
|
79
|
+
press so nothing inside it acts, then asks the control it carries to explain —
|
|
80
|
+
one callout, one sentence, the same on the pointer and on the keyboard, and the
|
|
81
|
+
caller's `readOnlyMessage` / `busyMessage` respected either way.
|
|
82
|
+
|
|
83
|
+
And it lands **where the press landed**. A row answers for a whole box, and its
|
|
84
|
+
own control is a visually hidden checkbox, so a sentence anchored on that would
|
|
85
|
+
sit in the middle of the row pointing at nothing. Pressed on the button, it
|
|
86
|
+
belongs on the button; pressed on the row's own surface, on the row:
|
|
87
|
+
|
|
88
|
+
| what was pressed | where the sentence appears |
|
|
89
|
+
| ----------------------------------------------- | -------------------------- |
|
|
90
|
+
| the button of a row whose own run is going | the button |
|
|
91
|
+
| the name of that same row | the row |
|
|
92
|
+
| the button of a row held back by something else | the button |
|
|
93
|
+
|
|
94
|
+
The last one needs nothing special: that row is not blocked, so the press
|
|
95
|
+
reaches the button and the button's own gate answers it.
|
|
96
|
+
|
|
97
|
+
A row that would rather always take it says so once:
|
|
98
|
+
|
|
99
|
+
```jsx
|
|
100
|
+
<List.Item data-callout-anchor="item" />
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Read-only, not loading
|
|
104
|
+
|
|
105
|
+
A row whose button is working is **not loading** — the button is. The row is in
|
|
106
|
+
use, and what says that is `readOnly`:
|
|
107
|
+
|
|
108
|
+
```jsx
|
|
109
|
+
<List.Item readOnly={pending} readOnlyMessage="Invitation en cours." />
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`loading` on a row means the ROW is waiting on something — being added to the
|
|
113
|
+
list, removed from it, saved where it is (see the `loading` values on
|
|
114
|
+
`List.Item`). Putting it there for a control's run draws a second wait next to
|
|
115
|
+
the one the button is already drawing, and says the row is being changed when it
|
|
116
|
+
is only being used.
|
|
117
|
+
|
|
118
|
+
Give the row a `readOnlyMessage` when it is held for a reason that will pass:
|
|
119
|
+
the default sentence is about availability ("this option is not available"),
|
|
120
|
+
which is the wrong thing to say about a wait.
|
|
121
|
+
|
|
122
|
+
## Hold the rows, not the list
|
|
123
|
+
|
|
124
|
+
It is tempting to write `<List readOnly={pending}>` to keep a second row from
|
|
125
|
+
being pressed while one is in flight. It works, and it works by luck: the
|
|
126
|
+
command the button fires on success (`--navi-select`) is aimed at the list, and
|
|
127
|
+
a list still read-only at that moment refuses it — the call goes through and the
|
|
128
|
+
row is never marked, silently.
|
|
129
|
+
|
|
130
|
+
Whether it lands comes down to one tick. Clearing the flag in a `finally` clears
|
|
131
|
+
it before the command runs, so the selection arrives; clearing it a moment later
|
|
132
|
+
— a transition, a debounce, one more `await` on the way out — loses it. Nothing
|
|
133
|
+
on screen says which of the two you wrote.
|
|
134
|
+
|
|
135
|
+
Hold the rows instead. `readOnly` on every row is what "one at a time" looks
|
|
136
|
+
like, it says the same thing on screen, and it leaves the list free to take the
|
|
137
|
+
answer whenever the answer comes:
|
|
138
|
+
|
|
139
|
+
```jsx
|
|
140
|
+
<List.Item readOnly={pending !== null} /> // all of them: one at a time
|
|
141
|
+
<List.Item readOnly={pending.includes(name)} /> // this one: each on its own
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## How many at once: `parallelGuard`
|
|
145
|
+
|
|
146
|
+
A list is a place where the same gesture is available many times over, and
|
|
147
|
+
nothing about a row's own action stops someone from starting one on every row it
|
|
148
|
+
draws — a dozen requests in flight because the list happened to be long.
|
|
149
|
+
|
|
150
|
+
So a list allows **four runs at once by default**. While that many are out,
|
|
151
|
+
every row that is not running goes read-only and says how many it is waiting on;
|
|
152
|
+
the next press is possible again the moment one comes back.
|
|
153
|
+
|
|
154
|
+
```jsx
|
|
155
|
+
<List parallelGuard={2}> // stricter
|
|
156
|
+
<List parallelGuard={Infinity}> // lifted, without taking it out of the tree
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
It counts runs, not values — which is the whole difference from `maxLengthGuard`
|
|
160
|
+
next to it: that one says how many things the selection may HOLD, this one how
|
|
161
|
+
many may be HAPPENING. A list can carry either, or both, and they refuse for
|
|
162
|
+
unrelated reasons.
|
|
163
|
+
|
|
164
|
+
Only a control that would start a run is held: a row with nothing to run, or a
|
|
165
|
+
button that merely fires a command, never waits its turn.
|
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
|