@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.
@@ -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
- - `src/nav/route_ui.md` — routes as UI: layout/section patterns, and
53
- `RouteTravel` (swiping between pages that are URLs the tabs of a page,
54
- driven by thumb, wheel, or a link).
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.
@@ -84,14 +84,36 @@ scrolling while a screen slides. Same word, other gesture.
84
84
 
85
85
  ## Who owns a gesture
86
86
 
87
- Two things can claim a pointer that landed on a travelling box, and both are
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.25",
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.7",
32
+ "@jsenv/dom": "0.17.8",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.2"
35
35
  },