@jsenv/navi 0.29.37 → 0.29.39

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.
@@ -78,6 +78,11 @@ consistency across the app, not from any single call site.
78
78
  `header`/`body`/`footer` on, `FixedBar` space, `List`'s `scroller`, and
79
79
  scroll inside a `Dialog`/`Popover`. Read it before writing CSS to make
80
80
  something scroll — navi almost certainly already has the prop.
81
+ - `docs/control_value.md` — who holds a control's value: nobody, a bound
82
+ `signal` (two-way, in both directions), or you (`value`/`checked`). What
83
+ `signal` + `defaultValue` says, what a signal holds for each kind of control,
84
+ and why `value` and `signal` cannot both be passed. Read it before wiring a
85
+ control's value by hand with `value` + `uiAction`.
81
86
  - `docs/control_group.md` — `<Group>`: several controls reading as one framed
82
87
  object (one border per seam, radius on the outer corners only). Read it
83
88
  before placing bordered controls against each other, and before writing
package/docs/actions.md CHANGED
@@ -115,3 +115,6 @@ changes do exactly that).
115
115
  - [resource_with_params.md](./resource_with_params.md) — `withParams()` and
116
116
  isolated rerun scopes
117
117
  - [list_refresh.md](./list_refresh.md) — what a write refreshes
118
+ - [popup_open.md](./popup_open.md#closing-when-a-button-also-runs-an-action) —
119
+ closing a popup from a button that also runs an action (closing from inside
120
+ the action is refused)
@@ -0,0 +1,132 @@
1
+ # Who holds a control's value
2
+
3
+ Nobody, a signal, or you. Every control answers one of those three, and which
4
+ one it is decides what happens when the value moves — from a gesture, or from
5
+ somewhere else in the app.
6
+
7
+ - [The three answers](#the-three-answers)
8
+ - [A bound signal works in both directions](#a-bound-signal-works-in-both-directions)
9
+ - [`signal` + `defaultValue`: the answer and where it starts](#signal--defaultvalue-the-answer-and-where-it-starts)
10
+ - [What a signal holds, control by control](#what-a-signal-holds-control-by-control)
11
+ - [Which controls take a `signal`](#which-controls-take-a-signal)
12
+ - [`value` and `signal` exclude each other](#value-and-signal-exclude-each-other)
13
+ - [A `stateSignal` brings more than a value](#a-statesignal-brings-more-than-a-value)
14
+
15
+ ## The three answers
16
+
17
+ | what you pass | who holds the value | when the user acts |
18
+ | ---------------------- | ------------------- | ------------------------------------------- |
19
+ | nothing | the control | it keeps it; `uiAction` tells you |
20
+ | `defaultValue` | the control | same — the default is only where it starts |
21
+ | `signal` | the signal | the control writes it back, both ways |
22
+ | `value` (or `checked`) | you | nothing moves until you hand a new one down |
23
+
24
+ A control given `value` and nothing to listen to it (`uiAction`, `action`, a
25
+ `signal`, a surrounding form) is read-only, and says so in dev: it is showing
26
+ something nobody can change.
27
+
28
+ ## A bound signal works in both directions
29
+
30
+ This is the part that does not show in a call site: `signal` is not a seed. The
31
+ control writes every change into it, **and follows it when something else
32
+ writes it**.
33
+
34
+ ```jsx
35
+ const minutesSignal = useSignal(0);
36
+
37
+ <Wheel type="integer" signal={minutesSignal}>
38
+ {MINUTES.map((m) => (
39
+ <Wheel.Item key={m} value={m}>
40
+ {pad2(m)}
41
+ </Wheel.Item>
42
+ ))}
43
+ </Wheel>;
44
+
45
+ // elsewhere — the wheel rolls to 30, no re-render of your own needed
46
+ minutesSignal.value = 30;
47
+ ```
48
+
49
+ Both halves are worth knowing about, because each replaces a habit:
50
+
51
+ - the write-back replaces `uiAction={(v) => (mySignal.value = v)}`;
52
+ - the follow replaces the `key` or the `value`/`uiAction` pair used to push an
53
+ outside change into a control.
54
+
55
+ ## `signal` + `defaultValue`: the answer and where it starts
56
+
57
+ They are not competing, they answer two different questions:
58
+
59
+ - the **signal** is the answer, when it holds one;
60
+ - `defaultValue` is where the control starts, and where a reset goes back to.
61
+
62
+ ```jsx
63
+ // "how many players" is what my account usually answers, unless this game says
64
+ // otherwise — no `??` to write, and no first render showing the wrong one
65
+ <List selectable signal={gameLevelsSignal} defaultValue={me.levels}>
66
+ ```
67
+
68
+ An emptied signal (`signal.value = undefined`) puts the control back on its
69
+ default rather than leaving it blank — which is what makes "nothing decided
70
+ here, use the usual answer" expressible at all. Without a `defaultValue`, an
71
+ emptied signal empties the control.
72
+
73
+ ## What a signal holds, control by control
74
+
75
+ The signal holds what the control is ABOUT, which is not always its `value`
76
+ attribute:
77
+
78
+ | control | what the signal holds |
79
+ | ------------------------------------------------------------- | --------------------------------- |
80
+ | text/number/date `Input`, `Wheel`, `Spin`, `Picker`, `Select` | the value itself |
81
+ | checkbox, radio | a boolean — whether it is checked |
82
+ | `List selectable` | the selected value |
83
+ | `List selectable multiple`, checkbox group | the array of selected values |
84
+
85
+ A group (a selectable list, a checkbox group) writes its whole selection into
86
+ the signal, not one item's value — its children put it together between them.
87
+
88
+ ## Which controls take a `signal`
89
+
90
+ All of them: `Input` (every type), `Picker`, `Select`, `Wheel`, `Spin`,
91
+ `List selectable` (single and multiple), and control groups in general. Anything
92
+ that is a navi control goes through the same state controller, and the same
93
+ `signal` prop.
94
+
95
+ Inside a `List selectable` you can bind the list, or give each `List.Item` its
96
+ own `selected` — but not expect the two to arbitrate. An item that declares
97
+ `selected` is answering for itself, and the list's signal does not reposition
98
+ it.
99
+
100
+ ## `value` and `signal` exclude each other
101
+
102
+ `value` (or `checked`) says "you hold it", `signal` says "the signal holds it".
103
+ Passing both is a call site to fix: **the signal wins and the other prop is
104
+ ignored** — on a leaf control as on a group (a selectable list, a checkbox
105
+ group) — and navi says so in dev. One owner, whichever half of the binding you
106
+ look at.
107
+
108
+ Replacing `value` with `signal` also means dropping the `uiAction` that used to
109
+ write the signal by hand — it is exactly what the binding now does. Keep
110
+ `uiAction` only for what is not "remember the value": logging, a side effect,
111
+ something else moving with it.
112
+
113
+ ## A `stateSignal` brings more than a value
114
+
115
+ A plain signal (`useSignal`, `signal()`) is enough to bind a control. A
116
+ `stateSignal` also carries its own `options`, and a control reads them so it
117
+ does not have to be told twice: `type` (which decides the input type and the
118
+ validation messages), `min`, `max`, `step`, and its **default**, which seeds
119
+ `defaultValue`/`defaultChecked` — so a reset goes back to the signal's original
120
+ default rather than to whatever it happened to hold at the last render.
121
+
122
+ That is the only difference. A plain signal binds the same way in both
123
+ directions; it just has nothing extra to say.
124
+
125
+ ## See also
126
+
127
+ - [control_group.md](./control_group.md) — several controls reading as one
128
+ framed object
129
+ - [actions.md](./actions.md) — `action` vs `uiAction`: what carries loading and
130
+ error
131
+ - [popup_open.md](./popup_open.md#escape-cancels-the-other-gestures-keep) — what
132
+ a cancelled popup does to the value inside it
@@ -8,6 +8,7 @@ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
8
8
  - [Which element receives the command](#which-element-receives-the-command)
9
9
  - [The anchor](#the-anchor)
10
10
  - [Reacting to open and close](#reacting-to-open-and-close)
11
+ - [Escape cancels, the other gestures keep](#escape-cancels-the-other-gestures-keep)
11
12
  - [When `open` is the right answer, and what it costs](#when-open-is-the-right-answer-and-what-it-costs)
12
13
  - [What the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)
13
14
 
@@ -51,7 +52,9 @@ the opening?":
51
52
 
52
53
  The available commands: `--navi-open`, `--navi-close`, `--navi-toggle`,
53
54
  `--navi-cancel` (closes, telling the popup the close means "revert"),
54
- `--navi-confirm` (says yes, then closes).
55
+ `--navi-confirm` (says yes, then closes). What "revert" does to what is inside
56
+ is [its own section](#escape-cancels-the-other-gestures-keep) — it is also what
57
+ Escape says.
55
58
 
56
59
  ## Something else opens it: `triggerNaviCommand`
57
60
 
@@ -139,6 +142,107 @@ useLayoutEffect(() => {
139
142
  }, []);
140
143
  ```
141
144
 
145
+ ### Closing when a button also runs an action
146
+
147
+ Closing from inside the `action` does not close. While the action runs, the
148
+ button that started it is busy, and a busy control is exactly what a popup
149
+ refuses to close over (see the top of this page). The refusal is not silent —
150
+ the busy control raises a callout saying so — but the popup stays open, and the
151
+ first Escape afterwards dismisses that callout rather than the popup, which
152
+ reads as a popup that no longer closes at all.
153
+
154
+ There are two shapes, and they say different things:
155
+
156
+ ```jsx
157
+ // Closes on the press. The popup does NOT wait for save(): it is already
158
+ // closed when the action starts, and the action finishes behind it.
159
+ <Button command="--navi-close" commandfor="note-dialog" action={save}>
160
+ Save
161
+ </Button>
162
+ ```
163
+
164
+ `command` next to `action` is the one to reach for when the answer is taken as
165
+ soon as it is given — the popup gets out of the way, the save runs on its own.
166
+ Know what it costs: **a save that fails does so behind a closed popup**, and the
167
+ error callout it raises lands on a button nobody can see any more. Use it where
168
+ the failure is reported somewhere else, or where losing it is acceptable.
169
+
170
+ ```jsx
171
+ // Closes only once save() has resolved, and stays open if it throws.
172
+ <Button
173
+ action={save}
174
+ onActionEnd={() => {
175
+ // NOT synchronously: the button still counts as busy while its own
176
+ // action-end handlers run, and the popup would refuse the close.
177
+ queueMicrotask(() => {
178
+ triggerNaviCommand(dialogRef.current, "--navi-close");
179
+ });
180
+ }}
181
+ >
182
+ Save
183
+ </Button>
184
+ ```
185
+
186
+ `onActionEnd` only fires when the action succeeded, so the popup stays open on
187
+ failure — the answer is then neither committed nor given up, and it is still
188
+ there to be corrected, with the error shown on the button that raised it.
189
+
190
+ ## Escape cancels, the other gestures keep
191
+
192
+ The gestures that close a popup do not all mean the same thing, and that is on
193
+ purpose:
194
+
195
+ | gesture | what it means | who decides |
196
+ | ------------------------------- | ------------- | --------------------------------------------------- |
197
+ | Escape | cancel | `escapeEffect="cancel"` (default) |
198
+ | a click outside | close, keep | `pointerInteractionOutsideEffect="close"` (default) |
199
+ | a close button (`--navi-close`) | close, keep | the button |
200
+ | `--navi-cancel` on a button | cancel | the button |
201
+
202
+ Escape says "forget it". It is the one gesture that has meant that everywhere,
203
+ for as long as there have been dialogs, and navi keeps it that way. **A popup
204
+ that must offer a way out that KEEPS what was chosen offers it with a close
205
+ cross, or by letting the click outside close** — not by teaching Escape to say
206
+ something else.
207
+
208
+ ### What "cancel" actually undoes
209
+
210
+ Cancelling is not itself an undo: it marks the close, and whoever holds a value
211
+ decides what to do with the mark.
212
+
213
+ - `Dialog` and `Popover` hold nothing, so they undo nothing. The close event
214
+ carries `detail.isCancel` and `onClose` receives it — reverting is then the
215
+ caller's own business.
216
+ - `Picker` holds a value, so it puts back **the value it held when it opened**.
217
+ That is what makes a picker a picker: opening one is trying something on, and
218
+ Escape is putting it back.
219
+
220
+ ```jsx
221
+ // Escape here puts back the level the picker held at open, and the list's
222
+ // uiAction fires with that restored value — the draft goes back with it.
223
+ <Picker id="level" ui={…}>
224
+ <List selectable multiple value={draft.levels} uiAction={…}>…</List>
225
+ </Picker>
226
+ ```
227
+
228
+ The trap is the FIRST open. A picker holds what its popup told it, and before
229
+ the popup has ever been open it has been told nothing — so "the value at open"
230
+ is nothing, even when the control inside starts on a `defaultValue` or on a
231
+ value the app passes it. Escape on that first pass goes back to empty, not to
232
+ what was on screen when the popup opened. From the second open onwards it puts
233
+ back what was really there.
234
+
235
+ ### `escapeEffect="close"`, and why it is a last resort
236
+
237
+ `escapeEffect="close"` makes Escape say what a click outside says. It exists,
238
+ and it is almost never what you want: it takes away the only key that undoes,
239
+ and a popup with no way back is one people stop opening. Reach for a close
240
+ cross first.
241
+
242
+ A dialog picker's cancel also goes back in history, so anything written to the
243
+ url while it was open (a route `stateSignal`, a search param) goes back with it
244
+ — one more reason Escape and the click outside are not interchangeable.
245
+
142
246
  ## When `open` is the right answer, and what it costs
143
247
 
144
248
  `open` is for a popup whose being-open is a fact about the application, not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.37",
3
+ "version": "0.29.39",
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.12",
32
+ "@jsenv/dom": "0.17.13",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.2"
35
35
  },