@jsenv/navi 0.29.171 → 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.
@@ -97,6 +97,19 @@ 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.
100
113
  - `docs/error_handling.md` — the two kinds of error and how navi keeps them
101
114
  apart: where an error is shown depending on where it came from (a control's
102
115
  action shows it on what was clicked, a route action replaces the page, a
@@ -167,8 +180,11 @@ consistency across the app, not from any single call site.
167
180
  RESORT for a decision that is not a press — with the rule that decides most
168
181
  reviews: the `event` it takes is the gesture you were handed, threaded down to
169
182
  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
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
172
188
  `onOpen`/`onClose` writing a signal.
173
189
  - `docs/autofocus.md` — who gets the keyboard when a popup opens or a slide
174
190
  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,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
+ ```
@@ -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.172",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {