@jsenv/navi 0.29.43 → 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.
@@ -69,8 +69,10 @@ consistency across the app, not from any single call site.
69
69
  `docs/resource_with_params.md`, `docs/resource_dependencies.md`.
70
70
  - `docs/list_refresh.md` — what a write sends back to the network and what stays
71
71
  on screen meanwhile: stale data returned by `useAsyncData({ loading: true })`,
72
- what updates from a response without any request, `rerunOn` and its defaults.
73
- Read it before adding verbs to `rerunOn` or hiding a list on `loading`.
72
+ what updates from a response without any request, `rerunOn` and its defaults,
73
+ and how a paginated `<List.Items>` re-reads its slices without disappearing.
74
+ Read it before adding verbs to `rerunOn`, hiding a list on `loading`, or
75
+ remounting a list with a `key` to refresh it.
74
76
  - `docs/css_architecture.md` — how Navi's CSS layering works, and the
75
77
  supported ways to override component styles (props > CSS variables > direct
76
78
  rule overrides, in that preference order).
@@ -90,6 +92,11 @@ consistency across the app, not from any single call site.
90
92
  `signal` carrying something is an answer). Read it before a screen that
91
93
  modifies an existing resource (`pristineKey`), and before reaching for
92
94
  `canSendWhileUnchanged` because a submit "does nothing".
95
+ - `docs/control_object.md` — a value made of several controls: `<ControlGroup>`
96
+ (the shape) vs `<Form>` (the shape plus a send), what a group's `name` does
97
+ and what a nameless one merges, and what a `<Picker type="object">` needs in
98
+ its popup (one group, not two controls). Read it before making one value out
99
+ of several controls, and before putting anything in a picker popup.
93
100
  - `docs/control_group.md` — `<Group>`: several controls reading as one framed
94
101
  object (one border per seam, radius on the outer corners only). Read it
95
102
  before placing bordered controls against each other, and before writing
@@ -130,6 +130,11 @@ The controls shipped by navi all follow this: `Button`, `Input`, `Select`,
130
130
  - Controls separated by space, each with its own frame — that is a `Box` with
131
131
  `spacing`, they were never one object.
132
132
  - A label and its control — that is `Field`.
133
+ - Several controls making ONE value between them (an address out of three
134
+ fields, a day and two wheels out of one moment) — that is `ControlGroup`,
135
+ which is about the value and draws nothing. Same word, other subject: see
136
+ [control_object.md](./control_object.md). Both at once is fine — a `Group`
137
+ around the members of a `ControlGroup`.
133
138
  - Radio buttons or checkboxes sharing a name and a validation — that is
134
139
  `RadioGroup` / `CheckboxGroup`, which is about the value, not the frame. They
135
140
  can be put inside a `Group` if you also want them to share a frame.
