@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.
- package/README.md +3 -1
- package/dist/jsenv_navi.js +960 -343
- package/dist/jsenv_navi.js.map +117 -62
- package/docs/AI_INSTRUCTIONS.md +7 -3
- package/docs/drag_to_travel.md +24 -2
- package/docs/navigation.md +253 -0
- package/docs/popup_open.md +185 -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).
|
|
@@ -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.
|
|
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
|
},
|