@jsenv/navi 0.29.14 → 0.29.16

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/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.
@@ -0,0 +1,111 @@
1
+ # Stacking (z-index)
2
+
3
+ What we want: **an element that must paint in front of another one, without
4
+ that decision reaching anything else on the page.** A `z-index` written without
5
+ a stacking context does the opposite — it is a claim against the whole
6
+ document, so a card's own detail ends up in front of the top bar.
7
+
8
+ Reach for the tools in this order.
9
+
10
+ ## 1. DOM order first
11
+
12
+ Between positioned elements that all have `z-index: auto`, the last one written
13
+ paints in front. Moving a tag is the cheapest way to reorder, and it can never
14
+ affect anything outside its parent.
15
+
16
+ ```jsx
17
+ // The stamp paints over the content because it comes after it. No z-index.
18
+ <Box position="relative">
19
+ <CardContent />
20
+ <Stamp />
21
+ </Box>
22
+ ```
23
+
24
+ If the element that must be in front cannot move in the DOM (it is a slot, it
25
+ is written by a consumer), that is a real reason to go further — "I did not
26
+ think about the order" is not.
27
+
28
+ ## 2. A `z-index` without a stacking context is compared against the page
29
+
30
+ `z-index: 5` does not mean "in front of my siblings". It means "in front of
31
+ everything painted lower **in the nearest stacking context**", and when no
32
+ ancestor opens one, that context is the document root — including `FixedBar`,
33
+ sticky list-group labels, and popups.
34
+
35
+ This is the failure that keeps happening: a small `z-index` inside a card wins
36
+ against a bar written at the other end of the page, because both are competing
37
+ in the same, page-wide context.
38
+
39
+ ## 3. If a `z-index` is genuinely needed, isolate
40
+
41
+ `isolation: isolate` on the common parent makes its descendants' `z-index`
42
+ values local to it — they order among themselves and the parent as a whole
43
+ takes its place among its own siblings.
44
+
45
+ ```css
46
+ .my_card {
47
+ /* z-index values inside the card mean "inside the card" */
48
+ isolation: isolate;
49
+ }
50
+ ```
51
+
52
+ A `z-index` inside a reusable component without this is a bug waiting for its
53
+ call site: the component behaves differently depending on where it is dropped.
54
+
55
+ ## 4. What creates a stacking context without you asking
56
+
57
+ `opacity` below 1, `transform`, `filter`, `backdrop-filter`, `will-change`,
58
+ `contain: paint`, `mix-blend-mode`, and a positioned element with a `z-index`
59
+ other than `auto` all open one. Two consequences:
60
+
61
+ - something you faded or moved suddenly paints as a block, in front of or
62
+ behind a sibling it used to interleave with;
63
+ - a `z-index` you wrote deeper inside stops reaching where you expected,
64
+ because one of these ancestors now caps it.
65
+
66
+ The answer is still DOM order — write the layer that must be on top last —
67
+ not a `z-index` "to repair it". Adding one on top of an unnoticed stacking
68
+ context is how a value ends up tuned to a symptom.
69
+
70
+ ## 5. The values navi plays with
71
+
72
+ | What | Value | Notes |
73
+ | ----------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | Top layer (`Dialog`/`Popover` with `layer="top"`) | above everything | Browser top layer — no `z-index` involved, nothing in the page can beat it |
75
+ | `Dialog`/`Popover` with `layer="local"`, and their backdrop | `--navi-popup-z-index` (1000) `+ stack order` | The stack order increments per open, so the last opened wins |
76
+ | Callout (validation messages) | `--callout-z-index` (1000) | |
77
+ | `FixedBar` | 1 | `position: fixed` — it opens its own stacking context, but competes in the root one at 1, which is exactly why a stray `z-index: 2` anywhere on the page lands in front of it |
78
+ | `List` sticky group labels, `List` footer | 1 | Local to the list |
79
+ | `Table` (sticky cells, drag, resize) | 1–7, see `src/control/table/z_indexes.js` | Derived from each other, never literals |
80
+
81
+ Two things to read from this table:
82
+
83
+ - navi itself keeps its values low and relative, except for popups, which sit
84
+ at 1000 precisely so nothing has to guess;
85
+ - an app that writes a number above 1 is already competing with `FixedBar`.
86
+ Write `isolation: isolate` on the parent instead, and the number stops
87
+ meaning anything outside it.
88
+
89
+ ## A card that stacks three layers with no `z-index`
90
+
91
+ A cover link that makes the whole card clickable, content above it, and a stamp
92
+ above everything — DOM order alone, in painting order:
93
+
94
+ ```jsx
95
+ <Box relative isolation="isolate">
96
+ {/* Painted first, fills the card, catches the clicks */}
97
+ <Link href={href} absolute inset aria-label={title} />
98
+ {/* After it, so text and buttons are on top and remain interactive */}
99
+ <Box relative>
100
+ <Text bold>{title}</Text>
101
+ <Text>{description}</Text>
102
+ </Box>
103
+ {/* Last, so it covers the two others */}
104
+ <Stamp />
105
+ </Box>
106
+ ```
107
+
108
+ The only positioning trick here is `position: relative` on the content: a
109
+ positioned element paints above a non-positioned one regardless of order, so
110
+ the content has to be positioned too to stay above the cover link. `isolation`
111
+ is there for what the card's children may do later, not for this file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.14",
3
+ "version": "0.29.16",
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.3",
32
+ "@jsenv/dom": "0.17.5",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.2"
35
35
  },