@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/dist/jsenv_navi.js +434 -28
- package/dist/jsenv_navi.js.map +47 -21
- package/dist/jsenv_navi_side_effects.js +21 -0
- package/dist/jsenv_navi_side_effects.js.map +2 -2
- package/docs/AI_INSTRUCTIONS.md +7 -0
- package/docs/css_architecture.md +53 -10
- package/docs/scroll.md +250 -0
- package/docs/z_index.md +111 -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/docs/z_index.md
ADDED
|
@@ -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.
|
|
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.
|
|
32
|
+
"@jsenv/dom": "0.17.5",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|