@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.
@@ -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). Read it before
171
- calling `triggerNaviCommand`, before passing `open` to a popup, and before an
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
@@ -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.
@@ -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 | what it means | who decides |
427
- | ------------------------------- | ------------- | --------------------------------------------------- |
428
- | Escape | cancel | `escapeEffect="cancel"` (default) |
429
- | a click outside | close, keep | `pointerInteractionOutsideEffect="close"` (default) |
430
- | a close button (`--navi-close`) | close, keep | the button |
431
- | `--navi-cancel` on a button | cancel | the 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.171",
3
+ "version": "0.29.173",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {