@jsenv/navi 0.29.14 → 0.29.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jsenv_navi.js +196 -13
- package/dist/jsenv_navi.js.map +26 -11
- package/dist/jsenv_navi_side_effects.js +21 -0
- package/dist/jsenv_navi_side_effects.js.map +2 -2
- package/docs/AI_INSTRUCTIONS.md +4 -0
- package/docs/css_architecture.md +53 -10
- package/docs/scroll.md +250 -0
- package/package.json +2 -2
package/docs/scroll.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Scroll & layout
|
|
2
|
+
|
|
3
|
+
Where scrolling happens in a navi app, and how the pieces that live inside a
|
|
4
|
+
scrolling area (`Box header/body/footer`, `List`, a popup) are told about it.
|
|
5
|
+
|
|
6
|
+
- [What makes header/body/footer work: the overflow](#what-makes-headerbodyfooter-work-the-overflow)
|
|
7
|
+
- [1. The document scrolls](#1-the-document-scrolls)
|
|
8
|
+
- [2. A part of the document scrolls](#2-a-part-of-the-document-scrolls)
|
|
9
|
+
- [3. A popup scrolls](#3-a-popup-scrolls)
|
|
10
|
+
- [The list border](#the-list-border)
|
|
11
|
+
|
|
12
|
+
## What makes header/body/footer work: the overflow
|
|
13
|
+
|
|
14
|
+
`header`, `footer` and `body` are roles inside a scrolling area. What turns
|
|
15
|
+
them on is an `overflow: auto | scroll` on the box that contains them — there
|
|
16
|
+
is no second prop for the same fact.
|
|
17
|
+
|
|
18
|
+
```jsx
|
|
19
|
+
// header/body do NOTHING here: nothing scrolls
|
|
20
|
+
<Box flex="y">
|
|
21
|
+
<Box header>…</Box>
|
|
22
|
+
<Box body>…</Box>
|
|
23
|
+
</Box>
|
|
24
|
+
|
|
25
|
+
// here they do
|
|
26
|
+
<Box overflow="auto" maxHeight="60vh">
|
|
27
|
+
<Box header>…</Box> {/* stays put */}
|
|
28
|
+
<Box body>…</Box> {/* the only thing that scrolls */}
|
|
29
|
+
<Box footer>…</Box> {/* stays put */}
|
|
30
|
+
</Box>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`Dialog` and `Popover` get their own `header`/`body`/`footer` by that exact
|
|
34
|
+
same path: they ask `Box` for `overflow: auto` on themselves.
|
|
35
|
+
|
|
36
|
+
Two shapes, and they do not behave the same:
|
|
37
|
+
|
|
38
|
+
| what is inside | behaviour |
|
|
39
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `header` / `footer` alone | the container itself scrolls, and they are `position: sticky` at its edges — the content scrolls under them |
|
|
41
|
+
| 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`) |
|
|
42
|
+
|
|
43
|
+
Two consequences worth knowing before fighting them:
|
|
44
|
+
|
|
45
|
+
- the body is `flex: 0 1 auto` — **it shrinks, it never grows**. A short body
|
|
46
|
+
leaves the footer right under it rather than pushed to the bottom of a box it
|
|
47
|
+
does not fill. Adding `expandY` to "fix" that is undoing a deliberate default.
|
|
48
|
+
- the separating line is a `box-shadow`, not a `border`: it draws without taking
|
|
49
|
+
part in layout, so nothing shifts by a pixel when it appears. Don't add a
|
|
50
|
+
border of your own — you get two lines.
|
|
51
|
+
|
|
52
|
+
Padding belongs on the parts, not on the scrolling box: padding on a scroller
|
|
53
|
+
sits inside the scrollbars, and a control flush against the edge of a scrolling
|
|
54
|
+
area raises a scrollbar of its own (a focus outline is drawn outside the control
|
|
55
|
+
it belongs to).
|
|
56
|
+
|
|
57
|
+
Reference: `src/box/box.jsx` (the `[data-scrollable]` CSS),
|
|
58
|
+
`src/box/demos/8_scrollable_demo.html`.
|
|
59
|
+
|
|
60
|
+
## 1. The document scrolls
|
|
61
|
+
|
|
62
|
+
The default case: nothing to do, the document scrolls.
|
|
63
|
+
|
|
64
|
+
The case that needs wiring is **fixed bars** — a top bar, a bottom nav, the
|
|
65
|
+
normal shape of a mobile app. `FixedBar` measures its own height (safe area
|
|
66
|
+
included) and publishes it on `<html>`:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
--navi-fixed-bar-space-top / -bottom / -left / -right
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Two distinct things must be given back to the content, and forgetting the
|
|
73
|
+
second one is the classic bug:
|
|
74
|
+
|
|
75
|
+
1. **padding**, or the last screenful of content stays under the bar,
|
|
76
|
+
unreachable;
|
|
77
|
+
2. **`scroll-padding`**, or everything the browser scrolls _to_ (an anchor,
|
|
78
|
+
`scrollIntoView()`, a field taking focus, a restored scroll position) lands
|
|
79
|
+
_behind_ the bar. The padding does not help here: it moves the content, not
|
|
80
|
+
the place the browser brings its target to.
|
|
81
|
+
|
|
82
|
+
`:root` gets the `scroll-padding` unconditionally. The padding goes on whatever
|
|
83
|
+
scrolls — which element that is, is the app's business, so navi does not pick:
|
|
84
|
+
|
|
85
|
+
```html
|
|
86
|
+
<!-- on the container that scrolls under the bars -->
|
|
87
|
+
<div id="main" data-navi-fixed-bar-space>…</div>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Do not make that container scrollable by accident.** An `overflow-x: auto`
|
|
91
|
+
forces the other axis to `auto` too: the container becomes a scrollport, and
|
|
92
|
+
every `position: sticky` inside it sticks to _it_ instead of to the page. To
|
|
93
|
+
merely clip, use `overflow-x: clip` — it clips without creating a scroll
|
|
94
|
+
container.
|
|
95
|
+
|
|
96
|
+
A `List` in this case takes `scroller="document"` (in dev it warns when it finds
|
|
97
|
+
itself inside a scrollport anyway, and names the element).
|
|
98
|
+
|
|
99
|
+
Reference: `src/layout/fixed_bar/fixed_bar_space.js`,
|
|
100
|
+
`docs/MOBILE_LAYOUT_PITFALLS.md`.
|
|
101
|
+
|
|
102
|
+
## 2. A part of the document scrolls
|
|
103
|
+
|
|
104
|
+
```jsx
|
|
105
|
+
<Box overflow="auto" maxHeight="60vh">
|
|
106
|
+
<Box header>…</Box>
|
|
107
|
+
<Box body>…</Box>
|
|
108
|
+
</Box>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `List` and its `scroller`
|
|
112
|
+
|
|
113
|
+
`List` has a `scroller` prop, and the default is not the one most call sites
|
|
114
|
+
want:
|
|
115
|
+
|
|
116
|
+
| value | which box scrolls |
|
|
117
|
+
| ----------------------- | ---------------------------------------------------------- |
|
|
118
|
+
| `"self"` _(default)_ | the list gets a scroll box **of its own** |
|
|
119
|
+
| `"parent"` | it virtualizes against the scrollable ancestor it lives in |
|
|
120
|
+
| `"document"` | the page |
|
|
121
|
+
| `Element` / `{current}` | that element, nothing is guessed |
|
|
122
|
+
|
|
123
|
+
> If the list already lives in a box that scrolls (a dialog's `body`, a panel),
|
|
124
|
+
> it is `scroller="parent"`. `"self"` is for the list that IS the scrolling
|
|
125
|
+
> area.
|
|
126
|
+
|
|
127
|
+
With `"self"` the list nests a scroll box inside the surrounding one and sizes
|
|
128
|
+
itself independently of it — its `maxHeight` then decides how tall that inner
|
|
129
|
+
box is allowed to get, and a virtualized run holds the room of the rows it
|
|
130
|
+
stands for (see `List.Items count` below), so the surrounding popup or panel
|
|
131
|
+
ends up sized on that rather than on the rows actually drawn. With anything
|
|
132
|
+
other than `"self"`, the list's own scroll box is made transparent to layout
|
|
133
|
+
(`max-height: none; overflow: visible`) — there is no nested scrollport and no
|
|
134
|
+
height to compute.
|
|
135
|
+
|
|
136
|
+
`"parent"` finds the ancestor **by measuring**: the nearest one whose content
|
|
137
|
+
actually overflows it, the page if none does. Declaring an `overflow` is not
|
|
138
|
+
enough to be picked (a box with `overflow-x: auto` that grows with its content
|
|
139
|
+
computes `overflow-y: auto` without ever scrolling). The answer is taken again
|
|
140
|
+
as the geometry moves, so an ancestor that starts scrolling once it fills up is
|
|
141
|
+
picked up then. When it is still not the box you mean, say so explicitly with
|
|
142
|
+
`"document"` or the element itself.
|
|
143
|
+
|
|
144
|
+
### Where the list opens, and where it is
|
|
145
|
+
|
|
146
|
+
- **`defaultScrolled`** — `"start"` (default), `"end"`, an index, or
|
|
147
|
+
`{id, offset}`. The `{id, offset}` form is what `onScrolledChange` hands out:
|
|
148
|
+
it asks for the row BY NAME, then puts it back by MEASURING it, so it lands
|
|
149
|
+
where it was even if rows were inserted before it, and whatever the screen it
|
|
150
|
+
was saved on. That is "reopen a thread where I left it", already provided.
|
|
151
|
+
- **`scrolled`** is the controlled form of the same thing — same pair as
|
|
152
|
+
`open`/`defaultOpen` elsewhere in navi. The list goes back there every time it
|
|
153
|
+
changes, even after the user scrolled.
|
|
154
|
+
- **`onScrolledChange`** gives `{id, index, offset}` as the user scrolls.
|
|
155
|
+
|
|
156
|
+
### Sticky rows inside the list
|
|
157
|
+
|
|
158
|
+
`<List.Item header>` / `<List.Item footer>` are sticky rows inside the list.
|
|
159
|
+
They publish their measured size as `--list-header-height` /
|
|
160
|
+
`--list-footer-height`, which feeds the `scroll-margin` of the rows — this is
|
|
161
|
+
what keeps a `scrollIntoView()` on a row from landing under the sticky header.
|
|
162
|
+
|
|
163
|
+
### Loading: two different situations
|
|
164
|
+
|
|
165
|
+
- **`loading` / `loadingFallback` / `loadingSkeletonCount` / `renderSkeleton`**
|
|
166
|
+
— "I have nothing at all to show yet". Placeholder rows (or a `"loader"`
|
|
167
|
+
spinner) stand in for the whole list.
|
|
168
|
+
- **`<List.Items count>`** — "I know how many rows are coming". The rows not
|
|
169
|
+
held yet are drawn as skeletons _in their own place_, virtualized like the
|
|
170
|
+
rest, and asked for as they enter the render window.
|
|
171
|
+
|
|
172
|
+
A list that knows its count has no use for the first one.
|
|
173
|
+
|
|
174
|
+
Reference: `src/control/list/list.jsx` (JSDoc on `List` and `List.Items`).
|
|
175
|
+
|
|
176
|
+
## 3. A popup scrolls
|
|
177
|
+
|
|
178
|
+
### Structure
|
|
179
|
+
|
|
180
|
+
A popup does nothing special: it obtains `header`/`body`/`footer` the same way
|
|
181
|
+
everyone else does, by asking for the overflow — and it already asks, on itself.
|
|
182
|
+
So the parts are direct children of the `Dialog`:
|
|
183
|
+
|
|
184
|
+
```jsx
|
|
185
|
+
<Dialog id="…" dockedOnTouch scrollCapture>
|
|
186
|
+
<Box header>title + close</Box>
|
|
187
|
+
<Box body>
|
|
188
|
+
<List scroller="parent" /> {/* NOT "self" */}
|
|
189
|
+
</Box>
|
|
190
|
+
<Box footer>…</Box>
|
|
191
|
+
</Dialog>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
A dialog is already bounded by the room its container leaves it
|
|
195
|
+
(`--dialog-maxmax-height`), so a `maxHeight` is only for making it smaller than
|
|
196
|
+
that.
|
|
197
|
+
|
|
198
|
+
`dialog.jsx` deliberately declares no `overflow` of its own: a modal dialog
|
|
199
|
+
would inherit `auto` from the UA stylesheet and a `layer="local"` one gets
|
|
200
|
+
nothing, so without a scrolling rule its `max-height` would only decide how big
|
|
201
|
+
the box looks while the content kept painting straight through it.
|
|
202
|
+
|
|
203
|
+
### `scrollCapture`
|
|
204
|
+
|
|
205
|
+
```jsx
|
|
206
|
+
<Dialog scrollCapture>
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Traps wheel/touch gestures inside the popup so the page behind it cannot
|
|
210
|
+
scroll. **Without it, on mobile, reaching the end of the content keeps going and
|
|
211
|
+
the screen underneath scrolls** — the sheet stays put while the content it
|
|
212
|
+
covers changes. It does not look like a scroll bug, and it is one.
|
|
213
|
+
|
|
214
|
+
Two details:
|
|
215
|
+
|
|
216
|
+
- a `layer="local"` dialog **always** locks its own positioned ancestor's scroll
|
|
217
|
+
while open (its backdrop only covers the scrollport, so scrolling there would
|
|
218
|
+
reveal uncovered content); `scrollCapture` extends the lock to the whole page;
|
|
219
|
+
- `Popover` has the same prop, plus `focusCapture` for Tab.
|
|
220
|
+
|
|
221
|
+
### `SlideContainer` inside a popup
|
|
222
|
+
|
|
223
|
+
All slides live in **the same grid cell**, so the box measures itself on the
|
|
224
|
+
**largest** of them. That is what guarantees nothing resizes as one moves
|
|
225
|
+
between slides — and it also means a short slide shows empty room below it. It
|
|
226
|
+
is a trade, not a leak.
|
|
227
|
+
|
|
228
|
+
`SlideContainer` is `flex: 0 1 auto`: it shrinks (the slides then scroll their
|
|
229
|
+
own body) but never grows on its own. Growing is the caller's decision —
|
|
230
|
+
`expandY`.
|
|
231
|
+
|
|
232
|
+
Pass `keyboardTravel={false}` when the arrow keys belong to the content (a list
|
|
233
|
+
one walks through, a picker whose slides are steps): otherwise the right arrow
|
|
234
|
+
changes screen mid-reading.
|
|
235
|
+
|
|
236
|
+
Reference: `src/layout/dialog.jsx`, `src/layout/popover.jsx`,
|
|
237
|
+
`src/layout/slide_container.jsx`.
|
|
238
|
+
|
|
239
|
+
## The list border
|
|
240
|
+
|
|
241
|
+
Not scroll, but the same family of problem — a reasonable default nobody knows
|
|
242
|
+
can be removed.
|
|
243
|
+
|
|
244
|
+
A `<List>` frames itself (`--list-border-width-default: 1px`): "a list is a box
|
|
245
|
+
with rows in it, it says where it starts and where it ends". Inside a popup or a
|
|
246
|
+
card, that frame is already the container's, and two frames around the same rows
|
|
247
|
+
read as a box in a box. `borderWidth="0"` removes it — the prop writes
|
|
248
|
+
`--list-border-width` inline, which wins over the `-default`. A list that is
|
|
249
|
+
itself the content of a `[popover]`/`<dialog>` already drops the default on its
|
|
250
|
+
own.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Library of components including navigation to create frontend applications",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"prepublishOnly": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@jsenv/dom": "0.17.
|
|
32
|
+
"@jsenv/dom": "0.17.4",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|