@@ -0,0 +1,133 @@
1
+ # A value made of several controls
2
+
3
+ One question, answered by more than one control: a day and two wheels that make
4
+ "mardi 19h", three fields that make an address, two wheels that add up to a
5
+ number of minutes. Navi has one mechanism for that — a group aggregates what its
6
+ children hold into a single value, and hands it back the same way — and three
7
+ places it shows up. This file says which one to reach for, and what a picker
8
+ whose value is an object needs in its popup.
9
+
10
+ - [`<ControlGroup>`: the shape](#controlgroup-the-shape)
11
+ - [`<Form>`: the shape, plus a send](#form-the-shape-plus-a-send)
12
+ - [Naming, and what a nameless group does](#naming-and-what-a-nameless-group-does)
13
+ - [A picker whose value is an object](#a-picker-whose-value-is-an-object)
14
+ - [`Group` is not `ControlGroup`](#group-is-not-controlgroup)
15
+
16
+ ## `<ControlGroup>`: the shape
17
+
18
+ A group with no opinion beyond the shape of its value: it aggregates its named
19
+ children into an object, distributes an object back down to them, and carries
20
+ its own `action`/`uiAction`/`command` if you want one.
21
+
22
+ ```jsx
23
+ <ControlGroup name="address">
24
+ <Input name="street" />
25
+ <Input name="city" />
26
+ <Input name="zip" />
27
+ </ControlGroup>
28
+ // worth { street, city, zip }
29
+ ```
30
+
31
+ It is also the brick composite controls are built from:
32
+ `aggregateChildStates` / `distributeChildUIState` replace "one key per child"
33
+ with a value of your own — two wheels that are one number of minutes, three
34
+ fields that are one date. A group with them takes and hands back a single
35
+ value, so it can be driven by one `value`/`signal` like any other control. It is
36
+ what `SpinTime` is made of, and the same mechanism `InputDuration`, `SpinGroup`
37
+ and `WheelGroup` use for their own members.
38
+
39
+ ## `<Form>`: the shape, plus a send
40
+
41
+ A `<Form>` aggregates exactly the same way. What it adds is everything about
42
+ **sending**: the reference it measures against, the refusal to act when nothing
43
+ changed (see [form_changed.md](./form_changed.md)), the submit button and its
44
+ `readOnlyWhileFormUnchanged`, what follows a successful send (`command`), and a
45
+ `<form>` element with the browser's own submit/reset.
46
+
47
+ So the choice is not about the value, it is about whether this cluster is a
48
+ **question with a send**:
49
+
50
+ - a shape inside a bigger whole → `ControlGroup`;
51
+ - something the user sends → `Form`.
52
+
53
+ A `<Form>` inside a `<Form>` is legal — the inner one becomes a group without
54
+ the `<form>` element — but it carries all of the above with it: its own
55
+ reference, its own "nothing changed", its own submit story. Grouping three
56
+ fields into a sub-object should cost none of that.
57
+
58
+ ## Naming, and what a nameless group does
59
+
60
+ A group's `name` is the key its value lands under, in the group above it. This
61
+ is true of a `ControlGroup` and of a nested `Form` alike.
62
+
63
+ Left nameless, a group is a **grouping**: it exists to hold its children
64
+ together (shared navigation, a visual cluster) without claiming a key, and what
65
+ it holds is merged into the object around it as if its children had been written
66
+ there.
67
+
68
+ ```jsx
69
+ <ControlGroup name="when">
70
+ <DaySpin name="day" />
71
+ <WheelGroup>
72
+ <Wheel name="hours" />
73
+ <Wheel name="minutes" />
74
+ </WheelGroup>
75
+ </ControlGroup>
76
+ // worth { day, hours, minutes } — the WheelGroup adds no key of its own
77
+ ```
78
+
79
+ A nameless **leaf** is a different story: it is a control whose value has
80
+ nowhere to go, and it is warned about. When that is deliberate — a control that
81
+ only opens something — say so with `allowNameless` (see
82
+ [form_changed.md](./form_changed.md#a-control-that-is-not-a-field)).
83
+
84
+ ## A picker whose value is an object
85
+
86
+ `type="object"` is the picker whose value is what the group in its popup
87
+ aggregates (next to `type="array"`, whose value is a selection):
88
+
89
+ ```jsx
90
+ <Picker name="when" type="object" value={{ day, hours, minutes }}>
91
+ <ControlGroup>
92
+ <DaySpin name="day" signal={daySignal} />
93
+ <WheelGroup>
94
+ <Wheel name="hours" signal={hoursSignal} />
95
+ <Wheel name="minutes" signal={minutesSignal} />
96
+ </WheelGroup>
97
+ </ControlGroup>
98
+ </Picker>
99
+ ```
100
+
101
+ Two things to get right:
102
+
103
+ - **One control in the popup, and it must be the group.** A picker syncs with a
104
+ single control: the first one receives the picker's whole value and is the
105
+ only one read back. Two controls side by side in a popup is the shape to
106
+ avoid — the second is neither filled nor collected, and navi says so in dev.
107
+ Wrap them in one `ControlGroup` (or one `Form`, when the popup has a send of
108
+ its own).
109
+ - **The value travels by name.** What the picker was given goes down into the
110
+ group, which hands each named child its own key; a nameless grouping inside
111
+ receives the whole object and picks out what it names. Give a picker of this
112
+ kind a scalar type and there is no group to distribute anything — the whole
113
+ object lands on one control, which is how `"[object Object]"` ends up in a
114
+ url.
115
+
116
+ ## `Group` is not `ControlGroup`
117
+
118
+ Two names, two subjects, no relation:
119
+
120
+ - `<Group>` is about the **frame** — several controls reading as one object to
121
+ the eye, one border per seam. See [control_group.md](./control_group.md).
122
+ - `<ControlGroup>` is about the **value** — several controls reading as one
123
+ object to the action.
124
+
125
+ They compose: a `Group` around controls that also happen to be a
126
+ `ControlGroup`'s children is fine, and either can exist without the other.
127
+
128
+ ## See also
129
+
130
+ - [form_changed.md](./form_changed.md) — what a form sends, and what it measures
131
+ against
132
+ - [control_value.md](./control_value.md) — who holds a single control's value
133
+ - [control_group.md](./control_group.md) — the visual `<Group>`
@@ -52,6 +52,23 @@ Both halves are worth knowing about, because each replaces a habit:
52
52
  - the follow replaces the `key` or the `value`/`uiAction` pair used to push an
53
53
  outside change into a control.
54
54
 
55
+ The follow goes all the way up: a bound control that lives inside a group — two
56
+ wheels in a `WheelGroup`, a field in a `ControlGroup` — makes that group
57
+ re-aggregate when its signal is written, and the form above sees the new value.
58
+ A shortcut that pushes the controls from the outside is an answer like any
59
+ other: the wheels roll, and the submit lights up.
60
+
61
+ ```jsx
62
+ <Button
63
+ onClick={() => {
64
+ hoursSignal.value = 2;
65
+ minutesSignal.value = 0;
66
+ }}
67
+ >
68
+ 2h
69
+ </Button>
70
+ ```
71
+
55
72
  ## `signal` + `defaultValue`: the answer and where it starts
56
73
 
57
74
  They are not competing, they answer two different questions:
@@ -126,6 +143,8 @@ directions; it just has nothing extra to say.
126
143
 
127
144
  - [form_changed.md](./form_changed.md) — what a form makes of each of these:
128
145
  which fields it counts as already answered, and when it sends nothing
146
+ - [control_object.md](./control_object.md) — several controls reading as one
147
+ object: which group aggregates them, and how the value travels down by name
129
148
  - [control_group.md](./control_group.md) — several controls reading as one
130
149
  framed object
131
150
  - [actions.md](./actions.md#action-or-uiaction) — `action` or `uiAction`: which
@@ -124,4 +124,6 @@ It is then neither collected nor complained about.
124
124
 
125
125
  - [control_value.md](./control_value.md) — who holds a control's value:
126
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
127
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.43",
3
+ "version": "0.29.44",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {