@jsenv/navi 0.29.418 → 0.29.420
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/README.md +1 -1
- package/dist/dev/jsenv_navi.js +584 -196
- package/dist/dev/jsenv_navi.js.map +35 -35
- package/dist/jsenv_navi.js +563 -187
- package/dist/jsenv_navi.js.map +35 -35
- package/docs/AI_INSTRUCTIONS.md +177 -261
- package/docs/actions.md +164 -251
- package/docs/autofocus.md +66 -83
- package/docs/control_object.md +117 -214
- package/docs/control_value.md +121 -185
- package/docs/create_and_edit.md +169 -254
- package/docs/css_architecture.md +196 -260
- package/docs/data_states.md +39 -43
- package/docs/dialog_shape.md +91 -134
- package/docs/drag_interactions.md +115 -174
- package/docs/drag_to_travel.md +10 -9
- package/docs/dynamic_import.md +119 -136
- package/docs/error_handling.md +75 -127
- package/docs/field_validation.md +49 -89
- package/docs/form_changed.md +66 -81
- package/docs/group.md +141 -0
- package/docs/i18n.md +31 -76
- package/docs/interactions.md +6 -4
- package/docs/list_action.md +36 -51
- package/docs/list_refresh.md +73 -115
- package/docs/mobile_touch.md +56 -62
- package/docs/navigation.md +377 -723
- package/docs/network_policy.md +61 -75
- package/docs/pan_zoom.md +62 -103
- package/docs/popup_backdrop.md +128 -168
- package/docs/popup_lift.md +179 -268
- package/docs/popup_open.md +380 -651
- package/docs/resource.md +228 -408
- package/docs/route_transitions.md +333 -482
- package/docs/safe_area.md +102 -89
- package/docs/scroll.md +175 -335
- package/docs/state_binding.md +2 -1
- package/docs/testid.md +18 -23
- package/docs/typography.md +111 -141
- package/docs/view_transitions.md +106 -104
- package/docs/z_index.md +61 -109
- package/package.json +2 -2
- package/docs/badge_list.md +0 -83
- package/docs/control_group.md +0 -168
- package/docs/mobile_layout_pitfalls.md +0 -145
package/docs/scroll.md
CHANGED
|
@@ -47,7 +47,7 @@ Two shapes, and they do not behave the same:
|
|
|
47
47
|
| `header` / `footer` alone | the container itself scrolls, and they are `position: sticky` at its edges — the content scrolls under them |
|
|
48
48
|
| a `body` as well | the container becomes a flex column, its own overflow turns to `hidden`, and the **body is the only thing that scrolls**; header and footer sit outside it (`position: static`, `flex-shrink: 0`) |
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
Before fighting them:
|
|
51
51
|
|
|
52
52
|
- the body is `flex: 0 1 auto` — **it shrinks, it never grows**. A short body
|
|
53
53
|
leaves the footer right under it rather than pushed to the bottom of a box it
|
|
@@ -56,126 +56,77 @@ Three consequences worth knowing before fighting them:
|
|
|
56
56
|
footer), not a `box-shadow`: a shadow is drawn outside the box and lost to
|
|
57
57
|
whatever is painted after it, so the body would cover the very line meant to
|
|
58
58
|
separate them. Don't add a border of your own — you get two lines.
|
|
59
|
-
- header
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
(
|
|
63
|
-
badge or a stamp overflowing a row is otherwise sliced by a header it never
|
|
64
|
-
scrolls under. The box is `isolation: isolate` already, so either value stays
|
|
65
|
-
local to it.
|
|
66
|
-
See `docs/z_index.md` and `src/box/demos/9_scrollable_z_index_demo.html`.
|
|
59
|
+
- a sticky header or footer (the shape without a body) is in the sticky
|
|
60
|
+
z-index band, so everything the box contains passes under it, positioned or
|
|
61
|
+
not; beside a body it is a plain block. What that costs and the way out are
|
|
62
|
+
in [z_index.md](./z_index.md#a-sticky-part-is-only-in-the-band-while-it-is-stuck).
|
|
67
63
|
|
|
68
64
|
Padding belongs on the parts, not on the scrolling box: padding on a scroller
|
|
69
65
|
sits inside the scrollbars, and a control flush against the edge of a scrolling
|
|
70
66
|
area raises a scrollbar of its own (a focus outline is drawn outside the control
|
|
71
67
|
it belongs to).
|
|
72
68
|
|
|
69
|
+
The scrolling box — the body, when there is one — has
|
|
70
|
+
`scroll-padding: var(--navi-scroll-padding, var(--navi-s))`: what the browser
|
|
71
|
+
scrolls into view lands inside the area with room for its focus ring, rather
|
|
72
|
+
than flush on the edge where a footer or the edge of a popup half-swallows it.
|
|
73
|
+
`--navi-scroll-padding` changes that room.
|
|
74
|
+
|
|
73
75
|
Reference: `src/box/box.jsx` (the `[data-scrollable]` CSS),
|
|
74
76
|
`src/box/demos/8_scrollable_demo.html`,
|
|
75
77
|
`src/box/demos/9_scrollable_z_index_demo.html` (sticky parts and stacking).
|
|
76
78
|
|
|
77
79
|
## 1. The document scrolls
|
|
78
80
|
|
|
79
|
-
The default case: nothing to do, the document scrolls.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
them says so with `data-navi-safe-area`, which gives it back both the `padding`
|
|
86
|
-
and the `scroll-padding` the bars took. The two levels of inset, what the marker
|
|
87
|
-
does and why both paddings are needed are in
|
|
88
|
-
[safe_area.md](./safe_area.md#something-that-scrolls-under-the-furniture); the
|
|
89
|
-
one way to turn that container into a scroller by accident (an `overflow-x:
|
|
90
|
-
auto` where a `clip` was meant) is in
|
|
91
|
-
[mobile_layout_pitfalls.md](./mobile_layout_pitfalls.md).
|
|
81
|
+
The default case: nothing to do, the document scrolls. What covers it — fixed
|
|
82
|
+
bars, the device's notch — is published as `--navi-safe-area-inset-*`, and the
|
|
83
|
+
container that scrolls under it says so with `data-navi-safe-area`: see
|
|
84
|
+
[safe_area.md](./safe_area.md#something-that-scrolls-under-the-furniture), and
|
|
85
|
+
[A document wider than the screen](./safe_area.md#a-document-wider-than-the-screen)
|
|
86
|
+
for the one way to turn that container into a scroller by accident.
|
|
92
87
|
|
|
93
88
|
A `List` in this case takes `scroller="document"` (in dev it warns when it finds
|
|
94
89
|
itself inside a scrollport anyway, and names the element).
|
|
95
90
|
|
|
96
91
|
## 2. A part of the document scrolls
|
|
97
92
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<Box header>…</Box>
|
|
101
|
-
<Box body>…</Box>
|
|
102
|
-
</Box>
|
|
103
|
-
```
|
|
93
|
+
A `Box` with an `overflow`, the shape at the top of this file. What needs
|
|
94
|
+
saying is a `List` inside it.
|
|
104
95
|
|
|
105
96
|
### `List` and its `scroller`
|
|
106
97
|
|
|
107
|
-
`List` has a `scroller` prop, and
|
|
108
|
-
want:
|
|
109
|
-
|
|
110
|
-
| value | which box scrolls |
|
|
111
|
-
| ----------------------- | ---------------------------------------------------------- |
|
|
112
|
-
| `"self"` _(default)_ | the list gets a scroll box **of its own** |
|
|
113
|
-
| `"parent"` | it virtualizes against the scrollable ancestor it lives in |
|
|
114
|
-
| `"document"` | the page |
|
|
115
|
-
| `Element` / `{current}` | that element, nothing is guessed |
|
|
98
|
+
`List` has a `scroller` prop, and its default, `"self"` — a scroll box of the
|
|
99
|
+
list's own — is not the one most call sites want:
|
|
116
100
|
|
|
117
101
|
> If the list already lives in a box that scrolls (a dialog's `body`, a panel),
|
|
118
102
|
> it is `scroller="parent"`. `"self"` is for the list that IS the scrolling
|
|
119
|
-
> area
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
ends up sized on that rather than on the rows actually drawn. With anything
|
|
133
|
-
other than `"self"`, the list's own scroll box is made transparent to layout
|
|
134
|
-
(`max-height: none; overflow: visible`) — there is no nested scrollport and no
|
|
135
|
-
height to compute.
|
|
136
|
-
|
|
137
|
-
`"parent"` finds the ancestor **by measuring**: the nearest one whose content
|
|
138
|
-
actually overflows it, the page if none does. Declaring an `overflow` is not
|
|
139
|
-
enough to be picked (a box with `overflow-x: auto` that grows with its content
|
|
140
|
-
computes `overflow-y: auto` without ever scrolling). The answer is taken again
|
|
141
|
-
as the geometry moves, so an ancestor that starts scrolling once it fills up is
|
|
142
|
-
picked up then. When it is still not the box you mean, say so explicitly with
|
|
143
|
-
`"document"` or the element itself.
|
|
103
|
+
> area, and it needs a height to scroll in (`maxHeight`, or `expandY` inside a
|
|
104
|
+
> bounded parent).
|
|
105
|
+
|
|
106
|
+
A `"self"` list inside a scrolling box nests a second scroller sized on its
|
|
107
|
+
own — and a virtualized run holds the room of every row it stands for, so the
|
|
108
|
+
popup or panel around it is sized on that rather than on the rows drawn.
|
|
109
|
+
|
|
110
|
+
`scroller` also names the box the render window follows (see
|
|
111
|
+
[Many rows](#many-rows-listitems-and-the-render-window)). A `"self"` list given
|
|
112
|
+
no height scrolls nothing; the window then follows whatever box does show the
|
|
113
|
+
list, and dev warns when nothing scrolls it at all — a recovery, not the shape
|
|
114
|
+
to aim for. When `"parent"` (found by measuring) is still not the box you mean,
|
|
115
|
+
say so with `"document"` or the element itself.
|
|
144
116
|
|
|
145
117
|
### Where the list opens, and where it is
|
|
146
118
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
`open`/`defaultOpen` elsewhere in navi. The list goes back there every time it
|
|
159
|
-
changes, even after the user scrolled.
|
|
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.
|
|
167
|
-
- A list with an `id` **comes back where it was** when its screen is left and
|
|
168
|
-
come back to, the way the page does: the position is kept under the list's
|
|
169
|
-
`id` and the page's url, for the session, and put back by name the way
|
|
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
|
|
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.
|
|
119
|
+
`defaultScrolled` (or `scrolled`, held) takes `{id, offset}` — what
|
|
120
|
+
`onScrolledChange` hands out — and asks for the row BY NAME, then puts it back
|
|
121
|
+
by MEASURING it: it lands where it was even if rows were inserted before it,
|
|
122
|
+
whatever the screen it was saved on. "Reopen a thread where I left it" is
|
|
123
|
+
already provided; keep the position whole, its `visibleCount` sizes the first
|
|
124
|
+
paint. A list with an `id` also **comes back where it was** when its screen is
|
|
125
|
+
left and come back to, the way the page does; **`scrollResetOnNavigation`**
|
|
126
|
+
opts out. A list scrolling the document that opens on a row places the
|
|
127
|
+
document itself: the offset the page kept for its url is not put back over it
|
|
128
|
+
— the rows held off screen are fillers of an estimated height, and the same
|
|
129
|
+
pixels now fall on other rows.
|
|
179
130
|
|
|
180
131
|
### A search moves the list, and gives it back
|
|
181
132
|
|
|
@@ -185,47 +136,33 @@ makes that possible is `searchText` on the `List` — without it the list sees n
|
|
|
185
136
|
children and nothing else.
|
|
186
137
|
|
|
187
138
|
- **While the search is on**, the list scrolls back to its first row every time
|
|
188
|
-
the best matches change
|
|
189
|
-
|
|
190
|
-
|
|
139
|
+
the best matches change (the top `renderBudget` rows, by id and
|
|
140
|
+
`matchInfo.matchScore`), so a letter that promotes nobody new leaves the list
|
|
141
|
+
where the user put it.
|
|
191
142
|
- **When the search is emptied**, the list returns to the offset it was at when
|
|
192
|
-
the search started, render window included.
|
|
193
|
-
row: the collection is the one from before again, in the order it was in.
|
|
143
|
+
the search started, render window included.
|
|
194
144
|
|
|
195
145
|
Both rest on each row knowing where it stands, which for rows declared one by
|
|
196
|
-
one is the order they are written in
|
|
197
|
-
|
|
198
|
-
are needed for that, and the list warns in dev when the second is missing:
|
|
146
|
+
one is the order they are written in. Two things are needed for that, and the
|
|
147
|
+
list warns in dev when the second is missing:
|
|
199
148
|
|
|
200
149
|
- **a stable `key` on every row**, which is what says a row moved rather than a
|
|
201
150
|
row changed;
|
|
202
151
|
- **the rows as the list's own children**. The places are read off the children
|
|
203
152
|
the list is given, so a component of yours rendering the rows is one child
|
|
204
|
-
however many rows come out of it — they all take the same place
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
One case is knowingly left out: a row selected during the search does not hold
|
|
208
|
-
the view. Emptying the search takes the list back to where it was, which may be
|
|
209
|
-
nowhere near the row just chosen.
|
|
153
|
+
however many rows come out of it — they all take the same place. Hand the
|
|
154
|
+
list the rows, or a `<List.Items>`.
|
|
210
155
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
`<List.Item header>` / `<List.Item footer>` are sticky rows inside the list.
|
|
214
|
-
They publish their measured size as `--list-header-height` /
|
|
215
|
-
`--list-footer-height`, which feeds the `scroll-margin` of the rows — this is
|
|
216
|
-
what keeps a `scrollIntoView()` on a row from landing under the sticky header.
|
|
156
|
+
A row selected during the search does not hold the view: emptying the search
|
|
157
|
+
takes the list back to where it was, which may be nowhere near that row.
|
|
217
158
|
|
|
218
159
|
### Loading: two different situations
|
|
219
160
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
held yet are drawn as skeletons _in their own place_, virtualized like the
|
|
226
|
-
rest, and asked for as they enter the render window.
|
|
227
|
-
|
|
228
|
-
A list that knows its count has no use for the first one.
|
|
161
|
+
`loading` (with `loadingFallback`, `loadingSkeletonCount`, `renderSkeleton`)
|
|
162
|
+
says "I have nothing at all to show yet": placeholder rows stand in for the
|
|
163
|
+
whole list. `<List.Items count>` says "I know how many rows are coming": the
|
|
164
|
+
rows not held yet are skeletons in their own place, asked for as they enter the
|
|
165
|
+
render window — a list that knows its count has no use for the first one.
|
|
229
166
|
|
|
230
167
|
Reference: `src/control/list/list.jsx` (JSDoc on `List` and `List.Items`).
|
|
231
168
|
|
|
@@ -238,7 +175,7 @@ everyone else does, by asking for the overflow — and it already asks, on itsel
|
|
|
238
175
|
So the parts are direct children of the `Dialog`:
|
|
239
176
|
|
|
240
177
|
```jsx
|
|
241
|
-
<Dialog id="…" dockedOnSmallTouchScreen
|
|
178
|
+
<Dialog id="…" dockedOnSmallTouchScreen>
|
|
242
179
|
<Box header>title + close</Box>
|
|
243
180
|
<Box body>
|
|
244
181
|
<List scroller="parent" /> {/* NOT "self" */}
|
|
@@ -247,15 +184,9 @@ So the parts are direct children of the `Dialog`:
|
|
|
247
184
|
</Dialog>
|
|
248
185
|
```
|
|
249
186
|
|
|
250
|
-
A dialog is already bounded by the room its container leaves it
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
`dialog.jsx` deliberately declares no `overflow` of its own: the
|
|
255
|
-
`[data-scrollable]` the dialog carries is its scrolling rule. A modal dialog
|
|
256
|
-
would get `auto` from the UA stylesheet anyway, but a `layer="local"` one gets
|
|
257
|
-
nothing, and without that rule its `max-height` would only decide how big the
|
|
258
|
-
box looks while the content kept painting straight through it.
|
|
187
|
+
A dialog is already bounded by the room its container leaves it, so a
|
|
188
|
+
`maxHeight` is only for making it smaller than that (see
|
|
189
|
+
[dialog_shape.md](./dialog_shape.md#the-ceiling-nobody-sets)).
|
|
259
190
|
|
|
260
191
|
### `scrollCapture`
|
|
261
192
|
|
|
@@ -264,16 +195,13 @@ box looks while the content kept painting straight through it.
|
|
|
264
195
|
```
|
|
265
196
|
|
|
266
197
|
Traps wheel/touch gestures inside the popup so the page behind it cannot
|
|
267
|
-
scroll. **Without it,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
while open (its backdrop only covers the scrollport, so scrolling there would
|
|
275
|
-
reveal uncovered content); `scrollCapture` extends the lock to the whole page;
|
|
276
|
-
- `Popover` has the same prop, plus `focusCapture` for Tab.
|
|
198
|
+
scroll. **Without it, reaching the end of the content keeps going and the
|
|
199
|
+
screen underneath scrolls** — the sheet stays put while the content it covers
|
|
200
|
+
changes. It does not look like a scroll bug, and it is one. A dialog docked by
|
|
201
|
+
`dockedOnSmallTouchScreen` — the phone's sheet — has it by default (see
|
|
202
|
+
[dialog_shape.md](./dialog_shape.md#one-dialog-two-shapes)), so the prop above
|
|
203
|
+
matters for the other shapes. `Popover` has the same prop, plus `focusCapture`
|
|
204
|
+
for Tab.
|
|
277
205
|
|
|
278
206
|
### `SlideContainer` inside a popup
|
|
279
207
|
|
|
@@ -282,11 +210,11 @@ All slides live in **the same grid cell**, so the box measures itself on the
|
|
|
282
210
|
between slides — and it also means a short slide shows empty room below it. It
|
|
283
211
|
is a trade, not a leak.
|
|
284
212
|
|
|
285
|
-
**The slide IS the body.**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
213
|
+
**The slide IS the body.** The shape everyone writes first gets it wrong: a
|
|
214
|
+
`Dialog` with a `<Box body>` around the slides puts a scroller ABOVE them, and
|
|
215
|
+
that scroller's content is the grid — measured on the tallest slide. Stand on a
|
|
216
|
+
short slide and it carries the scrollbar of a neighbour, scrolling through
|
|
217
|
+
emptiness.
|
|
290
218
|
|
|
291
219
|
```jsx
|
|
292
220
|
// WRONG — the dialog's body scrolls the tallest slide, on every slide
|
|
@@ -311,48 +239,35 @@ scrollbar of a neighbour, scrolling through emptiness.
|
|
|
311
239
|
</Dialog>
|
|
312
240
|
```
|
|
313
241
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
`
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
**Padding goes on the slide** — or on its parts, since the slide is now the
|
|
330
|
-
scroller (see the top of this file) — but never on the container nor on
|
|
331
|
-
anything above it.
|
|
332
|
-
Overflow clips at the _padding_ edge, so a padding on the container is a band
|
|
333
|
-
the clipping does not cover: the arriving slide is seen there before it has
|
|
334
|
-
reached the frame. And a padding above the slides does not travel — the two
|
|
335
|
-
contents cross each other flush, instead of each arriving already inset. On the
|
|
336
|
-
slide, the inset travels with what it insets.
|
|
242
|
+
The cap on the height must reach the slides as a **constraint**, never as a
|
|
243
|
+
scroller: `SlideContainer` shrinks into what is left (`flex: 0 1 auto`; growing
|
|
244
|
+
is `expandY`), the grid hands that height to **every** slide, and a slide with
|
|
245
|
+
an `overflow` of its own scrolls only when ITS content is taller. So nothing
|
|
246
|
+
scrollable between the cap and the slides — a `<Box body>` around them is a
|
|
247
|
+
scroller, and so is a bare `overflow="auto"` on a wrapper. A shared `header`
|
|
248
|
+
takes an explicit `flexShrink="0"`: the rule that gives it for free applies
|
|
249
|
+
only next to a `body`.
|
|
250
|
+
|
|
251
|
+
**Padding goes on the slide** — or on its parts, since the slide is the
|
|
252
|
+
scroller — never on the container nor on anything above it. Overflow clips at
|
|
253
|
+
the _padding_ edge, so a padding on the container is a band the clipping does
|
|
254
|
+
not cover: the arriving slide is seen there before it has reached the frame.
|
|
255
|
+
And a padding above the slides does not travel — the two contents cross each
|
|
256
|
+
other flush, instead of each arriving already inset.
|
|
337
257
|
|
|
338
258
|
Pass `travelByKeyboard={false}` when the arrow keys belong to the content (a list
|
|
339
259
|
one walks through, a picker whose slides are steps): otherwise the right arrow
|
|
340
260
|
changes screen mid-reading.
|
|
341
261
|
|
|
342
|
-
Reference: `src/layout/
|
|
343
|
-
|
|
344
|
-
others" case in `src/layout/demos/8_slide_container_demo.html`.
|
|
262
|
+
Reference: `src/layout/slide_container.jsx`, and the "One slide much taller
|
|
263
|
+
than the others" case in `src/layout/demos/8_slide_container_demo.html`.
|
|
345
264
|
|
|
346
265
|
## Many rows: `List.Items` and the render window
|
|
347
266
|
|
|
348
|
-
What a list costs must not follow the size of its collection
|
|
349
|
-
|
|
350
|
-
components once every resolver, box and context provider is counted — and the
|
|
351
|
-
browser paints nothing until the render that draws them has ended. Forty rows
|
|
267
|
+
What a list costs must not follow the size of its collection: the browser
|
|
268
|
+
paints nothing until the render that draws the rows has ended, so forty rows
|
|
352
269
|
given to a list that opens in a click are forty rows drawn before anything is
|
|
353
|
-
seen,
|
|
354
|
-
click that opens a sheet of 40 users took 536 ms, and 952 ms for 100 (wematch,
|
|
355
|
-
September 2026). A screen shows fourteen of them.
|
|
270
|
+
seen, for a screen that shows a dozen.
|
|
356
271
|
|
|
357
272
|
So the rule: **rows as `<List.Item>` children are all drawn**, and that is the
|
|
358
273
|
right shape only for a list the caller knows to be short — a menu, a settings
|
|
@@ -374,49 +289,32 @@ sheet, a handful of tabs. A collection whose size the caller does not decide
|
|
|
374
289
|
The run draws only the rows inside the **render window** — `renderBudget` of
|
|
375
290
|
them, 100 by default — and holds the room of the others with fillers, so the
|
|
376
291
|
scrollbar says how long the collection is and the DOM says how many rows fit a
|
|
377
|
-
screen and some. The window slides as the user scrolls
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
That box is the list's own with the default `scroller="self"`, the ancestor or
|
|
387
|
-
the page with `"parent"`/`"document"` (see
|
|
388
|
-
[2. A part of the document scrolls](#2-a-part-of-the-document-scrolls)). So
|
|
389
|
-
`scroller` is not only where the scrollbar appears: it is what decides whether
|
|
390
|
-
the window moves at all. A box with nothing to scroll reports nothing, the
|
|
391
|
-
window stays where it was drawn, and the rows it does not cover stay as
|
|
392
|
-
fillers — the list shows its first `renderBudget` rows and then blank space,
|
|
393
|
-
down to where it ends.
|
|
394
|
-
|
|
395
|
-
A list told `"self"` with no height to scroll in is exactly such a box: as tall
|
|
396
|
-
as its rows, scrolling nothing. The window then follows the box that does show
|
|
397
|
-
the list, measured the way `"parent"` is, and dev warns when nothing around it
|
|
398
|
-
scrolls either. Both are recoveries, not the shape to aim for — say which box
|
|
399
|
-
scrolls: a `maxHeight` on a list that IS the scrolling area,
|
|
400
|
-
`scroller="parent"` in a dialog body or a panel, `"document"` on a page.
|
|
292
|
+
screen and some. The window slides as the user scrolls, ahead of the direction
|
|
293
|
+
the user goes, so the budget has to exceed what the scroller shows at once,
|
|
294
|
+
with room for that lookahead: the list warns below 30, and when a budget leaves
|
|
295
|
+
fewer than two rows beyond the screen.
|
|
296
|
+
|
|
297
|
+
A run whose rows all fit the window is just rows: nothing is virtualized, and
|
|
298
|
+
it costs what the same rows would as children. There is no reason to hold back
|
|
299
|
+
from it for a list that might grow.
|
|
401
300
|
|
|
402
301
|
### The first paint of a list that opens in a click
|
|
403
302
|
|
|
404
|
-
A popup's content is built in the click that opens it (see
|
|
405
|
-
and
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
rows from the start, so the smaller first window costs no second request.
|
|
303
|
+
A popup's content is built in the click that opens it (see
|
|
304
|
+
[popup_open.md](./popup_open.md)), and a page coming back in a route transition
|
|
305
|
+
is built in the transition's update callback: rows below the fold cost the same
|
|
306
|
+
there as rows on screen, and delay the movement. `renderBudget` takes
|
|
307
|
+
`{ initial, after }` for exactly this: `initial` rows in the commit the browser
|
|
308
|
+
paints first — what a phone screen shows, plus a few — counted from the row the
|
|
309
|
+
list opens on, and `after` from the paint on. A position handed back with its
|
|
310
|
+
`visibleCount` sizes that first window itself, whatever `initial` guessed. The
|
|
311
|
+
runs ask their source for `after` rows from the start, so the smaller first
|
|
312
|
+
window costs no second request.
|
|
313
|
+
|
|
314
|
+
The switch waits for the paint itself. Do not rebuild it with a `useEffect`
|
|
315
|
+
that widens a slice: preact runs a component's pending effects early when that
|
|
316
|
+
component renders again, and something always re-renders before a popup has
|
|
317
|
+
painted.
|
|
420
318
|
|
|
421
319
|
### Cmd/Ctrl + F: `findText`
|
|
422
320
|
|
|
@@ -432,20 +330,17 @@ them, nothing else.
|
|
|
432
330
|
/>
|
|
433
331
|
```
|
|
434
332
|
|
|
435
|
-
With `findText`, the fillers carry the text of the rows they
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
stands — and the render window, following that scroll like any other, draws
|
|
440
|
-
the row. A row inside the window has no hidden copy, so every row is found
|
|
441
|
-
exactly once and the count in the find bar is right.
|
|
333
|
+
With `findText`, the fillers carry the text of the rows they stand for, inside
|
|
334
|
+
`hidden="until-found"` elements: on a match the browser scrolls there and the
|
|
335
|
+
render window, following that scroll like any other, draws the row. A row
|
|
336
|
+
inside the window has no hidden copy, so every row is found exactly once.
|
|
442
337
|
|
|
443
338
|
What it costs, and where it stops:
|
|
444
339
|
|
|
445
340
|
- **A cost that follows the collection again.** The text of every row outside
|
|
446
341
|
the window is rebuilt when the window slides (one `findText` call per row)
|
|
447
|
-
and sits in the DOM as text
|
|
448
|
-
|
|
342
|
+
and sits in the DOM as text. Give it to a list a user would search with the
|
|
343
|
+
browser, not to every run.
|
|
449
344
|
- **One line per row**, and only that line is found while the row is not
|
|
450
345
|
drawn: return what a user would type, not everything the row shows.
|
|
451
346
|
- **What the client holds, no more.** A row a paginated run has not loaded has
|
|
@@ -455,17 +350,16 @@ What it costs, and where it stops:
|
|
|
455
350
|
|
|
456
351
|
Two things not to write beside it:
|
|
457
352
|
|
|
458
|
-
- **A transparent copy of the list's text laid over it** (a `<textarea
|
|
459
|
-
`<div>` in `color: transparent`)
|
|
460
|
-
twice,
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
scrolled to it. The browser's own scroll is what moves the window.
|
|
353
|
+
- **A transparent copy of the list's text laid over it** (a `<textarea>`, a
|
|
354
|
+
`<div>` in `color: transparent`): every row in the window is then found
|
|
355
|
+
twice, and the copy only lines up with rows of one fixed height.
|
|
356
|
+
- **A `beforematch` listener moving the window**: the list re-renders after the
|
|
357
|
+
event, which removes the element before the browser has scrolled to it. The
|
|
358
|
+
browser's own scroll is what moves the window.
|
|
465
359
|
|
|
466
360
|
And one trap outside the list: a CSS reset forcing `[hidden] { display: none }`
|
|
467
361
|
must leave `[hidden="until-found"]` out — an element hidden with `display: none`
|
|
468
|
-
is never revealed. navi's own does
|
|
362
|
+
is never revealed. navi's own does.
|
|
469
363
|
|
|
470
364
|
Reference: `src/control/demos/19_list_find_in_page_demo.html`.
|
|
471
365
|
|
|
@@ -474,60 +368,35 @@ Reference: `src/control/demos/19_list_find_in_page_demo.html`.
|
|
|
474
368
|
- **A stable `id` on every item.** The run keys its rows on `item.id` — it is
|
|
475
369
|
what addresses a row from outside (`--navi-select`, `scrolled={{ id }}`), and
|
|
476
370
|
what tells a row that moved from a row that changed.
|
|
477
|
-
- **A stable `renderItem`.** The run
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
a row: once at mount, again when the popup around it opens (a row inside a
|
|
490
|
-
closed dialog measures 0), and after each commit while rows are held off
|
|
491
|
-
screen. Given, nothing is measured, and the fillers are right from the first
|
|
492
|
-
commit.
|
|
493
|
-
- **A `key` on the run when the collection changes** as a whole (`itemsAction`
|
|
494
|
-
with another scope): another collection is another run. With `items`,
|
|
495
|
-
another array is already another collection.
|
|
496
|
-
- **`scroller="parent"` inside a popup body**, as everywhere else in this file.
|
|
497
|
-
- **Groups come from the data**: `groupBy` on the run, `renderGroupLabel` for
|
|
498
|
-
the label — the only way a list discovering its rows page by page has
|
|
499
|
-
sections.
|
|
500
|
-
|
|
501
|
-
A run whose rows all fit the window is just rows: nothing is virtualized, no
|
|
502
|
-
row is ever a skeleton with `items`, and it costs what the same rows would as
|
|
503
|
-
children. There is no reason to hold back from it for a list that might grow.
|
|
504
|
-
|
|
505
|
-
Reference: `src/control/list/list.jsx` (JSDoc on `List` — `renderBudget`,
|
|
506
|
-
`virtualItemSize` — and on `List.Items` — `findText`),
|
|
371
|
+
- **A stable `renderItem`.** The run hands preact the vnode it drew for an item
|
|
372
|
+
last time, so an unchanged row is not walked again — only for the same
|
|
373
|
+
function as last time: `renderItem` written inline in a component that
|
|
374
|
+
re-renders is a new function each time, and every row is redrawn with it.
|
|
375
|
+
Define it outside the component, or `useCallback` it, and let it read the
|
|
376
|
+
item and the index it is given rather than closing over state.
|
|
377
|
+
|
|
378
|
+
The rest — `virtualItemSize` for uniform rows, a `key` on the run when the
|
|
379
|
+
collection changes as a whole (never to refresh it, see
|
|
380
|
+
[list_refresh.md](./list_refresh.md#a-paginated-list-stays-on-screen-too)),
|
|
381
|
+
`groupBy` for sections — is in the JSDoc of `List` and `List.Items`
|
|
382
|
+
(`src/control/list/list.jsx`). Demos:
|
|
507
383
|
`src/control/demos/17_virtual_scroll_and_filter_demo.html` (a run in memory,
|
|
508
|
-
searched), `src/control/demos/integration/1_list_loaded_by_scroll_demo.html`
|
|
509
|
-
|
|
510
|
-
[list_refresh.md](./list_refresh.md).
|
|
384
|
+
searched), `src/control/demos/integration/1_list_loaded_by_scroll_demo.html` (a
|
|
385
|
+
run reading a slice at a time).
|
|
511
386
|
|
|
512
387
|
## Hover while scrolling
|
|
513
388
|
|
|
514
|
-
A scroll moves the content under a pointer that does not move
|
|
515
|
-
reports that as hover
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
It is free as long as hover only paints a background. It stops being free the
|
|
520
|
-
moment hover triggers real work — a highlight somewhere else in the tree, a
|
|
521
|
-
prefetch, a map redrawing a layer — because that work then lands on the main
|
|
522
|
-
thread exactly while a scroll animation is running, and the scroll stutters.
|
|
389
|
+
A scroll moves the content under a pointer that does not move, and the browser
|
|
390
|
+
reports that as hover on **every element crossing the cursor**. Free while hover
|
|
391
|
+
only paints a background; once hover triggers real work — a highlight
|
|
392
|
+
elsewhere, a prefetch, a map redrawing a layer — that work lands on the main
|
|
393
|
+
thread mid-scroll, and the scroll stutters.
|
|
523
394
|
|
|
524
395
|
### The fact is in the DOM: `navi-scrolling`
|
|
525
396
|
|
|
526
|
-
While an element scrolls it carries `navi-scrolling`,
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
events stop before the movement does. Nothing subscribes to anything: whoever
|
|
530
|
-
is concerned says so in CSS.
|
|
397
|
+
While an element scrolls it carries `navi-scrolling`, removed once it has been
|
|
398
|
+
quiet for a moment — scroll events stop before the movement does. Whoever is
|
|
399
|
+
concerned says so in CSS:
|
|
531
400
|
|
|
532
401
|
```css
|
|
533
402
|
/* my rows answer the pointer only when nothing is moving them */
|
|
@@ -536,11 +405,10 @@ is concerned says so in CSS.
|
|
|
536
405
|
}
|
|
537
406
|
```
|
|
538
407
|
|
|
539
|
-
`pointer-events`
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
ever bring the hover back.
|
|
408
|
+
`pointer-events` does the whole of it: enter, move and leave at once, in the
|
|
409
|
+
browser, at no cost per element. Hand-written in JS the same suppression takes
|
|
410
|
+
three handlers — once `mouseenter` has been swallowed, only `mousemove` can
|
|
411
|
+
bring the hover back.
|
|
544
412
|
|
|
545
413
|
The page scroll carries the attribute on `document.scrollingElement`, so an
|
|
546
414
|
ancestor rule covers it too. In JS the same fact reads as `isScrolling()` /
|
|
@@ -549,17 +417,9 @@ ancestor rule covers it too. In JS the same fact reads as `isScrolling()` /
|
|
|
549
417
|
### In a `List`: nothing to do
|
|
550
418
|
|
|
551
419
|
`List` rows leave hit-testing while anything scrolling them moves — its own
|
|
552
|
-
scroll box, the panel around it, the page.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
<List hoverWhileScrolling> {/* opt back in */}
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
The default costs one thing, and it is the honest half of the same trade: right
|
|
559
|
-
after a scroll, the row under the pointer lights up only once the pointer moves
|
|
560
|
-
by a pixel.
|
|
561
|
-
|
|
562
|
-
Reference: `src/utils/scroll_activity.js`.
|
|
420
|
+
scroll box, the panel around it, the page. `hoverWhileScrolling` opts back in.
|
|
421
|
+
The default costs one thing: right after a scroll, the row under the pointer
|
|
422
|
+
lights up only once the pointer moves by a pixel.
|
|
563
423
|
|
|
564
424
|
## The list border
|
|
565
425
|
|
|
@@ -574,45 +434,25 @@ read as a box in a box. `borderWidth="0"` removes it — the prop writes
|
|
|
574
434
|
itself the content of a `[popover]`/`<dialog>` already drops the default on its
|
|
575
435
|
own.
|
|
576
436
|
|
|
577
|
-
### The corners: the frame clips, not the scroll box
|
|
578
|
-
|
|
579
|
-
A list clips its content twice, at the same edge, for two different reasons:
|
|
580
|
-
|
|
581
|
-
- the **scroll box** (`.navi_list_scroll_container`, square, `overflow: auto`)
|
|
582
|
-
cuts what scrolls;
|
|
583
|
-
- the **frame** (`.navi_list_container`, `overflow: hidden` plus the radius)
|
|
584
|
-
rounds that cut at the corners.
|
|
585
|
-
|
|
586
|
-
The curve belongs on the frame. Do not move it onto the scroll box. A scroll
|
|
587
|
-
container does not clip its own scrollbar to its `border-radius`: measured in
|
|
588
|
-
Chromium with classic scrollbars, the scrollbar paints square over the rounded
|
|
589
|
-
corner, while the frame's clip cuts it. The scroll box would also need the
|
|
590
|
-
inner radius (the outer radius minus the border width). CSS cannot know that
|
|
591
|
-
radius when the border was removed by the `border` prop.
|
|
592
|
-
|
|
593
|
-
The curve cuts whatever sits on it. For rows drawn as full-width stripes, that
|
|
594
|
-
is what you want: a selected row's background follows the corner. For rows that
|
|
595
|
-
are framed cards themselves (a grid of hours, a column of game cards), the
|
|
596
|
-
list's curve cuts their outer corners whenever its radius is larger than
|
|
597
|
-
theirs. At a fractional pixel ratio, the frame's clip can also blur the last
|
|
598
|
-
row's bottom edge (measured in WebKit at DPR 2.625).
|
|
599
|
-
|
|
600
437
|
### Without a border, no corner
|
|
601
438
|
|
|
602
|
-
The default radius is the border's
|
|
603
|
-
`border="
|
|
604
|
-
`
|
|
605
|
-
|
|
606
|
-
badge crossing the list's edge
|
|
607
|
-
remains the only way to let
|
|
439
|
+
The default radius is the border's, and the frame (not the scroll box) clips
|
|
440
|
+
the rows to its curve. A list given `border="none"`, `border="0"` or
|
|
441
|
+
`borderWidth="0"`, and no `borderRadius`, is marked `data-borderless`: it has
|
|
442
|
+
no radius, and its frame stops clipping, because rounding the corners was that
|
|
443
|
+
clip's only job. The scroll box still clips: a badge crossing the list's edge
|
|
444
|
+
is still cut there, and `overflow="visible"` remains the only way to let
|
|
445
|
+
content out.
|
|
608
446
|
|
|
609
447
|
- An explicit `borderRadius` keeps both the curve and the frame's clip: a
|
|
610
448
|
borderless list painting a background of its own may want that surface
|
|
611
449
|
rounded, with the rows clipped to it.
|
|
612
450
|
- Only the props turn it on. A border removed from CSS (the popup's own
|
|
613
|
-
`-default`, an app class) keeps the default radius.
|
|
614
|
-
|
|
615
|
-
|
|
451
|
+
`-default`, an app class) keeps the default radius.
|
|
452
|
+
|
|
453
|
+
The curve cuts whatever sits on it: a selected row's background follows the
|
|
454
|
+
corner, and rows that are framed cards themselves lose their outer corners
|
|
455
|
+
whenever the list's radius is larger than theirs. At a fractional pixel ratio,
|
|
456
|
+
the frame's clip can also blur the last row's bottom edge.
|
|
616
457
|
|
|
617
|
-
Reference: `src/control/list/list.jsx` (the `.navi_list_container` CSS
|
|
618
|
-
`isBorderless`).
|
|
458
|
+
Reference: `src/control/list/list.jsx` (the `.navi_list_container` CSS).
|