@jsenv/navi 0.29.24 → 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).
@@ -0,0 +1,185 @@
1
+ # Opening a popup
2
+
3
+ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
4
+
5
+ - [The popup owns its open state](#the-popup-owns-its-open-state)
6
+ - [A button opens it: the attributes](#a-button-opens-it-the-attributes)
7
+ - [Something else opens it: `triggerNaviCommand`](#something-else-opens-it-triggernavicommand)
8
+ - [Which element receives the command](#which-element-receives-the-command)
9
+ - [The anchor](#the-anchor)
10
+ - [Reacting to open and close](#reacting-to-open-and-close)
11
+ - [When `open` is the right answer, and what it costs](#when-open-is-the-right-answer-and-what-it-costs)
12
+ - [What the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)
13
+
14
+ ## The popup owns its open state
15
+
16
+ A `Dialog`/`Popover` with no `open` prop keeps its own open state and listens
17
+ for requests to change it. That is the default way to use one, and it buys
18
+ something a `useState` in the parent cannot give back: **a popup refuses to
19
+ close while a control inside it is mid-action**.
20
+
21
+ ```js
22
+ // what both do on every close request
23
+ const busyElement = findBusyElementInside(popupEl);
24
+ if (busyElement) {
25
+ dispatchRequestInteraction(busyElement, { ... });
26
+ requestCloseEvent.preventDefault();
27
+ }
28
+ ```
29
+
30
+ A form that is sending holds an answer that is neither committed nor given up.
31
+ Escape, the backdrop, a close button — all of them ask, and the busy control
32
+ answers, the same way it would answer anyone else.
33
+
34
+ So the question is never "should this popup be controlled?" but "what triggers
35
+ the opening?":
36
+
37
+ | what opens it | how |
38
+ | ---------------------------------- | ---------------------------------------------- |
39
+ | a button | `command` / `commandfor` attributes, no `open` |
40
+ | a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
41
+ | it is a piece of application state | `open` — see the cost below |
42
+
43
+ ## A button opens it: the attributes
44
+
45
+ ```jsx
46
+ <Button command="--navi-open" commandfor="note-dialog">
47
+ Read the note
48
+ </Button>
49
+ <Dialog id="note-dialog">…</Dialog>
50
+ ```
51
+
52
+ The available commands: `--navi-open`, `--navi-close`, `--navi-toggle`,
53
+ `--navi-cancel` (closes, telling the popup the close means "revert"),
54
+ `--navi-confirm` (says yes, then closes).
55
+
56
+ ## Something else opens it: `triggerNaviCommand`
57
+
58
+ The attributes fire on every click of the element that carries them. As soon as
59
+ the opening is a decision rather than a click — a long press, the end of a drag,
60
+ a double-click, a keyboard shortcut, a server answer, an `IntersectionObserver` —
61
+ the decision has to be made in JS, and the command triggered from there:
62
+
63
+ ```jsx
64
+ import { triggerNaviCommand } from "@jsenv/navi";
65
+
66
+ const dialogRef = useRef(null);
67
+ const open = (event) => {
68
+ if (draggedRef.current) {
69
+ // the click that ends a throw is not a request to open
70
+ return;
71
+ }
72
+ triggerNaviCommand(dialogRef.current, "--navi-open", event);
73
+ };
74
+
75
+ <Box role="button" onClick={open}>…</Box>
76
+ <Dialog ref={dialogRef}>…</Dialog>
77
+ ```
78
+
79
+ This is the same entry point the attributes go through: same target resolution,
80
+ same command proxies, same events. The popup stays uncontrolled, and keeps its
81
+ say over closing.
82
+
83
+ `event` is the DOM event the decision came from. Pass it whenever there is one:
84
+ it is chained into the request event, and that chain is what lets the popup
85
+ handle focus correctly (which element to give focus back to, whether a
86
+ mousedown's click must be swallowed). Omit it only when nothing user-initiated
87
+ triggered the command.
88
+
89
+ ## Which element receives the command
90
+
91
+ The first argument is the command's **source** — the element it is triggered
92
+ _from_. The target is resolved from it, in this order:
93
+
94
+ 1. `commandfor="someId"` on the source,
95
+ 2. `navi-command-target="parent-control" | "child-control"`,
96
+ 3. the command's own fallback — for the popup commands, `closest("[aria-expanded]")`.
97
+
98
+ A popup carries `aria-expanded` from its very first render, so passing the popup
99
+ element itself as the source resolves to that popup: `closest()` starts at the
100
+ element itself. That is the short form used above, and it is enough whenever the
101
+ JS that decides already holds the popup's ref.
102
+
103
+ To trigger from another element instead, give that element a `commandfor`
104
+ pointing at the popup's `id` — attribute-driven target resolution, JS-driven
105
+ timing.
106
+
107
+ ## The anchor
108
+
109
+ A popup with no `anchor` prop uses the command's source as its anchor
110
+ (`detail.anchor ?? detail.source`). Passing the popup itself as the source
111
+ therefore makes it its own anchor. For `Dialog` this only affects the
112
+ `--anchor-width`/`--anchor-height` CSS vars; for `Popover`, which really is
113
+ positioned relative to its anchor, say what the anchor is:
114
+
115
+ ```jsx
116
+ <Popover ref={popoverRef} anchor={rowRef}>
117
+ ```
118
+
119
+ The `anchor` prop always wins over whatever the command carried.
120
+ `anchorCustomEventDetail="ignore"` (Popover only) goes further and drops the
121
+ event's anchor entirely, for a popover that must never be anchored to whatever
122
+ opened it.
123
+
124
+ ## Reacting to open and close
125
+
126
+ `onClose` is called on every real close. There is no `onOpen`: an uncontrolled
127
+ popup rewrites its own open handler, so a passed one would never run. Listen on
128
+ the ref instead — or, when the JS that opens is yours, do the work right where
129
+ you trigger the command.
130
+
131
+ ```js
132
+ useLayoutEffect(() => {
133
+ const dialog = dialogRef.current;
134
+ const onOpen = () => {
135
+ /* … */
136
+ };
137
+ dialog.addEventListener("navi_request_open", onOpen);
138
+ return () => dialog.removeEventListener("navi_request_open", onOpen);
139
+ }, []);
140
+ ```
141
+
142
+ ## When `open` is the right answer, and what it costs
143
+
144
+ `open` is for a popup whose being-open is a fact about the application, not
145
+ about the user's last gesture: a route that _is_ a dialog, an error the app
146
+ decides to show. Use it there, and know what it changes.
147
+
148
+ The busy arbitration still runs — `open={false}` goes through the same
149
+ `requestClose` — but nobody hears the refusal. The parent's state says closed,
150
+ the popup stayed open, and the two disagree from then on: the effect only reacts
151
+ to `open` _changing_, so setting it back to `true` matches the popup's real
152
+ state and does nothing, and the popup can no longer be closed by the prop at
153
+ all until it closes on its own.
154
+
155
+ `defaultOpen` is the middle ground: mount-only, and the popup owns everything
156
+ afterwards. `defaultOpen="interaction"` means the mount _is_ the opening (the
157
+ entrance animation plays); any other truthy value means it was already open when
158
+ the page appeared (no entrance).
159
+
160
+ ## What the popup holds while it is closed
161
+
162
+ A closed popup builds nothing: `children` are mounted on the first open, and
163
+ stay mounted afterwards — a reopened popup finds its scroll position and its
164
+ half-typed form where it left them.
165
+
166
+ Two props move that line:
167
+
168
+ | prop | effect |
169
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
170
+ | `mountWhenClosed` | build `children` right away — for content something depends on before any opening (a value read off it, fields a surrounding form submits, a size measured from outside) |
171
+ | `unmountWhenClosed` | throw `children` away once the popup has finished closing — for content whose fresh state is its initial state |
172
+
173
+ `unmountWhenClosed` is what an uncontrolled field seeded from a `defaultValue`
174
+ needs: without it, a popup reopened after the underlying value changed still
175
+ shows what it showed at closing time.
176
+
177
+ ```jsx
178
+ <Dialog ref={dialogRef} unmountWhenClosed>
179
+ <Textarea defaultValue={note.text} />
180
+ </Dialog>
181
+ ```
182
+
183
+ The content is dropped only once the exit transition is over, so the popup never
184
+ plays it on a blank surface; a popup reopened while it was leaving keeps the
185
+ content that opening just asked for. `mountWhenClosed` wins if both are set.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.24",
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
  },