@jsenv/navi 0.29.25 → 0.29.26
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/jsenv_navi.js +665 -176
- package/dist/jsenv_navi.js.map +103 -54
- package/docs/AI_INSTRUCTIONS.md +7 -3
- package/docs/drag_to_travel.md +24 -2
- package/docs/navigation.md +253 -0
- package/package.json +2 -2
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -49,9 +49,13 @@ consistency across the app, not from any single call site.
|
|
|
49
49
|
values navi's own popups/bars/tables use. Read it before writing a `z-index`.
|
|
50
50
|
- `docs/MOBILE_LAYOUT_PITFALLS.md` — mobile-specific layout gotchas (viewport
|
|
51
51
|
units, virtual keyboard, safe areas).
|
|
52
|
-
- `
|
|
53
|
-
`
|
|
54
|
-
|
|
52
|
+
- `docs/navigation.md` — how to build navigation: declaring routes
|
|
53
|
+
(`route()` / `setupRoutes()`), when a section is a route of its own rather
|
|
54
|
+
than a param, search params bound to signals, rendering with `<Route>`,
|
|
55
|
+
tab rows (`Nav` / `Link` / `RouteTravel`), and the few cases where tabs are
|
|
56
|
+
legitimately not URLs. Read it before writing any routing code — the
|
|
57
|
+
position of the user belongs in the URL by default, and that decision is
|
|
58
|
+
not retrofittable.
|
|
55
59
|
- Source code on GitHub: https://github.com/jsenv/core/tree/main/packages/frontend/navi/src
|
|
56
60
|
— worth checking if the JSDoc on an export genuinely doesn't answer your
|
|
57
61
|
question.
|
package/docs/drag_to_travel.md
CHANGED
|
@@ -84,14 +84,36 @@ scrolling while a screen slides. Same word, other gesture.
|
|
|
84
84
|
|
|
85
85
|
## Who owns a gesture
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
read before the box moves:
|
|
87
|
+
Three things can claim a pointer that landed on a travelling box, and all three
|
|
88
|
+
are read before the box moves:
|
|
89
89
|
|
|
90
90
|
1. **What says so itself.** A field, a `contenteditable`, or anything carrying
|
|
91
91
|
`data-no-drag-travel`.
|
|
92
92
|
2. **A scroller in between with room left that way.** It keeps the gesture until
|
|
93
93
|
it has no room left, and only then hands the travel over — so a row that
|
|
94
94
|
scrolls sideways inside a page still scrolls sideways.
|
|
95
|
+
3. **Another travelling box in between.** The innermost one takes the axes it
|
|
96
|
+
walks, and leaves the ones it does not to whoever is above it.
|
|
97
|
+
|
|
98
|
+
### Boxes inside boxes
|
|
99
|
+
|
|
100
|
+
A row of slides inside a page that walks between pages, a carousel inside a
|
|
101
|
+
carousel, a `SlideContainer` inside a `RouteTravel`: they all get the same
|
|
102
|
+
press, and the innermost is the one the hand is pointing at. So it takes the
|
|
103
|
+
gesture on the axis it walks, and the boxes above it are left with whatever axis
|
|
104
|
+
it does not — a row swiped sideways inside a column of screens keeps the
|
|
105
|
+
sideways gesture, and the column still answers a finger going down. Nothing has
|
|
106
|
+
to be declared for this: each box says which axes it travels in the DOM
|
|
107
|
+
(`data-travel-by-drag`, `data-travel-by-wheel`), and that is what the boxes above
|
|
108
|
+
read.
|
|
109
|
+
|
|
110
|
+
Decided at the press, once and for all: from the first pixel the gesture belongs
|
|
111
|
+
to whoever asked the browser for the pointer last, which is the outermost box —
|
|
112
|
+
so the arbitration has to happen before anyone asks, and the box that does not
|
|
113
|
+
own the gesture never does. The consequence is that an inner box sitting on its
|
|
114
|
+
last slide does not hand the gesture over mid-drag: it leans on its wall, the way
|
|
115
|
+
it does when it is alone. Travelling the box around it means starting the gesture
|
|
116
|
+
outside it.
|
|
95
117
|
|
|
96
118
|
### The browser also wants to answer the gesture
|
|
97
119
|
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# Navigation
|
|
2
|
+
|
|
3
|
+
How to build navigation with `@jsenv/navi`: declaring routes, rendering them,
|
|
4
|
+
linking to them, and turning them into tabs.
|
|
5
|
+
|
|
6
|
+
## The rule that decides everything else: the position belongs in the URL
|
|
7
|
+
|
|
8
|
+
Where the user is — which section, which tab, which sub-page — is state. Put it
|
|
9
|
+
in the URL unless there is a reason not to. What that buys, none of which can be
|
|
10
|
+
retrofitted later:
|
|
11
|
+
|
|
12
|
+
- the browser's back and forward buttons work, because each place is a history
|
|
13
|
+
entry;
|
|
14
|
+
- the place is shareable and bookmarkable — someone can send a link to exactly
|
|
15
|
+
what they are looking at;
|
|
16
|
+
- the place is **targetable**: anything, anywhere in the app, can send the user
|
|
17
|
+
there with a `<Link route={…}>`, without knowing anything about the component
|
|
18
|
+
that displays it;
|
|
19
|
+
- a reload lands where the user was.
|
|
20
|
+
|
|
21
|
+
So the default shape of a tab row is routes: `<Nav>` + `<Link route>` +
|
|
22
|
+
`<RouteTravel>`. `SlideContainer` is the exception, not the starting point — see
|
|
23
|
+
[Tabs with no URL](#tabs-with-no-url) for the cases that genuinely are one.
|
|
24
|
+
|
|
25
|
+
## Declaring routes
|
|
26
|
+
|
|
27
|
+
Every route is created with `route()` and they are all declared to `setupRoutes()`
|
|
28
|
+
in one call — the routing system resolves specificity and signal ownership across
|
|
29
|
+
the whole set, so it has to see the whole set.
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
// routes.js
|
|
33
|
+
import { route, setupRoutes } from "@jsenv/navi";
|
|
34
|
+
|
|
35
|
+
export const HOME_ROUTE = route("/");
|
|
36
|
+
export const GAMES_ROUTE = route("/games");
|
|
37
|
+
export const GAME_ROUTE = route("/games/:gameId");
|
|
38
|
+
|
|
39
|
+
setupRoutes([HOME_ROUTE, GAMES_ROUTE, GAME_ROUTE]);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Named exports from one module, on purpose: the file is the map of the
|
|
43
|
+
application, and an import line says which places a component deals with.
|
|
44
|
+
Routes are plain objects usable outside of any component — `route.buildUrl()`,
|
|
45
|
+
`route.navTo()`, `route.redirectTo()`, `route.matching` — which is why they are
|
|
46
|
+
declared apart from the JSX that renders them.
|
|
47
|
+
|
|
48
|
+
### A section is allowed to be a route of its own
|
|
49
|
+
|
|
50
|
+
This is the most commonly missed point.
|
|
51
|
+
|
|
52
|
+
When a segment can take a **finite, known set of values**, declare one literal
|
|
53
|
+
route per value rather than one parameterized route you pass params to:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
// ✅ each section is a route object of its own
|
|
57
|
+
export const MY_GAMES_ROUTE = route("/games/my_games");
|
|
58
|
+
export const CANDIDATE_GAMES_ROUTE = route("/games/candidates");
|
|
59
|
+
export const FINISHED_GAMES_ROUTE = route("/games/finished");
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A literal route may sit alongside a parameterized one on the same segment
|
|
63
|
+
(`/games/:section` and `/games/my_games`). Both match, and the literal one is
|
|
64
|
+
taken as the more specific — so declaring the sections costs nothing and takes
|
|
65
|
+
nothing away.
|
|
66
|
+
|
|
67
|
+
Why prefer it:
|
|
68
|
+
|
|
69
|
+
- **The routes are listable.** `routes.js` shows the places the application has.
|
|
70
|
+
A single `/games/:section` shows one place and hides three.
|
|
71
|
+
- **No `routeParams` at the call sites.** `<Link route={MY_GAMES_ROUTE}>` instead
|
|
72
|
+
of `<Link route={GAMES_ROUTE} routeParams={{ section: "my_games" }}>`, and the
|
|
73
|
+
same for `<Route>`. A wrong section is then a missing import rather than a
|
|
74
|
+
string nobody checks.
|
|
75
|
+
- **Each section can carry its own search params.** `/games/finished` may have a
|
|
76
|
+
`sort` the other sections have no business knowing about.
|
|
77
|
+
|
|
78
|
+
Params stay for what is genuinely dynamic — a value the code cannot enumerate:
|
|
79
|
+
|
|
80
|
+
```js
|
|
81
|
+
export const GAME_ROUTE = route("/games/:gameId"); // ✅ an id
|
|
82
|
+
export const DAY_ROUTE = route("/planning/:day"); // ✅ any date
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A parameterized route also remains right for a finite set that must be handled
|
|
86
|
+
**uniformly** — a row built by `.map()` over a list of sections, where writing
|
|
87
|
+
one branch per section would be writing the same branch N times. Bind the param
|
|
88
|
+
to a signal to get validation and a default:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { stateSignal } from "@jsenv/navi";
|
|
92
|
+
|
|
93
|
+
const sectionSignal = stateSignal("to_come", {
|
|
94
|
+
id: "games_section",
|
|
95
|
+
oneOf: ["candidate", "to_come", "done"],
|
|
96
|
+
autoFix: true,
|
|
97
|
+
});
|
|
98
|
+
export const GAMES_SECTION_ROUTE = route(`/games/:section=${sectionSignal}`);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Search params
|
|
102
|
+
|
|
103
|
+
A param that qualifies a page rather than naming it — a zoom level, a sort, a
|
|
104
|
+
view mode — is a search param, declared with the signal it two-way syncs with:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const vueSignal = stateSignal("liste", {
|
|
108
|
+
id: "vue",
|
|
109
|
+
oneOf: ["liste", "carte"],
|
|
110
|
+
});
|
|
111
|
+
export const HOME_ROUTE = route("/", { searchParams: { vue: vueSignal } });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The signal and the URL are the same state: writing the signal rewrites the URL,
|
|
115
|
+
and a URL arriving from outside writes the signal. Never keep a `useState`
|
|
116
|
+
beside a route param for the same fact.
|
|
117
|
+
|
|
118
|
+
Declared on the **root route**, a search param is a position that holds wherever
|
|
119
|
+
one is in the application — a view mode that survives moving from page to page.
|
|
120
|
+
Declared on one route, it exists only there.
|
|
121
|
+
|
|
122
|
+
## Rendering routes
|
|
123
|
+
|
|
124
|
+
`<Route>` is the only primitive. With `children` it is a container that renders
|
|
125
|
+
the branch matching the URL; with a `route` it is a branch; with `fallback` it is
|
|
126
|
+
the branch taken when no sibling matches.
|
|
127
|
+
|
|
128
|
+
```jsx
|
|
129
|
+
<Route>
|
|
130
|
+
<Route route={MY_GAMES_ROUTE} element={MyGamesPage} />
|
|
131
|
+
<Route route={CANDIDATE_GAMES_ROUTE} element={CandidateGamesPage} />
|
|
132
|
+
<Route route={GAME_ROUTE} element={GamePage} />
|
|
133
|
+
<Route fallback element={NotFoundPage} />
|
|
134
|
+
</Route>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`elementProps` passes props to the element, which is how a section hands its own
|
|
138
|
+
local state down to its sub-pages.
|
|
139
|
+
|
|
140
|
+
Two shapes for a section, and which one applies is decided by the URL:
|
|
141
|
+
|
|
142
|
+
- **A section with a shared prefix owns its own sub-router.** One leaf
|
|
143
|
+
`<Route route={DASHBOARD_SECTION_ROUTE} element={DashboardSection} />` at the
|
|
144
|
+
top, and `DashboardSection` renders its own `<Route>` tree plus whatever chrome
|
|
145
|
+
it has. Everything about the section is in one file.
|
|
146
|
+
- **Pages sharing a layout but no prefix** (`/profile` and `/settings` inside an
|
|
147
|
+
authenticated shell) use a container `<Route element={AuthLayout}>`: the active
|
|
148
|
+
child is injected into the layout as its children.
|
|
149
|
+
|
|
150
|
+
### Loading data
|
|
151
|
+
|
|
152
|
+
A branch loads with `action`, and shows its states with the usual boundaries:
|
|
153
|
+
|
|
154
|
+
```jsx
|
|
155
|
+
<ErrorBoundary fallback={(error, { resetError }) => …}>
|
|
156
|
+
<Suspense fallback={<p>Loading…</p>}>
|
|
157
|
+
<Route route={GAME_ROUTE} action={loadGame} element={(game) => <GamePage game={game} />} />
|
|
158
|
+
</Suspense>
|
|
159
|
+
</ErrorBoundary>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Links and tab rows
|
|
163
|
+
|
|
164
|
+
`<Link route={…}>` builds its href from the route and knows on its own whether it
|
|
165
|
+
is the current one — that is what draws the current-tab state. `<Nav>` says once,
|
|
166
|
+
for the whole row, where the bar that marks the current tab goes:
|
|
167
|
+
|
|
168
|
+
```jsx
|
|
169
|
+
<Nav currentIndicator>
|
|
170
|
+
<Link route={MY_GAMES_ROUTE} variant="tab">
|
|
171
|
+
Mes parties
|
|
172
|
+
</Link>
|
|
173
|
+
<Link route={CANDIDATE_GAMES_ROUTE} variant="tab">
|
|
174
|
+
Candidatures
|
|
175
|
+
</Link>
|
|
176
|
+
</Nav>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The bar travels from one tab to the next rather than blinking, because `<Nav>`
|
|
180
|
+
gives it a `view-transition-name` of its own: the browser then moves it on the
|
|
181
|
+
same clock as any transition playing — including a `RouteTravel` swipe, with no
|
|
182
|
+
wiring between the two.
|
|
183
|
+
|
|
184
|
+
## Tabs that travel: `RouteTravel`
|
|
185
|
+
|
|
186
|
+
`<RouteTravel>` wraps the `<Route>` tree of a row of tabs and makes every change
|
|
187
|
+
between them a movement — a tab pressed, a key, the back button, and a thumb
|
|
188
|
+
dragging the pages.
|
|
189
|
+
|
|
190
|
+
```jsx
|
|
191
|
+
<SectionNav />
|
|
192
|
+
<RouteTravel>
|
|
193
|
+
<Route>
|
|
194
|
+
<Route route={MY_GAMES_ROUTE} element={MyGamesPage} />
|
|
195
|
+
<Route route={CANDIDATE_GAMES_ROUTE} element={CandidateGamesPage} />
|
|
196
|
+
<Route route={FINISHED_GAMES_ROUTE} element={FinishedGamesPage} />
|
|
197
|
+
</Route>
|
|
198
|
+
</RouteTravel>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The router still mounts only the branch that matches; the page being left is
|
|
202
|
+
shown from the picture the browser keeps of it. The page arriving mounts during
|
|
203
|
+
the gesture and fills in under the finger, as its own loading state.
|
|
204
|
+
|
|
205
|
+
The order of the tabs — what "one step that way" means, which no URL says — is
|
|
206
|
+
read from the children in the order they are written. Pass `routes` only to say
|
|
207
|
+
another order, or when the pages are not children of the box. An entry is a route,
|
|
208
|
+
or `{ route, params }` when the tabs are params of one route.
|
|
209
|
+
|
|
210
|
+
A swipe **replaces** the current history entry (a gesture browses; a tab pressed
|
|
211
|
+
aims at a place and pushes, which its `<Link>` already does). `onTravel` decides
|
|
212
|
+
otherwise.
|
|
213
|
+
|
|
214
|
+
Several `RouteTravel` boxes may live on one page — a section of the path and a
|
|
215
|
+
search param of the root route are two rows of tabs, both live — and only the one
|
|
216
|
+
actually travelling is captured.
|
|
217
|
+
|
|
218
|
+
Demo: [../src/nav/demos/route_travel/route_travel.html](../src/nav/demos/route_travel/route_travel.html)
|
|
219
|
+
and [../src/nav/demos/tabs/tabs.html](../src/nav/demos/tabs/tabs.html). The full
|
|
220
|
+
spec of the gesture is [drag_to_travel.md](./drag_to_travel.md).
|
|
221
|
+
|
|
222
|
+
## Tabs with no URL
|
|
223
|
+
|
|
224
|
+
`SlideContainer` holds slides that replace one another in one box, with the same
|
|
225
|
+
gestures and the same travelling bar, and nothing written to the URL. Use it when
|
|
226
|
+
the position genuinely is not a place one should be able to link to:
|
|
227
|
+
|
|
228
|
+
- the steps of a wizard, or the screens of a picker, inside a dialog or a popover
|
|
229
|
+
— a popup is promoted to the browser's top layer, so no container can hold two
|
|
230
|
+
of them side by side and `RouteTravel` has nothing to work with there;
|
|
231
|
+
- a carousel, or any window over something endless (days, months);
|
|
232
|
+
- a panel switch local to one widget, which nobody would ever send a link to.
|
|
233
|
+
|
|
234
|
+
If the answer to "should a link be able to open the app on this?" is yes, it is a
|
|
235
|
+
route.
|
|
236
|
+
|
|
237
|
+
```jsx
|
|
238
|
+
<Nav slideContainer="messagerie" currentIndicator>
|
|
239
|
+
<Link slide="unread" variant="tab">Non lus</Link>
|
|
240
|
+
<Link slide="read" variant="tab">Lus</Link>
|
|
241
|
+
</Nav>
|
|
242
|
+
<SlideContainer id="messagerie">
|
|
243
|
+
<Slide area="unread">…</Slide>
|
|
244
|
+
<Slide area="read">…</Slide>
|
|
245
|
+
</SlideContainer>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`<Nav slideContainer>` names the container by id — the row can sit anywhere on the
|
|
249
|
+
page. It reads which slide is on screen from the container itself, and its bar
|
|
250
|
+
follows the slides, a finger dragging them included. `<Link slide>` has no href
|
|
251
|
+
and behaves like a button: this is not a link to anywhere.
|
|
252
|
+
|
|
253
|
+
Demo: [../src/layout/demos/8_slide_container_demo.html](../src/layout/demos/8_slide_container_demo.html).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.26",
|
|
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.8",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|