@jsenv/navi 0.29.43 → 0.29.45

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,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
@@ -120,6 +120,71 @@ Note that scoping to an ancestor is not enough: `.my-sidebar { --link-color-pres
120
120
 
121
121
  When a component default deserves to be themed globally, promote it: declare a `--navi-<component>-<thing>` in [navi_css_vars.js](../src/navi_css_vars.js) and make the component default read `var(--navi-…)`.
122
122
 
123
+ #### An app narrower than the screen
124
+
125
+ An app that never spans the whole window — a phone-shaped column centered in a
126
+ wide one, bands on the sides — has one problem with popups: a dialog lives in
127
+ the browser's top layer, so it is calibrated on the _viewport_, and would paint
128
+ 1500px of modal over a 600px app. The top bar and the bottom nav have the same
129
+ problem and solve it by repeating the app width by hand; popups must not need
130
+ that, because the app would then have to know which components exist.
131
+
132
+ So the app states its own screen once, and never names a component:
133
+
134
+ ```css
135
+ :root {
136
+ --navi-app-max-width: 600px;
137
+ /* --navi-app-max-height too, for an app that also caps its height */
138
+ }
139
+ ```
140
+
141
+ In pixels: popup placement reads this value back from CSS to compute its own
142
+ margins, and a custom property computes to a token stream rather than to a
143
+ length, so `40rem` would arrive there as the string `"40rem"`. A non-px value
144
+ still caps the popup's size (that part is pure CSS) but leaves the margins
145
+ viewport-sized, and says so in the console.
146
+
147
+ Every popup follows: `Dialog`, `Popover`, and everything built on them
148
+ (`Picker`, `Select`…). It is a ceiling and nothing more — on a screen narrower
149
+ than the app it never binds, and each popup still subtracts its own
150
+ `marginWithContainer` from it, so the gap with the edges is kept either way.
151
+ That gap is itself a share of the app's screen, not of the window (`"3appw"`,
152
+ navi's own unit alongside `vvw`/`vvh`) — otherwise a 3% margin measured on a
153
+ 1500px window would eat 90px out of a 600px app.
154
+
155
+ Do **not** try to get this by setting `--dialog-max-width` on `.navi_dialog`
156
+ from the app. Two reasons:
157
+
158
+ - it is a `--component-*` token, declared on the element (see the table above),
159
+ so components that write it themselves outrank an app rule of lower
160
+ specificity — `.navi_picker[aria-haspopup="dialog"] .navi_dialog` does exactly
161
+ that, and the app's cap silently disappears for every picker;
162
+ - it is the knob a single popup uses to ask for a specific size, not a ceiling.
163
+ `--navi-app-max-width` feeds `--dialog-maxmax-width`, the hard ceiling _under_
164
+ that knob, so a popup that genuinely needs its own `maxWidth` can still say so
165
+ without any of them escaping the app's screen.
166
+
167
+ ##### Current limitations
168
+
169
+ `--navi-app-max-width` caps how big a popup may get; it does not move where one
170
+ is placed. Placement is still computed against the real viewport
171
+ (`pickPositionRelativeTo`, in `@jsenv/dom`). That is invisible for anything
172
+ centered on its cross axis — `center`, `bottom`, `top`, which is what a dialog
173
+ does nearly always — but shows for anything anchored to an edge: a
174
+ `positionArea` like `bottom-start`, a `SidePanel`, a fixed bar. Those sit
175
+ against the window's edge rather than the app column's, so they stay on the real
176
+ viewport for now (`side_panel.jsx` restates `--dialog-maxmax-width` as the full
177
+ viewport on purpose).
178
+
179
+ Making them follow the app column too means narrowing the container rect
180
+ placement is computed against, inside `pickPositionRelativeTo` — worth doing the
181
+ day a side panel or a fixed bar has to live inside a simulated screen.
182
+
183
+ Note that an app can already get all of it, placement included, by rendering
184
+ itself in an iframe of the target width: the viewport then genuinely _is_ the
185
+ app's screen and no token is needed at all. `--navi-app-max-width` is the answer
186
+ for an app that does not want to pay that price.
187
+
123
188
  ### 3. Direct rule override (avoid unless necessary)
124
189
 
125
190
  Overriding the actual CSS rules (not the variables) is intentionally hard — that is by design. If you find yourself needing to do this, it usually means a CSS variable should be exposed for that property. Open an issue or add the variable yourself and contribute it back.
@@ -133,4 +198,5 @@ Overriding the actual CSS rules (not the variables) is intentionally hard — th
133
198
  | One component instance | Component prop or `style` attribute |
134
199
  | All instances of a component | `--component-*` in unlayered app CSS, on a selector matching the component |
135
200
  | A global design token | `--navi-*` on `:root` |
201
+ | How wide popups may ever get | `--navi-app-max-width` on `:root` |
136
202
  | A structural layout rule | Expose a new CSS variable (contribute) |
@@ -7,6 +7,7 @@ as an answer the form already holds, and what to do on a screen whose fields are
7
7
  filled a request later.
8
8
 
9
9
  - [Sending nothing is the default](#sending-nothing-is-the-default)
10
+ - [What follows a send](#what-follows-a-send)
10
11
  - [What the form is measured against](#what-the-form-is-measured-against)
11
12
  - [What counts as already held](#what-counts-as-already-held)
12
13
  - [A screen filled after it opened: `pristineKey`](#a-screen-filled-after-it-opened-pristinekey)
@@ -31,6 +32,50 @@ duplicates are fine.
31
32
  <Form action={notify} canSendWhileUnchanged>
32
33
  ```
