@jsenv/navi 0.29.52 → 0.29.53
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 +375 -140
- package/dist/jsenv_navi.js.map +36 -22
- package/dist/jsenv_navi_side_effects.js +131 -12
- package/dist/jsenv_navi_side_effects.js.map +4 -2
- package/docs/AI_INSTRUCTIONS.md +38 -0
- package/docs/MOBILE_LAYOUT_PITFALLS.md +2 -2
- package/docs/actions.md +3 -1
- package/docs/create_and_edit.md +3 -1
- package/docs/css_architecture.md +10 -8
- package/docs/error_handling.md +174 -0
- package/docs/navigation.md +27 -6
- package/docs/safe_area.md +161 -0
- package/docs/scroll.md +10 -5
- package/package.json +1 -1
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -73,9 +73,28 @@ consistency across the app, not from any single call site.
|
|
|
73
73
|
and how a paginated `<List.Items>` re-reads its slices without disappearing.
|
|
74
74
|
Read it before adding verbs to `rerunOn`, hiding a list on `loading`, or
|
|
75
75
|
remounting a list with a `key` to refresh it.
|
|
76
|
+
- `docs/error_handling.md` — the two kinds of error and how navi keeps them
|
|
77
|
+
apart: where an error is shown depending on where it came from (a control's
|
|
78
|
+
action shows it on what was clicked, a route action replaces the page, a
|
|
79
|
+
refused value is validation and not an error at all), why a run never rejects,
|
|
80
|
+
the `__handled_by__` mark that means "this error is on screen somewhere" (and
|
|
81
|
+
what `preact/debug` throws over your app without it), what becomes of an error
|
|
82
|
+
nobody displays, and the two rules any error boundary must follow — mark only
|
|
83
|
+
what you render, reset on the document URL and not only on the rerun. Read it
|
|
84
|
+
before displaying an error by hand, before writing an error boundary of your
|
|
85
|
+
own, and before concluding that a dev overlay over a page that already shows
|
|
86
|
+
its error is a crash.
|
|
76
87
|
- `docs/css_architecture.md` — how Navi's CSS layering works, and the
|
|
77
88
|
supported ways to override component styles (props > CSS variables > direct
|
|
78
89
|
rule overrides, in that preference order).
|
|
90
|
+
- `docs/safe_area.md` — where the app is in the window and what covers it:
|
|
91
|
+
the two inset families (`--navi-app-inset-*` for what is pinned to an edge,
|
|
92
|
+
`--navi-safe-area-inset-*` for what flows inside), how an app declares itself
|
|
93
|
+
narrower than the window, `data-navi-safe-area`, how something new joins the
|
|
94
|
+
sum, and which viewport is which once a virtual keyboard is open. Read it
|
|
95
|
+
before hand-writing an offset to clear a `FixedBar`, before reaching for
|
|
96
|
+
`env(safe-area-inset-*)` directly, and before making an app simulate a phone
|
|
97
|
+
screen.
|
|
79
98
|
- `docs/scroll.md` — where scrolling happens: what turns `Box`
|
|
80
99
|
`header`/`body`/`footer` on, `FixedBar` space, `List`'s `scroller`, scroll
|
|
81
100
|
inside a `Dialog`/`Popover`, and what a scroll does to hover
|
|
@@ -185,6 +204,25 @@ consistency across the app, not from any single call site.
|
|
|
185
204
|
does this for its current-tab indicator automatically
|
|
186
205
|
(`currentIndicator`), which is why the bar follows a `RouteTravel` swipe
|
|
187
206
|
with no wiring.
|
|
207
|
+
- the document's rendering is SUSPENDED for the whole update callback, from
|
|
208
|
+
the capture of the old state to the capture of the new one. Nothing paints,
|
|
209
|
+
and `requestAnimationFrame` does not tick in there — awaiting a frame
|
|
210
|
+
inside the callback awaits something that cannot happen, until the browser
|
|
211
|
+
gives up on the transition entirely (`Transition was aborted because of
|
|
212
|
+
timeout in DOM update`). Await a microtask, a task or a render; never a
|
|
213
|
+
frame.
|
|
214
|
+
- that suspension lasts exactly as long as the callback, and **in a
|
|
215
|
+
sub-document it takes the scrollbar with it**: an iframe's scrollbar is
|
|
216
|
+
painted by the framed document, so it goes and comes back, shifting the
|
|
217
|
+
layout by its width. A top-level page is spared — its root scrollbar is the
|
|
218
|
+
compositor's. So a callback that waits on the network flickers every demo
|
|
219
|
+
shown in an iframe while the same app, opened on its own, shows nothing.
|
|
220
|
+
Keep the callback short, and suspect the frame before the code when a
|
|
221
|
+
scrollbar blinks.
|
|
222
|
+
- `viewTransition.finished` REJECTS when another transition replaces this one
|
|
223
|
+
— there is only ever one per document. `.finally()` does not handle a
|
|
224
|
+
rejection, so an unhandled one is what it leaves behind; `.then(done, done)`
|
|
225
|
+
is the shape that ends a transition whichever way it went.
|
|
188
226
|
|
|
189
227
|
If unsure which export solves a problem, check `README.md` first: it names what
|
|
190
228
|
navi provides, area by area. From a name, the built export tells you the API and
|
|
@@ -82,8 +82,8 @@ far from the wrapper:
|
|
|
82
82
|
- **Worse than not sticking: the sticky element is offset downwards.** The rectangle a
|
|
83
83
|
sticky element sticks within is the scroll container's box shrunk by its
|
|
84
84
|
`scroll-padding` (CSS Position L3), and Chromium applies that for an element scroll
|
|
85
|
-
container. A wrapper carrying `data-navi-
|
|
86
|
-
`scroll-padding-top: var(--navi-
|
|
85
|
+
container. A wrapper carrying `data-navi-safe-area` has
|
|
86
|
+
`scroll-padding-top: var(--navi-safe-area-inset-top)`, so labels come to rest at
|
|
87
87
|
`scroll-padding-top + top` — a group label floating a hundred pixels below the bar,
|
|
88
88
|
covering the content above it.
|
|
89
89
|
|
package/docs/actions.md
CHANGED
|
@@ -81,7 +81,9 @@ const [user] = useAsyncData(userAction);
|
|
|
81
81
|
`useAsyncData` suspends until the data is there and throws on failure, leaving
|
|
82
82
|
both to the nearest `<Loading>` and `<ErrorBoundary>`; pass `{ loading: true }`
|
|
83
83
|
or `{ error: true }` to handle either inside the component (stale data stays
|
|
84
|
-
available while a rerun is in flight).
|
|
84
|
+
available while a rerun is in flight). Where a failure goes when no screen takes
|
|
85
|
+
it — and why a run settles with its error rather than rejecting — is
|
|
86
|
+
[error_handling.md](./error_handling.md). `useActionStatus(action)` gives the whole
|
|
85
87
|
state at once — `{ idle, loading, completed, aborted, error, data, params }` —
|
|
86
88
|
for a component that needs to look at it rather than render it.
|
|
87
89
|
|
package/docs/create_and_edit.md
CHANGED
|
@@ -138,7 +138,9 @@ Two things to know or the screen stays empty:
|
|
|
138
138
|
- **Handle the error where it happens**, or hand it to an `<ErrorBoundary>`:
|
|
139
139
|
`useAsyncData(action, { loading: true, error: true })` returns
|
|
140
140
|
`[data, loading, error]`, which is what lets a page draw its own "the server
|
|
141
|
-
refused" with a "try again" that calls `action.rerun()`.
|
|
141
|
+
refused" with a "try again" that calls `action.rerun()`. Which of the two, and
|
|
142
|
+
what happens to an error no screen takes:
|
|
143
|
+
[error_handling.md](./error_handling.md).
|
|
142
144
|
|
|
143
145
|
## Two screens, two states
|
|
144
146
|
|
package/docs/css_architecture.md
CHANGED
|
@@ -222,14 +222,16 @@ is placed. Placement is still computed against the real viewport
|
|
|
222
222
|
(`pickPositionRelativeTo`, in `@jsenv/dom`). That is invisible for anything
|
|
223
223
|
centered on its cross axis — `center`, `bottom`, `top`, which is what a dialog
|
|
224
224
|
does nearly always — but shows for anything anchored to an edge: a
|
|
225
|
-
`positionArea` like `bottom-start`, a `SidePanel
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
225
|
+
`positionArea` like `bottom-start`, a `SidePanel`. Those sit against the
|
|
226
|
+
window's edge rather than the app column's, so they stay on the real viewport
|
|
227
|
+
for now (`side_panel.jsx` restates `--dialog-maxmax-width` as the full viewport
|
|
228
|
+
on purpose).
|
|
229
|
+
|
|
230
|
+
`FixedBar` is the exception, and shows the shape of the fix: it is pinned to
|
|
231
|
+
`--navi-app-inset-*` (see `docs/safe_area.md`), which describes where the app's
|
|
232
|
+
rectangle is in the window rather than how wide it may be. Making the rest
|
|
233
|
+
follow means narrowing the container rect placement is computed against, inside
|
|
234
|
+
`pickPositionRelativeTo`, to that same rectangle.
|
|
233
235
|
|
|
234
236
|
Note that an app can already get all of it, placement included, by rendering
|
|
235
237
|
itself in an iframe of the target width: the viewport then genuinely _is_ the
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Error handling
|
|
2
|
+
|
|
3
|
+
There are two kinds of error in an app, and everything here exists to keep them
|
|
4
|
+
apart:
|
|
5
|
+
|
|
6
|
+
- **what the person can see and act on** — offline, a 404, the server refusing
|
|
7
|
+
a value. That is data. It must be displayed, and displayed where it makes
|
|
8
|
+
sense for what just happened;
|
|
9
|
+
- **a bug** — a `TypeError`, a contract broken. That must be as loud as
|
|
10
|
+
possible, and must never be swallowed by the machinery built for the first
|
|
11
|
+
kind.
|
|
12
|
+
|
|
13
|
+
The rule the whole thing rests on: **displaying an error is claiming it.**
|
|
14
|
+
Whatever puts an error on screen says so, and an error nobody claimed is
|
|
15
|
+
treated as a bug. Nothing has to guess which kind it is, and nothing has to
|
|
16
|
+
guess at the wrong moment — the answer is given after the fact by what actually
|
|
17
|
+
rendered.
|
|
18
|
+
|
|
19
|
+
## Where an error appears depends on where it came from
|
|
20
|
+
|
|
21
|
+
### Someone just acted — a control's action
|
|
22
|
+
|
|
23
|
+
A `<Button action>`, a `<Form>` submit: the person did something and is waiting
|
|
24
|
+
for the answer, so the answer belongs where they acted. navi shows it as a
|
|
25
|
+
validation message on the **requester** (the submit button, not the form):
|
|
26
|
+
|
|
27
|
+
```jsx
|
|
28
|
+
<Button action={GAME.POST.bindParams(values)}>Créer</Button>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `errorMapping(error)` turns the raw error into what to show: a string, an
|
|
32
|
+
`Error`, an element, or `{ message, target }` to point the message at another
|
|
33
|
+
element.
|
|
34
|
+
- The message is a constraint on the control (`navi_action_error`) rather than a
|
|
35
|
+
render of its own, and it **auto-resets on the next action**: hitting submit
|
|
36
|
+
again clears it and re-submits, instead of the form being stuck invalid.
|
|
37
|
+
- `actionErrorEffect` changes that: `"throw"` re-throws the error from a layout
|
|
38
|
+
effect so the nearest `<ErrorBoundary>` takes it (a control cannot throw from
|
|
39
|
+
an event handler and be caught — see the comment in
|
|
40
|
+
[use_execute_action.js](../src/action/use_execute_action.js)), and anything
|
|
41
|
+
else — `"none"` — means the control displays nothing because something else
|
|
42
|
+
does. `<Details>` uses that: its `<ActionRenderer>` shows the error inside the
|
|
43
|
+
open panel.
|
|
44
|
+
|
|
45
|
+
### A screen's data failed — a route action
|
|
46
|
+
|
|
47
|
+
Nobody clicked; the page simply cannot be drawn. It is the page, or a piece of
|
|
48
|
+
it, that is replaced — by an ancestor, never by a prop on the page. That is what
|
|
49
|
+
`useAsyncData`'s default means: **delegate**. The component says what it renders
|
|
50
|
+
when it has data, and what it cannot render is somebody else's job, up the tree:
|
|
51
|
+
|
|
52
|
+
```jsx
|
|
53
|
+
<Route>
|
|
54
|
+
<ErrorBoundary
|
|
55
|
+
fallback={({ error, resetError }) => (
|
|
56
|
+
<ErrorScreen error={error} onRetry={resetError} />
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Loading fallback={<GameSkeleton />}>
|
|
60
|
+
<Route route={GAME_ROUTE} element={GamePage} />
|
|
61
|
+
<Route route={GAMES_ROUTE} element={GamesPage} />
|
|
62
|
+
</Loading>
|
|
63
|
+
</ErrorBoundary>
|
|
64
|
+
</Route>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Two things about that shape:
|
|
68
|
+
|
|
69
|
+
- The boundary goes **outside** the `<Loading>`. A page suspends first and fails
|
|
70
|
+
second; a boundary placed under the `Suspense` it suspended in is part of the
|
|
71
|
+
tree being held.
|
|
72
|
+
- It is written **between** a container `<Route>` and its branches, and the
|
|
73
|
+
container reads through it — the selected branch keeps whatever was written
|
|
74
|
+
around it. So a boundary can bracket a section of the pages instead of the
|
|
75
|
+
whole router, without the router having to know it exists.
|
|
76
|
+
|
|
77
|
+
Inside the component when the error is part of what the screen draws rather than
|
|
78
|
+
a screen of its own:
|
|
79
|
+
|
|
80
|
+
```jsx
|
|
81
|
+
const [game, loading, error, dismissError] = useAsyncData(gameAction, {
|
|
82
|
+
loading: true,
|
|
83
|
+
error: true,
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
And one row rather than the whole list: `<List.Item error>` replaces the row's
|
|
88
|
+
content with the message — what the row stood for did not happen, so drawing it
|
|
89
|
+
as if it had would be a lie.
|
|
90
|
+
|
|
91
|
+
### A value is refused — validation
|
|
92
|
+
|
|
93
|
+
Not an error at all, and not to be modelled as one: a value the app or the
|
|
94
|
+
browser refuses is a **status on the control**, carried by navi's constraint
|
|
95
|
+
validation. Never throw to reject a value — see `src/control/rules/`.
|
|
96
|
+
|
|
97
|
+
## What a failing action does
|
|
98
|
+
|
|
99
|
+
It writes the error into `errorSignal`, moves to `FAILED`, and stops.
|
|
100
|
+
|
|
101
|
+
**A run never rejects.** `run()`, `rerun()`, `prerun()` settle with the error as
|
|
102
|
+
their value. Nothing about a failed action arrives as a rejected promise, so
|
|
103
|
+
there is no floating rejection to catch and no navigation that has to await one
|
|
104
|
+
to stay quiet. Code that wants the error asks for it — `useAsyncData`,
|
|
105
|
+
`errorSignal`, or an `onError` passed to the run.
|
|
106
|
+
|
|
107
|
+
Why it does no more than that: **at the instant an action fails, nothing can
|
|
108
|
+
know whether a screen will display it.** A route action runs before its page
|
|
109
|
+
renders — often before that page exists — so any decision made there about "will
|
|
110
|
+
someone show this?" is a guess, and it is wrong for the most common case in the
|
|
111
|
+
app. So no decision is made there.
|
|
112
|
+
|
|
113
|
+
## Claiming an error: `__handled_by__`
|
|
114
|
+
|
|
115
|
+
Whatever displays the error sets `error.__handled_by__` (via
|
|
116
|
+
`markErrorAsDisplayedBy` in
|
|
117
|
+
[action_error_report.js](../src/action/action_error_report.js)). That mark means
|
|
118
|
+
one thing and only one: **this error is on screen somewhere.**
|
|
119
|
+
|
|
120
|
+
It is the same mark the jsenv supervisor reads to keep its dev overlay out of
|
|
121
|
+
the way. It has to exist because `preact/debug` re-throws, in a `setTimeout`,
|
|
122
|
+
**every error a boundary handled** — deliberately, so that React-style dev
|
|
123
|
+
overlays keep working:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
// when an error was handled by an ErrorBoundary we will nonetheless emit an error
|
|
127
|
+
// event on the window object. This is to make up for react compatibility in dev mode
|
|
128
|
+
// and thus make the Next.js dev overlay work.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Without the mark, an app calmly displaying "you are offline" gets a crash
|
|
132
|
+
overlay thrown over it.
|
|
133
|
+
|
|
134
|
+
Everything navi ships already marks: `<ErrorBoundary>` when it renders its
|
|
135
|
+
fallback, `<ActionRenderer>` / `<Button action>` when they render their error
|
|
136
|
+
branch, `useAsyncData({ error: true })`
|
|
137
|
+
when it hands the error to the component, and a run given an `onError` — asking
|
|
138
|
+
for the error is taking it.
|
|
139
|
+
|
|
140
|
+
## Claimed by nobody: the report
|
|
141
|
+
|
|
142
|
+
Unclaimed errors are re-thrown from a macrotask, which makes them ordinary
|
|
143
|
+
unhandled errors — window `error` event, jsenv overlay in dev — pointing at the
|
|
144
|
+
code that produced them.
|
|
145
|
+
|
|
146
|
+
The delay is the whole mechanism: every render that could display the error
|
|
147
|
+
(Preact's queue, a `Suspense` boundary settling on the failure, the boundary
|
|
148
|
+
above it) happens in microtasks, so one macrotask later the answer is final. A
|
|
149
|
+
screen that mounts much later than that — mounted by something slower than a
|
|
150
|
+
render — gets its error reported anyway; the report is then a duplicate of what
|
|
151
|
+
it shows, never a lie about it. The same error reaching the reporter twice is
|
|
152
|
+
reported once.
|
|
153
|
+
|
|
154
|
+
## Writing your own boundary
|
|
155
|
+
|
|
156
|
+
navi's `<ErrorBoundary>` is what a boundary must be, and both of its rules are
|
|
157
|
+
easy to miss when writing another one:
|
|
158
|
+
|
|
159
|
+
- **Mark only what you actually render.** Marking on catch, before knowing
|
|
160
|
+
whether anything is displayed, turns a boundary into a bug swallower: a
|
|
161
|
+
`TypeError` in a component becomes a blank page AND a silent one, since the
|
|
162
|
+
mark muted the overlay that would have pointed at it. navi's boundary marks
|
|
163
|
+
just before rendering its fallback, and re-throws untouched when it has no
|
|
164
|
+
fallback — nothing to display means nothing handled.
|
|
165
|
+
- **Reset on the document URL, not only on the rerun.** Re-running the failed
|
|
166
|
+
action is one way out; going somewhere else is the common one. A boundary that
|
|
167
|
+
only watches the action stays in place of every page after the failure,
|
|
168
|
+
including the ones that would render fine.
|
|
169
|
+
|
|
170
|
+
The one reason to write your own: **filtering what you take.** navi's takes
|
|
171
|
+
everything its subtree throws. An app that wants its own bugs to stay visible
|
|
172
|
+
displays only what is data to it — an error carrying an HTTP status, its own
|
|
173
|
+
`OfflineError` — and re-throws the rest **unmarked**, so the overlay still does
|
|
174
|
+
its job.
|
package/docs/navigation.md
CHANGED
|
@@ -190,16 +190,37 @@ Two shapes for a section, and which one applies is decided by the URL:
|
|
|
190
190
|
|
|
191
191
|
### Loading data
|
|
192
192
|
|
|
193
|
-
A
|
|
193
|
+
A page reads its data from a route action through `useAsyncData` and says only
|
|
194
|
+
what it renders; what it cannot render is delegated to an ancestor — waiting to
|
|
195
|
+
`<Loading>`, failing to `<ErrorBoundary>`. Both are written **between** the
|
|
196
|
+
container and its branches, and the container reads through them, so a whole
|
|
197
|
+
section of pages shares one:
|
|
194
198
|
|
|
195
199
|
```jsx
|
|
196
|
-
<
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
<Route>
|
|
201
|
+
<ErrorBoundary
|
|
202
|
+
fallback={({ error, resetError }) => (
|
|
203
|
+
<ErrorScreen error={error} onRetry={resetError} />
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<Loading fallback={<GameSkeleton />}>
|
|
207
|
+
<Route route={GAME_ROUTE} element={GamePage} />
|
|
208
|
+
<Route route={GAMES_ROUTE} element={GamesPage} />
|
|
209
|
+
</Loading>
|
|
210
|
+
</ErrorBoundary>
|
|
211
|
+
<Route fallback element={NotFoundPage} />
|
|
212
|
+
</Route>
|
|
201
213
|
```
|
|
202
214
|
|
|
215
|
+
The order matters: the boundary goes **outside** the `<Loading>`. A page
|
|
216
|
+
suspends first and fails second, and a boundary placed under the `Suspense` it
|
|
217
|
+
suspended in is part of the tree being held.
|
|
218
|
+
|
|
219
|
+
A branch selected inside a wrapper keeps it — the container renders the active
|
|
220
|
+
branch alone, wrapper included, so `<Loading>`/`<ErrorBoundary>` can bracket a
|
|
221
|
+
subset of the branches rather than the whole router. What happens to a failure
|
|
222
|
+
no boundary takes: [error_handling.md](./error_handling.md).
|
|
223
|
+
|
|
203
224
|
## Links and tab rows
|
|
204
225
|
|
|
205
226
|
`<Link route={…}>` builds its href from the route and knows on its own whether it
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# The safe area: where the app is, and what covers it
|
|
2
|
+
|
|
3
|
+
An app is rarely given the whole window. A bar is pinned over it, the device
|
|
4
|
+
eats a corner, the app itself pretends to be a phone inside a desktop window.
|
|
5
|
+
Every component that must stay clear of all that would otherwise have to learn
|
|
6
|
+
what "all that" is — and each one would learn a different subset.
|
|
7
|
+
|
|
8
|
+
So navi publishes it, once, as CSS variables on `<html>`. Whoever reduces the
|
|
9
|
+
visible region says so; whoever must avoid it reads the sum and never learns
|
|
10
|
+
what is covering it.
|
|
11
|
+
|
|
12
|
+
## The two levels
|
|
13
|
+
|
|
14
|
+
There are two rectangles, and confusing them is the mistake this file exists to
|
|
15
|
+
prevent.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌────────────────────── window ───────────────────────┐
|
|
19
|
+
│ │ │ │
|
|
20
|
+
│ band │ ┌───────── FixedBar top ─────────┐ │ band │ ← --navi-app-inset-*
|
|
21
|
+
│ │ ├────────────────────────────────┤ │ │
|
|
22
|
+
│ │ │ │ │ │
|
|
23
|
+
│ │ │ the safe area │ │ │ ← --navi-safe-area-inset-*
|
|
24
|
+
│ │ │ │ │ │
|
|
25
|
+
│ │ └──────── FixedBar bottom ───────┘ │ │
|
|
26
|
+
└─────────────────────────────────────────────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**`--navi-app-inset-{top,right,bottom,left}`** — from the window's edges to the
|
|
30
|
+
app's own rectangle. What is _pinned to an edge_ is pinned to this.
|
|
31
|
+
|
|
32
|
+
**`--navi-safe-area-inset-{top,right,bottom,left}`** — from the window's edges
|
|
33
|
+
to the band left free _inside_ that rectangle. What _flows, scrolls or gets
|
|
34
|
+
painted_ keeps to this.
|
|
35
|
+
|
|
36
|
+
Two and not one, because a fixed bar is one of the things that reduce the free
|
|
37
|
+
band: placed against the band it contributes to, it would push itself off its
|
|
38
|
+
own edge. What is anchored and what is anchored-inside cannot be the same
|
|
39
|
+
number.
|
|
40
|
+
|
|
41
|
+
Level 2 is level 1 plus everything on that edge:
|
|
42
|
+
|
|
43
|
+
```css
|
|
44
|
+
--navi-safe-area-inset-top: calc(
|
|
45
|
+
var(--navi-app-inset-top) +
|
|
46
|
+
max(env(safe-area-inset-top), var(--navi-fixed-bar-space-top))
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`max()` and not a sum between the notch and the bars: a bar pinned to an edge
|
|
51
|
+
already reaches under the notch and counts it in its own height, so adding both
|
|
52
|
+
would reserve it twice.
|
|
53
|
+
|
|
54
|
+
Declared in `src/layout/safe_area.js`.
|
|
55
|
+
|
|
56
|
+
## Using it
|
|
57
|
+
|
|
58
|
+
### An app that is narrower than the window
|
|
59
|
+
|
|
60
|
+
One line, and nothing names a component:
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
:root {
|
|
64
|
+
--navi-app-max-width: 600px;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The bands fall out of it (centered), `--navi-app-width` follows, and `FixedBar`
|
|
69
|
+
pins itself to the column's edges rather than the glass. An app wanting them
|
|
70
|
+
uneven writes `--navi-app-inset-left` / `-right` directly instead.
|
|
71
|
+
|
|
72
|
+
Do **not** get this by mounting empty `FixedBar area="left"/"right"`: they would
|
|
73
|
+
reserve the room, but the app's rectangle would still be the whole window, so
|
|
74
|
+
dialogs and popovers would keep sizing themselves against 1500px.
|
|
75
|
+
|
|
76
|
+
### Something that scrolls under the furniture
|
|
77
|
+
|
|
78
|
+
Mark it, and it gets both paddings:
|
|
79
|
+
|
|
80
|
+
```html
|
|
81
|
+
<div id="main" data-navi-safe-area>…</div>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Two distinct things must be given back, and forgetting the second is the classic
|
|
85
|
+
bug — `padding`, or the last screenful stays unreachable under the bar; and
|
|
86
|
+
`scroll-padding`, or everything the browser scrolls _to_ (an anchor,
|
|
87
|
+
`scrollIntoView()`, a field taking focus, a restored position) lands _behind_ it.
|
|
88
|
+
The padding does not help there: it moves the content, not the place the browser
|
|
89
|
+
brings its target to.
|
|
90
|
+
|
|
91
|
+
Which element scrolls is the app's business, so navi never picks one. `:root`
|
|
92
|
+
gets the `scroll-padding` unconditionally, since the document is the scrollport
|
|
93
|
+
in the common case.
|
|
94
|
+
|
|
95
|
+
Beware of making that container scrollable by accident — see
|
|
96
|
+
`MOBILE_LAYOUT_PITFALLS.md`.
|
|
97
|
+
|
|
98
|
+
### Reading it yourself
|
|
99
|
+
|
|
100
|
+
`var(--navi-safe-area-inset-bottom)` in any rule. It is always declared, whether
|
|
101
|
+
or not the app ever mounts a bar.
|
|
102
|
+
|
|
103
|
+
Reading it from **JS** takes a probe: an unregistered custom property keeps its
|
|
104
|
+
`calc()` unresolved through `getComputedStyle`. Give a hidden box
|
|
105
|
+
`height: var(--navi-safe-area-inset-bottom)` and measure it — see
|
|
106
|
+
`src/layout/demos/fixed_bar/keyboard.html`.
|
|
107
|
+
|
|
108
|
+
### Putting something new into it
|
|
109
|
+
|
|
110
|
+
Publish what you take on one edge, into that edge's slot. That is the whole
|
|
111
|
+
contract — a native banner, an OS strip, anything an app invents joins the sum
|
|
112
|
+
without a single component learning it exists. `FixedBar` is the worked example:
|
|
113
|
+
it measures its own border box (notch included) and writes
|
|
114
|
+
`--navi-fixed-bar-space-*` (`src/layout/fixed_bar/fixed_bar_space.js`).
|
|
115
|
+
|
|
116
|
+
## What already reads it
|
|
117
|
+
|
|
118
|
+
Pointers, not a list to keep in sync — grep `--navi-safe-area-inset` for the
|
|
119
|
+
truth:
|
|
120
|
+
|
|
121
|
+
- `FixedBar` pins itself to `--navi-app-inset-*`.
|
|
122
|
+
- `List` offsets its sticky group labels when `scroller="document"`, so a label
|
|
123
|
+
comes to rest in front of the bar and not behind it.
|
|
124
|
+
- `RouteTravel` clips the pictures of a travel to the safe area. It has to: a
|
|
125
|
+
view transition paints in the top layer, where no `overflow` of the document
|
|
126
|
+
reaches it, and the box pages travel in runs _under_ the bars by design — so
|
|
127
|
+
a page scrolled by one pixel would be watched painting over them.
|
|
128
|
+
|
|
129
|
+
## The trap: which viewport
|
|
130
|
+
|
|
131
|
+
Three heights are in play and they are not the same one.
|
|
132
|
+
|
|
133
|
+
| what | shrinks when the keyboard opens |
|
|
134
|
+
| -------------------------------- | ------------------------------- |
|
|
135
|
+
| `window.innerHeight` / `100dvh` | no |
|
|
136
|
+
| `visualViewport.height` | yes |
|
|
137
|
+
| `--navi-vvh` (tracks the visual) | yes |
|
|
138
|
+
|
|
139
|
+
`position: fixed` — so every `FixedBar` — is laid out against the **layout**
|
|
140
|
+
viewport. A bottom bar therefore stays at the bottom of a window the keyboard is
|
|
141
|
+
covering: it ends up _behind_ the keyboard, and no inset says so, because
|
|
142
|
+
nothing reduced the layout viewport.
|
|
143
|
+
|
|
144
|
+
The consequence for anything measuring against the insets: mix the two families
|
|
145
|
+
and you get a drift that only appears with a keyboard open. `getBoundingClientRect`
|
|
146
|
+
is in layout-viewport coordinates, so what is compared to it must be too
|
|
147
|
+
(`100dvh`), while `--navi-app-*` derives from `--navi-vvh` because what navi
|
|
148
|
+
_sizes_ must fit what is actually visible.
|
|
149
|
+
|
|
150
|
+
`src/layout/demos/fixed_bar/keyboard.html` puts all of these on screen at once
|
|
151
|
+
and turns the bottom bar's number red when it goes under the keyboard. On a
|
|
152
|
+
phone; a desktop has no keyboard to open.
|
|
153
|
+
|
|
154
|
+
## Current limitation
|
|
155
|
+
|
|
156
|
+
Popup **placement** does not follow level 1 yet: `pickPositionRelativeTo` (in
|
|
157
|
+
`@jsenv/dom`) still computes against the real viewport. Invisible for anything
|
|
158
|
+
centered on its cross axis — which is what a dialog does nearly always — but a
|
|
159
|
+
`positionArea` like `bottom-start` or a `SidePanel` sits against the window's
|
|
160
|
+
edge rather than the app column's. See "Current limitations" in
|
|
161
|
+
`css_architecture.md`.
|
package/docs/scroll.md
CHANGED
|
@@ -71,14 +71,19 @@ Reference: `src/box/box.jsx` (the `[data-scrollable]` CSS),
|
|
|
71
71
|
|
|
72
72
|
The default case: nothing to do, the document scrolls.
|
|
73
73
|
|
|
74
|
-
The case that needs wiring is **fixed bars**
|
|
75
|
-
normal shape of a mobile app
|
|
76
|
-
|
|
74
|
+
The case that needs wiring is whatever covers the viewport — **fixed bars** (a
|
|
75
|
+
top bar, a bottom nav, the normal shape of a mobile app), the device's own
|
|
76
|
+
notch, a band an app reserves for itself. Each publishes what it takes, navi
|
|
77
|
+
adds them up on `<html>`, and the content reads the sum:
|
|
77
78
|
|
|
78
79
|
```
|
|
79
|
-
--navi-
|
|
80
|
+
--navi-safe-area-inset-top / -right / -bottom / -left
|
|
80
81
|
```
|
|
81
82
|
|
|
83
|
+
`docs/safe_area.md` holds the concept: the two levels, how an app declares
|
|
84
|
+
itself narrower than the window, and how something other than a bar joins the
|
|
85
|
+
sum.
|
|
86
|
+
|
|
82
87
|
Two distinct things must be given back to the content, and forgetting the
|
|
83
88
|
second one is the classic bug:
|
|
84
89
|
|
|
@@ -94,7 +99,7 @@ scrolls — which element that is, is the app's business, so navi does not pick:
|
|
|
94
99
|
|
|
95
100
|
```html
|
|
96
101
|
<!-- on the container that scrolls under the bars -->
|
|
97
|
-
<div id="main" data-navi-
|
|
102
|
+
<div id="main" data-navi-safe-area>…</div>
|
|
98
103
|
```
|
|
99
104
|
|
|
100
105
|
**Do not make that container scrollable by accident.** An `overflow-x: auto`
|