@jsenv/navi 0.29.417 → 0.29.418
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/dev/jsenv_navi.js +155 -40
- package/dist/dev/jsenv_navi.js.map +3 -3
- package/dist/jsenv_navi.js +155 -40
- package/dist/jsenv_navi.js.map +3 -3
- package/docs/AI_INSTRUCTIONS.md +5 -2
- package/docs/drag_interactions.md +297 -401
- package/docs/drag_to_travel.md +157 -754
- package/docs/interactions.md +170 -328
- package/docs/route_transitions.md +9 -0
- package/docs/scroll.md +23 -5
- package/docs/state_binding.md +39 -84
- package/package.json +1 -1
|
@@ -669,6 +669,15 @@ elements):
|
|
|
669
669
|
container over its rows (`.navi_list_container:has(.card) …`), evaluated
|
|
670
670
|
again as the rows land. An attribute the app sets on the container says the
|
|
671
671
|
same thing as that `:has()`, for nothing.
|
|
672
|
+
- **Building rows the first picture does not show.** A list coming back
|
|
673
|
+
where it was draws, in the update callback, the rows that were on screen
|
|
674
|
+
then — the `visibleCount` of the position it is handed back (see
|
|
675
|
+
[scroll.md](./scroll.md#where-the-list-opens-and-where-it-is)) — and the
|
|
676
|
+
rest of its window once the movement has started. The movement does not wait
|
|
677
|
+
for them: it plays on the compositor, which keeps presenting frames while the
|
|
678
|
+
main thread builds the rows. On the list of thirty cards, handed its whole
|
|
679
|
+
position back: the movement starts 70–85 ms after the click instead of
|
|
680
|
+
185–270.
|
|
672
681
|
- **Re-rendering what did not change, on the press itself.** A component that
|
|
673
682
|
only wants a yes or a no reads a computed flag, not the signal it is derived
|
|
674
683
|
from: a list whose rows each read the count of runs in flight re-rendered
|
package/docs/scroll.md
CHANGED
|
@@ -157,13 +157,25 @@ picked up then. When it is still not the box you mean, say so explicitly with
|
|
|
157
157
|
- **`scrolled`** is the controlled form of the same thing — same pair as
|
|
158
158
|
`open`/`defaultOpen` elsewhere in navi. The list goes back there every time it
|
|
159
159
|
changes, even after the user scrolled.
|
|
160
|
-
- **`onScrolledChange`** gives `{id, index, offset}` as the user
|
|
160
|
+
- **`onScrolledChange`** gives `{id, index, offset, visibleCount}` as the user
|
|
161
|
+
scrolls. `visibleCount` is how many rows were on screen from that one on:
|
|
162
|
+
handed back whole (`defaultScrolled={position}`), the position also says what
|
|
163
|
+
to draw first — the list's first commit draws those rows (and the one above,
|
|
164
|
+
when it stood partly in view) and the rest of its window once painted. An app
|
|
165
|
+
keeping only the `id` (in its url, say) keeps the place and drops that: the
|
|
166
|
+
list then draws its whole window before its first picture.
|
|
161
167
|
- A list with an `id` **comes back where it was** when its screen is left and
|
|
162
168
|
come back to, the way the page does: the position is kept under the list's
|
|
163
169
|
`id` and the page's url, for the session, and put back by name the way
|
|
164
|
-
`defaultScrolled={{id, offset}}` is
|
|
165
|
-
|
|
170
|
+
`defaultScrolled={{id, offset}}` is — `visibleCount` included, and kept even
|
|
171
|
+
when the list was never scrolled. A fresh arrival at the page still opens at
|
|
172
|
+
`defaultScrolled`. **`scrollResetOnNavigation`** opts out: the list then
|
|
166
173
|
opens the same way every time.
|
|
174
|
+
- A list scrolling the document and opening on a row **places the document
|
|
175
|
+
itself**, on the row: the offset the page kept for its url is not put back
|
|
176
|
+
over it. Those are pixels of the page as it was drawn, and the rows the
|
|
177
|
+
window holds off screen are fillers of an estimated height — the same pixels
|
|
178
|
+
now fall on other rows.
|
|
167
179
|
|
|
168
180
|
### A search moves the list, and gives it back
|
|
169
181
|
|
|
@@ -390,10 +402,16 @@ scrolls: a `maxHeight` on a list that IS the scrolling area,
|
|
|
390
402
|
### The first paint of a list that opens in a click
|
|
391
403
|
|
|
392
404
|
A popup's content is built in the click that opens it (see `popup_open.md`),
|
|
393
|
-
and rows below the fold cost the same there as rows on screen.
|
|
405
|
+
and rows below the fold cost the same there as rows on screen. A page coming
|
|
406
|
+
back in a route transition is the same case: it is built in the transition's
|
|
407
|
+
update callback, and every row drawn there delays the movement. `renderBudget`
|
|
394
408
|
takes `{ initial, after }` for exactly this: `initial` rows in the commit the
|
|
395
409
|
browser paints first — what a phone screen shows, plus a few — and `after` from
|
|
396
|
-
the paint on.
|
|
410
|
+
the paint on. They are counted from the row the list opens on: the first
|
|
411
|
+
picture of a list opening on a row shows that row and what is below it, and
|
|
412
|
+
the window is centered on it only from the paint on. A position handed back
|
|
413
|
+
with its `visibleCount` sizes that first window itself, whatever `initial`
|
|
414
|
+
guessed. The switch waits for the paint itself, not for an effect: preact
|
|
397
415
|
runs a component's pending effects early when that component renders again,
|
|
398
416
|
and something always re-renders before a popup has painted. Do not rebuild
|
|
399
417
|
this by hand with a `useEffect` that widens a slice: that effect is one preact
|
package/docs/state_binding.md
CHANGED
|
@@ -32,8 +32,6 @@ was missed.
|
|
|
32
32
|
|
|
33
33
|
## The callback that only remembers
|
|
34
34
|
|
|
35
|
-
The same mistake, in four places:
|
|
36
|
-
|
|
37
35
|
```jsx
|
|
38
36
|
// ✗ the app is told where the slides went, and writes it back down
|
|
39
37
|
<SlideContainer
|
|
@@ -45,18 +43,6 @@ The same mistake, in four places:
|
|
|
45
43
|
// ✓
|
|
46
44
|
<SlideContainer signal={stepSignal} />
|
|
47
45
|
|
|
48
|
-
// ✗ the control is told what it now holds, and the app writes it back down
|
|
49
|
-
<Picker value={side} uiAction={(value) => (sideSignal.value = value)} />
|
|
50
|
-
// ✓
|
|
51
|
-
<Picker signal={sideSignal} />
|
|
52
|
-
|
|
53
|
-
// ✗ a button proposing a value to a control, straight into the signal
|
|
54
|
-
<Button onClick={() => (durationSignal.value = ninetyMinutes)}>1h30</Button>
|
|
55
|
-
// ✓
|
|
56
|
-
<Button command="--navi-update" commandFor="duration" value={ninetyMinutes}>
|
|
57
|
-
1h30
|
|
58
|
-
</Button>
|
|
59
|
-
|
|
60
46
|
// ✗ the popup is told it opened, and the app writes it back down
|
|
61
47
|
<Dialog
|
|
62
48
|
open={isOpen}
|
|
@@ -69,7 +55,8 @@ The same mistake, in four places:
|
|
|
69
55
|
|
|
70
56
|
In each pair the second line is not a tidier spelling of the first: it is the
|
|
71
57
|
binding the component already implements, and the first is a partial
|
|
72
|
-
re-implementation of it.
|
|
58
|
+
re-implementation of it. The same pair for a control's value, and for a button
|
|
59
|
+
proposing one, is in [control_value.md](./control_value.md).
|
|
73
60
|
|
|
74
61
|
## Why it is not merely shorter
|
|
75
62
|
|
|
@@ -77,22 +64,20 @@ A binding is written by **everything that moves the state**, not only by the
|
|
|
77
64
|
gesture the caller thought of. A `SlideContainer`'s signal is written by a drag,
|
|
78
65
|
by an arrow key, by a `--navi-right` command sent from a chevron outside the
|
|
79
66
|
box, by the address when the signal is a route's own, by a step refusing to be
|
|
80
|
-
left. A control's
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
added.
|
|
67
|
+
left. A control's signal is written by the user typing, by a group placing its
|
|
68
|
+
children, by a picker filling its popup or putting back what it held on Escape,
|
|
69
|
+
by a `--navi-update` from a shortcut button. A hand-written callback catches the
|
|
70
|
+
cases it was written for and silently misses the rest — and the drift only shows
|
|
71
|
+
on the screen where a second gesture was added.
|
|
86
72
|
|
|
87
73
|
It reads in the other direction too, which no callback gives at all: writing the
|
|
88
74
|
signal MOVES the component. The wheels roll, the slides travel, the popup opens.
|
|
89
75
|
That is what replaces the `key` or the `value` prop pushed down to force a
|
|
90
76
|
component into a state.
|
|
91
77
|
|
|
92
|
-
And
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
allowed to change.
|
|
78
|
+
And a `command` goes through navi's interaction gate, which an `onClick` writing
|
|
79
|
+
the same state does not (see
|
|
80
|
+
[control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
|
|
96
81
|
|
|
97
82
|
## What is left for the callback
|
|
98
83
|
|
|
@@ -113,13 +98,10 @@ Binding the state does not remove the callback — it removes one job from it:
|
|
|
113
98
|
## The callback also fires for the state's own changes
|
|
114
99
|
|
|
115
100
|
A bound container walks to whatever its signal holds, and tells the callback
|
|
116
|
-
about that walk — `cause: "state"`, `event: null`.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
flows back INTO the signal — a server whose response is upserted into the store,
|
|
121
|
-
a `useSignalSync` on the row that re-renders from it — makes every answer a
|
|
122
|
-
change, and every change a write:
|
|
101
|
+
about that walk — `cause: "state"`, `event: null`. The trap is a callback that
|
|
102
|
+
writes somewhere whose answer flows back INTO the signal — a server whose
|
|
103
|
+
response is upserted into the store, a `useSignalSync` on the row that
|
|
104
|
+
re-renders from it:
|
|
123
105
|
|
|
124
106
|
```jsx
|
|
125
107
|
// ✗ the answer of the write feeds the signal the write came from
|
|
@@ -130,49 +112,29 @@ const styleSignal = useSignalSync(user.share_image_style);
|
|
|
130
112
|
/>;
|
|
131
113
|
```
|
|
132
114
|
|
|
133
|
-
|
|
134
|
-
already holds
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
t=0 notch → B signal=B PUT B ──────────────┐
|
|
139
|
-
t=200 notch → A signal=A PUT A ────────────┐ │
|
|
140
|
-
t=300 B answers, store=B, signal=B, the box │ │
|
|
141
|
-
walks to B, onCurrentChange(B, "state"), │ │
|
|
142
|
-
save(B) → PUT B ───────────────────────┐ │ │
|
|
143
|
-
t=500 A answers … save(A) → PUT A │ │ │
|
|
144
|
-
t=600 B answers … save(B) → PUT B │ │ │
|
|
145
|
-
…
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Nothing was reordered — it is enough that an answer lands after a newer write
|
|
149
|
-
was sent. Every answer is then the answer to a request the user has already
|
|
150
|
-
moved past, the container obeys the model, the callback saves what it was just
|
|
151
|
-
told, and that write's answer starts the next turn.
|
|
115
|
+
One write at a time shows nothing: the answer carries the value the signal
|
|
116
|
+
already holds. Two in flight — two notches in quick succession — and every
|
|
117
|
+
answer is to a request the user has already moved past: the container walks to
|
|
118
|
+
it, the callback saves it, and that write's answer starts the next turn. The
|
|
119
|
+
loop never closes.
|
|
152
120
|
|
|
153
121
|
Decide what the signal IS:
|
|
154
122
|
|
|
155
123
|
- **The signal is the choice, the server only confirms it.** Seed it once with
|
|
156
|
-
`useSignal(model)` and never sync it
|
|
157
|
-
picture
|
|
158
|
-
|
|
159
|
-
|
|
124
|
+
`useSignal(model)` and never sync it: a late answer changes the store, not the
|
|
125
|
+
picture, and a write that fails is not undone on screen unless the callback
|
|
126
|
+
undoes it. Right for a setting saved on the gesture, with no "Save" button.
|
|
127
|
+
That is a `SlideContainer`'s shape; a control says it with
|
|
128
|
+
`defaultValue={model}` and an `action` (see
|
|
129
|
+
[control_value.md](./control_value.md#a-defaultvalue-follows-what-it-was-read-from)).
|
|
160
130
|
- **The model is the truth, the control only proposes.** Keep `useSignalSync`
|
|
161
|
-
and ignore `cause: "state"`
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
131
|
+
and ignore `cause: "state"` — a change the state asked for is not a choice to
|
|
132
|
+
save. A late answer still pulls the picture back for one round trip. Right
|
|
133
|
+
when another client or a background refresh may change the row while it is on
|
|
134
|
+
screen.
|
|
165
135
|
- **Never two writes in flight for one field.** Debounce the write, or cancel
|
|
166
|
-
the one before it (`createRequestCanceller`), so no answer
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Measured on the same screen, the same two notches, a 300ms round trip:
|
|
170
|
-
|
|
171
|
-
| what the signal is | writes sent | slide changes |
|
|
172
|
-
| ----------------------------------------- | --------------- | ------------- |
|
|
173
|
-
| `useSignalSync` + save every change | 39, still going | 39 |
|
|
174
|
-
| `useSignal`, seeded once | 2 | 2 |
|
|
175
|
-
| `useSignalSync` + ignore `cause: "state"` | 2 | 4 |
|
|
136
|
+
the one before it (`createRequestCanceller`), so no answer is older than the
|
|
137
|
+
latest intent. Right when the write is the expensive part.
|
|
176
138
|
|
|
177
139
|
## What binds itself
|
|
178
140
|
|
|
@@ -189,13 +151,10 @@ Measured on the same screen, the same two notches, a 300ms round trip:
|
|
|
189
151
|
|
|
190
152
|
"Who writes the score" has three answers — a player of the game, the organizer
|
|
191
153
|
alone, nobody — and only two of them are rows: "nobody" is a switch under the
|
|
192
|
-
list.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
The finding is elsewhere: **the list is not asking that question**. It asks
|
|
197
|
-
"which of these two", the switch asks "anybody at all", and the answer the
|
|
198
|
-
screen sends is made of both:
|
|
154
|
+
list. Bound to the state holding all three, the list shows nothing whenever the
|
|
155
|
+
switch is on. The fix is not `value` + `uiAction`: **the list is not asking that
|
|
156
|
+
question**. It asks "which of these two", the switch asks "anybody at all", and
|
|
157
|
+
the answer the screen sends is made of both:
|
|
199
158
|
|
|
200
159
|
```jsx
|
|
201
160
|
const scorerSignal = signal("player"); // what the list asks: one of ITS rows
|
|
@@ -210,13 +169,10 @@ const whoWritesSignal = computed(() =>
|
|
|
210
169
|
```
|
|
211
170
|
|
|
212
171
|
Each control binds the state it is about, and the value that spans them is
|
|
213
|
-
computed from those — never held a second time
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
The rule underneath: a control shows values, so the state it binds is the one
|
|
218
|
-
whose values it can show. A state holding more than that is more than one
|
|
219
|
-
question.
|
|
172
|
+
computed from those — never held a second time (the row chosen before the switch
|
|
173
|
+
went on is still chosen when it goes off: nothing cleared it). A control binds
|
|
174
|
+
the state whose values it can show; a state holding more than that is more than
|
|
175
|
+
one question.
|
|
220
176
|
|
|
221
177
|
## When there is no binding
|
|
222
178
|
|
|
@@ -235,7 +191,6 @@ the next screen copies, and by then it is the app's pattern.
|
|
|
235
191
|
|
|
236
192
|
- [control_value.md](./control_value.md) — who holds a control's value, and what
|
|
237
193
|
a bound signal holds control by control
|
|
238
|
-
- [actions.md](./actions.md#action-or-uiaction) — `action` or `uiAction`
|
|
239
194
|
- [popup_open.md](./popup_open.md) — who owns the fact that a popup is open
|
|
240
195
|
- [interactions.md](./interactions.md) — a gesture is named, not read by hand:
|
|
241
196
|
the same rule, one layer down
|