33
34
 
35
+ ## What follows a send
36
+
37
+ The form has answered its question; `command` says what the screen does about
38
+ it — dismiss the popup (`--navi-close`), move on the slide map
39
+ (`--navi-left`…), go to a page (`--navi-nav-to:/games/42`), stay put
40
+ (`--navi-void`). Left out, the surface the form sits in decides: a popup closes,
41
+ a slide goes on, a form on a page does nothing.
42
+
43
+ It runs **whether or not there was anything to send** — that is the other half
44
+ of the rule above: the person is done either way, and a submit that ran no
45
+ action still closes the popup, still moves on, still navigates. Which is why
46
+ this is a prop, decided before the send: the form has to know where it goes even
47
+ when nothing happened.
48
+
49
+ Nothing runs when the send fails, or when a constraint refuses it. The form then
50
+ stays in front of the person, showing what it is waiting for.
51
+
52
+ ### When only the response knows where to go
53
+
54
+ A creation lands on the page the server just made, and its id comes back with
55
+ the response — too late for a prop. Do it in the action, which is where the
56
+ answer is:
57
+
58
+ ```jsx
59
+ <Form
60
+ action={async (value) => {
61
+ const game = await createGame(value);
62
+ navTo(`/games/${game.id}`);
63
+ }}
64
+ >
65
+ ```
66
+
67
+ Nothing to declare: a creation always has something to send, so there is no
68
+ "the press did nothing" case for `command` to cover.
69
+
70
+ If you would rather it go through the command machinery all the same (to reuse
71
+ whatever a command does on that surface), the form carries what follows the send
72
+ as `data-after-send`, read once the send has succeeded — so an action can write
73
+ it while it runs:
74
+
75
+ ```js
76
+ formRef.current.setAttribute("data-after-send", `--navi-nav-to:/games/${id}`);
77
+ ```
78
+
34
79
  ## What the form is measured against
35
80
 
36
81
  One value, called the baseline here: **what the form held the last time it had
@@ -90,6 +135,11 @@ Its submit is live, and pressing it sends back the resource untouched.
90
135
  Change it **once**, when the screen is ready. Taken again after someone started
91
136
  typing, it would call what they wrote the reference.
92
137
 
138
+ No need to delay it by a tick: the reference is taken when the fields have
139
+ settled, and again at the end of that same tick — so a row that arrives in a
140
+ render of its own (a value computed from signals, a memoized row) is part of it
141
+ without the screen having to know which of its fields settle late.
142
+
93
143
  Do not use a `key` on the `<Form>` for this: it remounts every control and every
94
144
  popup inside it, and anything half-typed goes with them.
95
145
 
@@ -124,4 +174,6 @@ It is then neither collected nor complained about.
124
174
 
125
175
  - [control_value.md](./control_value.md) — who holds a control's value:
126
176
  nothing, a bound `signal`, or you
177
+ - [control_object.md](./control_object.md) — one value made of several
178
+ controls: `ControlGroup`, `Form`, and a picker whose value is an object
127
179
  - [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.45",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {