@jsenv/navi 0.29.42 → 0.29.44

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.
@@ -0,0 +1,129 @@
1
+ # What a form sends, and what it measures against
2
+
3
+ A form answers a question. Asked again with the same answer, it has nothing to
4
+ say — so by default **a `<Form>` sends nothing when nothing changed**. This is
5
+ the whole subject of this file: what "changed" is measured against, what counts
6
+ as an answer the form already holds, and what to do on a screen whose fields are
7
+ filled a request later.
8
+
9
+ - [Sending nothing is the default](#sending-nothing-is-the-default)
10
+ - [What the form is measured against](#what-the-form-is-measured-against)
11
+ - [What counts as already held](#what-counts-as-already-held)
12
+ - [A screen filled after it opened: `pristineKey`](#a-screen-filled-after-it-opened-pristinekey)
13
+ - [A submit that says it is waiting](#a-submit-that-says-it-is-waiting)
14
+ - [A control that is not a field](#a-control-that-is-not-a-field)
15
+
16
+ ## Sending nothing is the default
17
+
18
+ Submitting a form nobody touched — one just rendered, one whose fields still
19
+ hold their defaults, one reopened and left alone — runs **no action**. No
20
+ request, nothing in the network tab.
21
+
22
+ Everything around the action still happens: the constraints are checked, and
23
+ what follows the send still follows it (the popup closes, the slide moves on).
24
+ The user is done either way; there was simply nothing to send.
25
+
26
+ `canSendWhileUnchanged` turns that off, for a form where sending the same thing
27
+ twice is the point — a single button firing a notification, an action whose
28
+ duplicates are fine.
29
+
30
+ ```jsx
31
+ <Form action={notify} canSendWhileUnchanged>
32
+ ```
33
+
34
+ ## What the form is measured against
35
+
36
+ One value, called the baseline here: **what the form held the last time it had
37
+ nothing to say.**
38
+
39
+ - taken once the fields have registered — the earliest moment the form knows
40
+ what it holds;
41
+ - taken again after every **successful** send, so the next submit is measured
42
+ against what was just sent (a send that failed changes nothing: the same
43
+ value must remain sendable);
44
+ - never taken again on its own. A form does not notice that its fields were
45
+ filled from the outside — see `pristineKey` below.
46
+
47
+ Fields holding nothing are left out on both sides. Whether an empty field is
48
+ absent or present-and-empty depends on when it registered, and comparing those
49
+ would make an untouched form look changed.
50
+
51
+ ## What counts as already held
52
+
53
+ This is the part that decides everything, and the one that surprises: a field
54
+ can be **named, filled, and still not part of the baseline**.
55
+
56
+ | what the field was given | held? |
57
+ | --------------------------------------------- | ----- |
58
+ | `value` | yes |
59
+ | `signal` carrying something | yes |
60
+ | `signal` that is empty | no |
61
+ | `defaultValue` (and the field still shows it) | no |
62
+ | `defaultValue`, moved away from it | yes |
63
+ | nothing | no |
64
+
65
+ The rule behind the table: **a value is an answer, a default is a suggestion.**
66
+ An age that is usually 18, a duration that is usually 1h30 — the form holds
67
+ nothing there, and confirming the suggestion IS an answer ("yes, 18"), which
68
+ must be sendable. A bound signal falls on whichever side its content puts it: a
69
+ signal restored from the url or set by whoever fills the screen carries an
70
+ answer, even when the signal also declares a default (its default only says
71
+ where a reset goes back to).
72
+
73
+ The same question is asked of a single control by `isUIStateHeld`
74
+ (`src/control/held_ui_state.js`) — a form asks it once per field.
75
+
76
+ ## A screen filled after it opened: `pristineKey`
77
+
78
+ The baseline is taken as soon as the fields have registered, which is right for
79
+ a form whose values are there on the first render — and wrong for a screen that
80
+ modifies something: the resource arrives a request later and fills the fields,
81
+ so a form measured against what it held BEFORE that opens **already changed**.
82
+ Its submit is live, and pressing it sends back the resource untouched.
83
+
84
+ `pristineKey` takes the baseline again. Pass whatever says the filling is done:
85
+
86
+ ```jsx
87
+ <Form pristineKey={game && players && places ? "loaded" : undefined}>
88
+ ```
89
+
90
+ Change it **once**, when the screen is ready. Taken again after someone started
91
+ typing, it would call what they wrote the reference.
92
+
93
+ Do not use a `key` on the `<Form>` for this: it remounts every control and every
94
+ popup inside it, and anything half-typed goes with them.
95
+
96
+ ## A submit that says it is waiting
97
+
98
+ By default a submit that sends nothing is still accepted — in a dialog or a
99
+ slide it closes / moves on all the same. In a form that goes nowhere on its own,
100
+ the press would visibly do nothing; `readOnlyWhileFormUnchanged` on the button
101
+ holds it back and says what it is waiting for.
102
+
103
+ ```jsx
104
+ <Button type="submit" readOnlyWhileFormUnchanged>
105
+ Save
106
+ </Button>
107
+ ```
108
+
109
+ ## A control that is not a field
110
+
111
+ A control inside a form is expected to carry a value under its name, and a
112
+ nameless one is warned about — its state would silently stay out of what is
113
+ sent. A control that only opens something (a picker whose popup draws a shape, a
114
+ row whose value is carried by a hidden input beside it) says so with
115
+ `allowNameless`:
116
+
117
+ ```jsx
118
+ <Picker allowNameless ui={…}>
119
+ ```
120
+
121
+ It is then neither collected nor complained about.
122
+
123
+ ## See also
124
+
125
+ - [control_value.md](./control_value.md) — who holds a control's value:
126
+ nothing, a bound `signal`, or you
127
+ - [control_object.md](./control_object.md) — one value made of several
128
+ controls: `ControlGroup`, `Form`, and a picker whose value is an object
129
+ - [actions.md](./actions.md) — what an action does around the send itself
@@ -67,9 +67,56 @@ way back is a re-read.
67
67
  `DELETE` is symmetric: returning the id drops the item from the store, and every
68
68
  list containing it drops it too.
69
69
 
70
+ ## A paginated list stays on screen too
71
+
72
+ A `<List.Items>` reading through `GET_RANGE` holds the slices it received —
73
+ places in a collection, not a list of ids — so nothing the store does can fix
74
+ them: a row that changed tab, or one that was deleted, moves every row after it
75
+ one rank up, and only the collection knows who fills the last place.
76
+
77
+ It is told, and it re-reads by itself:
78
+
79
+ ```jsx
80
+ <List.Items
81
+ count={count}
82
+ itemsAction={NOTIFICATION.GET_RANGE.bindParams({ scope })}
83
+ renderItem={(item, index, { refreshing }) => …}
84
+ />
85
+ ```
86
+
87
+ The reader keeps no value, so there is nothing to rerun; what it has is a
88
+ signal, bumped by the verbs `rerunOn.GET_RANGE` lists (`["POST", "DELETE"]` by
89
+ default — `DELETE` is in there precisely because the store cannot fix places).
90
+ A run hearing it asks again **for the window it is drawing**, and keeps drawing
91
+ it meanwhile:
92
+
93
+ | Moment | what the run draws | state |
94
+ | ------------------------------- | -------------------- | ------------ |
95
+ | nothing received yet | skeletons | loading |
96
+ | re-reading after a first answer | the rows from before | `refreshing` |
97
+ | answer received | the new rows | — |
98
+
99
+ The rows, the scroll position and the row being read all stay; the slices
100
+ outside the window are forgotten only once the answer is in, and asked for
101
+ again if the user goes back to them. A re-read that fails leaves the rows from
102
+ before on screen. While it is in flight, the list carries `navi-refreshing` and
103
+ `renderItem` gets `{ refreshing }` — read it as "what you see is from before",
104
+ never as "there is nothing to see".
105
+
106
+ An app that knows a row is on its way out (it is the one deleting it) says so
107
+ itself: it is the one rendering the row, so it draws it loading, muted, or not
108
+ at all. The run is not told about rows, only about the collection.
109
+
110
+ ```jsx
111
+ // ✗ remounting the run to refresh it: every row on screen becomes a skeleton
112
+ // again, and the list reopens where it opens, not where it was being read
113
+ <List.Items key={`${scope}:${moved}`} … />
114
+ ```
115
+
70
116
  ## `rerunOn`, verb by verb
71
117
 
72
- `rerunOn` says which verbs invalidate this resource's `GET` / `GET_MANY`:
118
+ `rerunOn` says which verbs invalidate this resource's `GET` / `GET_MANY` /
119
+ `GET_RANGE`:
73
120
 
74
121
  ```js
75
122
  const GAME_RADAR = resource("game_radar", {
@@ -78,12 +125,13 @@ const GAME_RADAR = resource("game_radar", {
78
125
  });
79
126
  ```
80
127
 
81
- Defaults are `{ GET: false, GET_MANY: ["POST"] }`:
128
+ Defaults are `{ GET: false, GET_MANY: ["POST"], GET_RANGE: ["POST", "DELETE"] }`:
82
129
 
83
- | Default | Why |
84
- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
- | `GET: false` | `PUT`/`PATCH` already update the UI through the store; `DELETE` resets the `GET` rather than re-running it, so a deleted item shows nothing instead of a spinner then a 404. Give the deleted case its own UI (an "item not found" panel, a redirect) instead of `GET: ["DELETE"]`. |
86
- | `GET_MANY: ["POST"]` | Whether a new item belongs in this list depends on filters, pagination, sort — the backend knows, the client does not. `DELETE` is excluded because the store already removes the item from every list. |
130
+ | Default | Why |
131
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
132
+ | `GET: false` | `PUT`/`PATCH` already update the UI through the store; `DELETE` resets the `GET` rather than re-running it, so a deleted item shows nothing instead of a spinner then a 404. Give the deleted case its own UI (an "item not found" panel, a redirect) instead of `GET: ["DELETE"]`. |
133
+ | `GET_MANY: ["POST"]` | Whether a new item belongs in this list depends on filters, pagination, sort — the backend knows, the client does not. `DELETE` is excluded because the store already removes the item from every list. |
134
+ | `GET_RANGE: ["POST", "DELETE"]` | A slice is a range of places: a row leaving the collection shifts every place after it, which the store cannot do. Add the verb that moves an item in or out of the collection — a `PATCH` that archives, one that changes an item's tab. |
87
135
 
88
136
  Adding `PUT`/`PATCH` to `GET_MANY` is the usual over-correction: it costs a
89
137
  request and a `loading` pass to obtain something the response already contained.
@@ -92,12 +140,13 @@ updated item — not client-side refreshing.
92
140
 
93
141
  ## Decision table
94
142
 
95
- | What changed | Re-read the list? |
96
- | ------------------------- | ---------------------------------------------------- |
97
- | a field of one item | no — the write's response is enough |
98
- | membership of the list | yes (`POST`) — the backend decides who belongs |
99
- | the ORDER of the list | yes — the store stores, it does not sort (see below) |
100
- | nothing came back (`204`) | yes — there is nothing to put in the store |
143
+ | What changed | Re-read the list? |
144
+ | --------------------------- | ---------------------------------------------------- |
145
+ | a field of one item | no — the write's response is enough |
146
+ | membership of the list | yes (`POST`) — the backend decides who belongs |
147
+ | the ORDER of the list | yes — the store stores, it does not sort (see below) |
148
+ | nothing came back (`204`) | yes — there is nothing to put in the store |
149
+ | a place in a paginated list | yes — a `GET_RANGE` reads places, and places shift |
101
150
 
102
151
  ## The store stores, it does not sort
103
152
 
package/docs/scroll.md CHANGED
@@ -226,16 +226,66 @@ All slides live in **the same grid cell**, so the box measures itself on the
226
226
  between slides — and it also means a short slide shows empty room below it. It
227
227
  is a trade, not a leak.
228
228
 
229
- `SlideContainer` is `flex: 0 1 auto`: it shrinks (the slides then scroll their
230
- own body) but never grows on its own. Growing is the caller's decision
231
- `expandY`.
229
+ **The slide IS the body.** This is the one thing to get right, and the shape
230
+ everyone writes first gets it wrong: a `Dialog` with a `<Box body>` around the
231
+ slides puts a scroller ABOVE them, and that scroller's content is the grid —
232
+ measured on the tallest slide. Stand on a short slide and it carries the
233
+ scrollbar of a neighbour, scrolling through emptiness.
234
+
235
+ ```jsx
236
+ // WRONG — the dialog's body scrolls the tallest slide, on every slide
237
+ <Dialog maxHeight="min(80vh, 640px)">
238
+ <Box header>tabs</Box>
239
+ <Box body>
240
+ <SlideContainer>
241
+ <Slide padding="l">…</Slide>
242
+ </SlideContainer>
243
+ </Box>
244
+ </Dialog>
245
+
246
+ // RIGHT — the cap stays a constraint, each slide scrolls its own content
247
+ <Dialog maxHeight="min(80vh, 640px)" flex="y">
248
+ <Box header flexShrink="0">tabs</Box>
249
+ <SlideContainer>
250
+ <Slide overflow="auto">
251
+ <Box header padding="m">…</Box> {/* the slide scrolls: padding on the parts */}
252
+ <Box body padding="l">…</Box>
253
+ </Slide>
254
+ </SlideContainer>
255
+ </Dialog>
256
+ ```
257
+
258
+ Why it then behaves: the cap on the height comes from above and must reach the
259
+ slides as a **constraint**, never as a scroller. `SlideContainer` is
260
+ `flex: 0 1 auto` — it shrinks into what is left (growing is the caller's
261
+ decision, `expandY`) — the grid hands that height to **every** slide, and a
262
+ slide with an `overflow` of its own scrolls only when ITS content is taller than
263
+ that. The tall slide scrolls; the short ones are tall boxes with a short content
264
+ in them, which is what one wants: they take the height the context imposes and
265
+ ignore the height of their neighbour.
266
+
267
+ So: nothing scrollable between the cap and the slides. A `<Box body>` around
268
+ them is a scroller (see the table at the top of this file) — and so is a bare
269
+ `overflow="auto"` on a wrapper. The dialog keeps a shared `header` if the tabs
270
+ are shared, with an explicit `flexShrink="0"` since the rule that gives it for
271
+ free lives inside `[data-scrollable]`.
272
+
273
+ **Padding goes on the slide** — or on its parts, since the slide is now the
274
+ scroller (see the top of this file) — but never on the container nor on
275
+ anything above it.
276
+ Overflow clips at the _padding_ edge, so a padding on the container is a band
277
+ the clipping does not cover: the arriving slide is seen there before it has
278
+ reached the frame. And a padding above the slides does not travel — the two
279
+ contents cross each other flush, instead of each arriving already inset. On the
280
+ slide, the inset travels with what it insets.
232
281
 
233
282
  Pass `travelByKeyboard={false}` when the arrow keys belong to the content (a list
234
283
  one walks through, a picker whose slides are steps): otherwise the right arrow
235
284
  changes screen mid-reading.
236
285
 
237
286
  Reference: `src/layout/dialog.jsx`, `src/layout/popover.jsx`,
238
- `src/layout/slide_container.jsx`.
287
+ `src/layout/slide_container.jsx`, and the "One slide much taller than the
288
+ others" case in `src/layout/demos/8_slide_container_demo.html`.
239
289
 
240
290
  ## Hover while scrolling
241
291
 
package/docs/z_index.md CHANGED
@@ -97,6 +97,47 @@ What to read from it:
97
97
  `isolation: isolate` on the parent instead, and the number stops meaning
98
98
  anything outside it.
99
99
 
100
+ ### A sticky part is only in the band while it is stuck
101
+
102
+ `--navi-z-index-sticky` says "kept stuck while something scrolls under it", and
103
+ the second half of that sentence is a condition, not decoration. A `List` group
104
+ label at rest is a block in the flow: nothing passes under it, and painting it
105
+ at 10 there is what slices a focus ring, a badge or a stamp that a neighbouring
106
+ row lets out of its box — including a `Group` member raising itself to 1 or 2.
107
+
108
+ CSS cannot express the condition. There is no `:stuck`, and
109
+ `@container scroll-state(stuck: top)` styles a container's **descendants**, so
110
+ an element can never read its own stuck state — which is the one that matters
111
+ here. So `List` measures it and marks its three sticky parts
112
+ (`.navi_list_item_header`, `.navi_list_item_footer`,
113
+ `.navi_list_item_group_label`) with `navi-stuck`, against its own scroller and
114
+ not the window, updated on scroll, on resize, and on every commit (a virtualized
115
+ list changes which labels exist without anything scrolling).
116
+
117
+ navi's own rule is the first reader: the band applies under `[navi-stuck]`,
118
+ `auto` applies at rest. That is what puts the decision back within reach of an
119
+ app — a card whose badge overflows into the label below it gets past it with a
120
+ literal in the card, against its own neighbour, exactly as §1–3 above ask:
121
+
122
+ ```jsx
123
+ // Reaches past a label at rest (auto), loses to one that is stuck (10).
124
+ <Stamp style={{ position: "absolute", bottom: "-12px", zIndex: 1 }} />
125
+ ```
126
+
127
+ There was no such value before: a label sat at 10 whether or not it was stuck,
128
+ so nothing a card could write got past it.
129
+
130
+ For what a literal cannot reach, each part has a pair of variables —
131
+ `--list-header-z-index`, `--list-footer-z-index`, `--list-group-label-z-index`,
132
+ each with a `-stuck` counterpart defaulting to the band — settable on `<List>`
133
+ with no selector and no navi class name. Reach for them last, and remember a
134
+ negative value is compared against the page like any other: without a stacking
135
+ context between the label and the nearest opaque background, `-1` does not put
136
+ the label behind the rows, it puts it behind that background and out of sight.
137
+
138
+ See the "Sticky parts" chapter of
139
+ [12_list_demo.html](../src/control/demos/12_list_demo.html).
140
+
100
141
  ### Why a `Group` member is not isolated
101
142
 
102
143
  `Group` overlaps its members by one border width, so the one the user is on has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.42",
3
+ "version": "0.29.44",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {