@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.
- package/dist/jsenv_navi.js +655 -250
- package/dist/jsenv_navi.js.map +51 -28
- package/dist/jsenv_navi_side_effects.js +5 -0
- package/dist/jsenv_navi_side_effects.js.map +2 -2
- package/docs/AI_INSTRUCTIONS.md +15 -2
- package/docs/control_group.md +5 -0
- package/docs/control_object.md +133 -0
- package/docs/control_value.md +21 -0
- package/docs/form_changed.md +129 -0
- package/docs/list_refresh.md +61 -12
- package/docs/scroll.md +54 -4
- package/docs/z_index.md +41 -0
- package/package.json +1 -1
|
@@ -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
|
package/docs/list_refresh.md
CHANGED
|
@@ -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
|
|
84
|
-
|
|
|
85
|
-
| `GET: false`
|
|
86
|
-
| `GET_MANY: ["POST"]`
|
|
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
|
|
96
|
-
|
|
|
97
|
-
| a field of one item
|
|
98
|
-
| membership of the list
|
|
99
|
-
| the ORDER of the list
|
|
100
|
-
| nothing came back (`204`)
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|