@jsenv/navi 0.29.142 → 0.29.144
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 +6376 -9151
- package/dist/jsenv_navi.js.map +44 -52
- package/dist/jsenv_navi_side_effects.js +129 -491
- package/dist/jsenv_navi_side_effects.js.map +2 -4
- package/docs/AI_INSTRUCTIONS.md +20 -3
- package/docs/actions.md +44 -4
- package/docs/control_value.md +7 -1
- package/docs/css_architecture.md +219 -1
- package/docs/navigation.md +122 -35
- package/docs/popup_open.md +117 -17
- package/docs/state_binding.md +142 -0
- package/package.json +2 -2
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -112,6 +112,9 @@ consistency across the app, not from any single call site.
|
|
|
112
112
|
overrides, in that preference order), and what a popup inherits from the
|
|
113
113
|
element that opened it: which tokens a surface takes back, the color
|
|
114
114
|
keywords and their `-mix` ratios, and why a ratio is a `:root` knob only.
|
|
115
|
+
Read its first section before writing any `import.meta.css`: the build parses
|
|
116
|
+
that css, and a `${}` the build cannot read costs the whole stylesheet its
|
|
117
|
+
comments, its transpilation, its `url()` assets and its minification.
|
|
115
118
|
- `docs/safe_area.md` — where the app is in the window and what covers it:
|
|
116
119
|
the two inset families (`--navi-app-inset-*` for what is pinned to an edge,
|
|
117
120
|
`--navi-safe-area-inset-*` for what flows inside), how an app declares itself
|
|
@@ -153,6 +156,16 @@ consistency across the app, not from any single call site.
|
|
|
153
156
|
given a `ui` draws no placeholder of its own. Read it before putting a
|
|
154
157
|
`BadgeList` in a picker's `ui`, before wrapping its fallback in a `Badge`,
|
|
155
158
|
and before setting `maxLines` on the list rather than on the picker.
|
|
159
|
+
- `docs/state_binding.md` — the rule the whole API rests on: state navi shows is
|
|
160
|
+
BOUND (`signal`), not copied back by a callback. The three shapes an
|
|
161
|
+
application ever has to say (`signal` / `action` / `command` + `commandFor`),
|
|
162
|
+
the four ways the same mistake is written (`onCurrentChange`, `uiAction`,
|
|
163
|
+
`onClick` and `onOpen`/`onClose` all writing a signal), why a binding is not
|
|
164
|
+
merely shorter (it is written by every gesture, it moves the component when
|
|
165
|
+
written, and it goes through the interaction gate), what is left for the
|
|
166
|
+
callback, and what to conclude when the state being wired has no prop for it —
|
|
167
|
+
a parallel copy, or a binding navi is missing. Read it before writing any
|
|
168
|
+
handler whose body only assigns state.
|
|
156
169
|
- `docs/control_value.md` — who holds a control's value: nobody, a bound
|
|
157
170
|
`signal` (two-way, in both directions), or you (`value`/`checked`). What
|
|
158
171
|
`signal` + `defaultValue` says, what a signal holds for each kind of control,
|
|
@@ -282,9 +295,13 @@ consistency across the app, not from any single call site.
|
|
|
282
295
|
is given back the offset it was read at, and only when the row owns the
|
|
283
296
|
document scroll), a back arrow that stays inside the app (`useCanNavBack()` /
|
|
284
297
|
`navBack({ fallback })`), and the few cases where tabs are legitimately not
|
|
285
|
-
URLs.
|
|
286
|
-
|
|
287
|
-
|
|
298
|
+
URLs. It also holds the middle answer — a position READ from the URL and
|
|
299
|
+
restored on reload without a route and without an entry per step: a search
|
|
300
|
+
param signal handed to a `SlideContainer`, walked to rather than jumped to,
|
|
301
|
+
and `history: "push"` for the states whose values ARE places (with the write
|
|
302
|
+
that says otherwise, `signal.set(v, { history })`). Read it before writing any
|
|
303
|
+
routing code — the position of the user belongs in the URL by default, and
|
|
304
|
+
that decision is not retrofittable.
|
|
288
305
|
- Source code and demos on GitHub:
|
|
289
306
|
https://github.com/jsenv/core/tree/main/packages/frontend/navi — where to go
|
|
290
307
|
when the built export's JSDoc doesn't answer the question (see "Where the
|
package/docs/actions.md
CHANGED
|
@@ -107,10 +107,47 @@ obvious fix does not work — a `useEffect` in that same component that would
|
|
|
107
107
|
run it was about to start is exactly what the suspension waits for. So either
|
|
108
108
|
the action is started **before** anything reads it — bound where the screen is
|
|
109
109
|
decided: a `routeAction`, a `<Button action>`, the `action` of the `<Link>` one
|
|
110
|
-
came in by — or the component owns its request
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
came in by — or the component owns its request: a folding panel, a slice a
|
|
111
|
+
button asks for. That second one is `{ run: true }`, said in the same line that
|
|
112
|
+
reads the data:
|
|
113
|
+
|
|
114
|
+
```jsx
|
|
115
|
+
// asks for it if nobody did, from the render that reads it
|
|
116
|
+
const [members] = useAsyncData(membersAction, { run: true });
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
It starts the action from the render rather than from an effect, which is what
|
|
120
|
+
makes it work at all: a suspended component has no effects, so a `run()` written
|
|
121
|
+
in one would be waiting for itself. Everything else is unchanged — `loading` and
|
|
122
|
+
`error` mean what they mean everywhere, delegated by default, handled inline
|
|
123
|
+
when asked for — and running an action twice costs nothing, so nothing has to
|
|
124
|
+
be guarded.
|
|
125
|
+
|
|
126
|
+
**`{ run: true }` is the fallback, not the shape to reach for.** A route action
|
|
127
|
+
is asked for when the ADDRESS changes, with everything else that address needs;
|
|
128
|
+
this one cannot start before the component that draws it exists, which is a
|
|
129
|
+
render late at best and a gesture late in a popup. So it is for data nothing
|
|
130
|
+
else can ask for — a parameter chosen inside the component and dying with it —
|
|
131
|
+
and it says so at the call site, where a hand-written `useEffect` + `run()` said
|
|
132
|
+
nothing at all.
|
|
133
|
+
|
|
134
|
+
Outside a component — a request the app owns rather than a component —
|
|
135
|
+
`actionRunEffect(action, () => …)` is that same run declared once: it runs on
|
|
136
|
+
the first truthy params, reruns when they change, aborts when they go false. It
|
|
137
|
+
is the very machinery `routeAction` is built on.
|
|
138
|
+
|
|
139
|
+
**Which of the two it is, is decided by the parameter, not by what draws it.**
|
|
140
|
+
If the parameter is in the address — a path param, a search param bound to a
|
|
141
|
+
`stateSignal` — the data belongs to the screen, and it is a `routeAction`, even
|
|
142
|
+
when a popup is the only thing that draws it. The component owns its request
|
|
143
|
+
only when the parameter is chosen inside it and dies with it. A popup is never
|
|
144
|
+
by itself a reason to own one: being open can be bound too — to a signal, to the
|
|
145
|
+
history entry, to the address — and the data of a popup that binds it is a route
|
|
146
|
+
action like any other (see
|
|
147
|
+
[popup_open.md](./popup_open.md#a-popup-that-loads-data)). Getting this wrong is
|
|
148
|
+
invisible at the call site and expensive on screen: the request then leaves with
|
|
149
|
+
the gesture instead of with the screen, one waterfall behind everything else the
|
|
150
|
+
address needed.
|
|
114
151
|
|
|
115
152
|
`{ onLoad }` is what the screen does with the data **once, when it becomes
|
|
116
153
|
known** — seed the fields someone is about to edit, focus something, remember
|
|
@@ -268,6 +305,9 @@ changes do exactly that).
|
|
|
268
305
|
|
|
269
306
|
## See also
|
|
270
307
|
|
|
308
|
+
- [state_binding.md](./state_binding.md) — `signal`, `action` or
|
|
309
|
+
`command` + `commandFor`: the three shapes, and the callback that only
|
|
310
|
+
remembers
|
|
271
311
|
- [resource.md](./resource.md) — actions created from REST callbacks, and the
|
|
272
312
|
store behind them
|
|
273
313
|
- [resource_with_params.md](./resource_with_params.md) — `withParams()` and
|
package/docs/control_value.md
CHANGED
|
@@ -243,7 +243,10 @@ that is a navi control goes through the same state controller, and the same
|
|
|
243
243
|
|
|
244
244
|
`SlideContainer` takes one too, though it is layout rather than a control: the
|
|
245
245
|
area it shows is a piece of state like any other, and binding it is what lets
|
|
246
|
-
something else read where the slides are — or move them by writing it.
|
|
246
|
+
something else read where the slides are — or move them by writing it. Bind it
|
|
247
|
+
to a `stateSignal` a route declares as a search param and the area is in the
|
|
248
|
+
address, with no route per slide — see
|
|
249
|
+
[navigation.md](./navigation.md#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from).
|
|
247
250
|
|
|
248
251
|
Inside a `List selectable` you can bind the list, or give each `List.Item` its
|
|
249
252
|
own `selected` — but not expect the two to arbitrate. An item that declares
|
|
@@ -348,6 +351,9 @@ directions; it just has nothing extra to say.
|
|
|
348
351
|
|
|
349
352
|
## See also
|
|
350
353
|
|
|
354
|
+
- [state_binding.md](./state_binding.md) — the same rule beyond controls:
|
|
355
|
+
a `SlideContainer`'s area, a popup being open, the position in the app — all
|
|
356
|
+
bound rather than copied back by a callback
|
|
351
357
|
- [form_changed.md](./form_changed.md) — what a form makes of each of these:
|
|
352
358
|
which fields it counts as already answered, and when it sends nothing
|
|
353
359
|
- [control_object.md](./control_object.md) — several controls reading as one
|
package/docs/css_architecture.md
CHANGED
|
@@ -10,6 +10,224 @@ Navi components are styled through a combination of CSS custom properties (varia
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## Where CSS lives: `import.meta.css`
|
|
14
|
+
|
|
15
|
+
A navi component declares its stylesheet with `import.meta.css`, and the build
|
|
16
|
+
**parses that css**. Everything below depends on it staying parseable.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
const css = /* css */ `
|
|
20
|
+
.navi_button {
|
|
21
|
+
height: var(--button-height);
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
import.meta.css = css;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The `css` constant may be declared next to the assignment or anywhere in the same
|
|
28
|
+
module — the build follows the name to where it is declared. What it cannot
|
|
29
|
+
follow is a name coming from **another** module: `import.meta.css = importedCss`
|
|
30
|
+
is read only when the constant belongs to the module doing the assignment.
|
|
31
|
+
|
|
32
|
+
**Keep the assignment in a render, not at module scope.** A component's
|
|
33
|
+
stylesheet is adopted when the component first renders, so a page that never
|
|
34
|
+
renders it never carries it, and a bundler that sees no caller drops the css
|
|
35
|
+
along with the code. `import.meta.css = css` at the top level of a module makes
|
|
36
|
+
the sheet a module side effect: it lands on every page importing the module, for
|
|
37
|
+
a component that may never appear, and nothing can shake it out.
|
|
38
|
+
|
|
39
|
+
That is also how a stylesheet shared between components is written — a module of
|
|
40
|
+
its own, exposing an install function its callers run from their render:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
// input_css.js
|
|
44
|
+
const inputCss = /* css */ `
|
|
45
|
+
@layer navi {
|
|
46
|
+
.navi_input {
|
|
47
|
+
/* ... */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
// Keyed on this module, so the controls drawn as a .navi_input box share one
|
|
52
|
+
// stylesheet instead of each carrying a copy.
|
|
53
|
+
export const installInputCss = () => {
|
|
54
|
+
import.meta.css = inputCss;
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
// textarea.jsx
|
|
60
|
+
import { installInputCss } from "./input_css.js";
|
|
61
|
+
|
|
62
|
+
export const Textarea = (props) => {
|
|
63
|
+
installInputCss();
|
|
64
|
+
import.meta.css = css; // this component's own css, and nothing else
|
|
65
|
+
// ...
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Note the setter is keyed by module: **two assignments in one module do not add
|
|
70
|
+
up**, the second replaces the first. One `import.meta.css` per module — which is
|
|
71
|
+
why the shared sheet has to live in a module of its own rather than be
|
|
72
|
+
concatenated into the component's.
|
|
73
|
+
|
|
74
|
+
The exception is a module that is a side effect by design — `navi_css_vars.js`
|
|
75
|
+
and the sheets it pulls in are the app's tokens, always needed, and assign at
|
|
76
|
+
module scope on purpose.
|
|
77
|
+
|
|
78
|
+
### `${}` blinds the whole stylesheet
|
|
79
|
+
|
|
80
|
+
A substitution the build cannot read makes the **entire** template opaque — not
|
|
81
|
+
just the line it sits on — and everything below is lost for all of it:
|
|
82
|
+
|
|
83
|
+
- **comments ship to production**, with every space and newline
|
|
84
|
+
- **nothing is transpiled**: no nesting lowering, no prefixing, no fallback for
|
|
85
|
+
the browsers the app targets
|
|
86
|
+
- **`url("./icon.svg")` is never seen**: the file is not part of the build, not
|
|
87
|
+
copied, not hashed, and the url resolves against the document instead of the
|
|
88
|
+
module — it 404s in production
|
|
89
|
+
- **nothing is checked**: an unclosed brace or an invalid value reaches the
|
|
90
|
+
browser instead of failing the build
|
|
91
|
+
- **nothing is minified**
|
|
92
|
+
|
|
93
|
+
So a `${}` is not a small convenience: it opts a component's whole stylesheet
|
|
94
|
+
out of the build. The one shape that survives is a substitution standing exactly
|
|
95
|
+
**where a css value stands** — inside a rule block, after the `:` of a
|
|
96
|
+
declaration, not in a string, not in `url()`, not in a selector, a property name
|
|
97
|
+
or an at-rule prelude:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
// read, transformed, and the expression put back
|
|
101
|
+
import.meta.css = /* css */ `
|
|
102
|
+
.panel {
|
|
103
|
+
transition: translate ${SETTLE_DURATION_MS}ms ease-out;
|
|
104
|
+
}
|
|
105
|
+
`;
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Anything else ships verbatim, silently. Do not rely on the distinction: write css
|
|
109
|
+
without substitutions.
|
|
110
|
+
|
|
111
|
+
### Writing css without `${}`
|
|
112
|
+
|
|
113
|
+
**A value the JS knows → a custom property.** The css stays static, and the value
|
|
114
|
+
changes without building a new stylesheet:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
// avoid
|
|
118
|
+
const setPanelWidth = (width) => {
|
|
119
|
+
import.meta.css = `.panel { width: ${width}; }`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// prefer
|
|
123
|
+
import.meta.css = /* css */ `
|
|
124
|
+
.panel {
|
|
125
|
+
width: var(--panel-width, 300px);
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
const setPanelWidth = (element, width) => {
|
|
129
|
+
element.style.setProperty("--panel-width", width);
|
|
130
|
+
};
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**An attribute or class name held in a JS constant → write it out in the css.**
|
|
134
|
+
This is the most common way navi's own stylesheets used to go blind: a constant
|
|
135
|
+
exists because JS sets the attribute, and the css then reads it through a
|
|
136
|
+
substitution. Keep the constant for the JS side and write the selector literally
|
|
137
|
+
— it is one string, and it buys back the whole stylesheet:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const SWIPE_AXES_ATTRIBUTE = "data-swipe";
|
|
141
|
+
|
|
142
|
+
// avoid
|
|
143
|
+
import.meta.css = `[${SWIPE_AXES_ATTRIBUTE}="x"] { touch-action: pan-y; }`;
|
|
144
|
+
|
|
145
|
+
// prefer
|
|
146
|
+
import.meta.css = /* css */ `
|
|
147
|
+
[data-swipe="x"] {
|
|
148
|
+
touch-action: pan-y;
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
element.setAttribute(SWIPE_AXES_ATTRIBUTE, "x");
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**A selector you did not want to repeat → nesting.** Reaching into JS for a name
|
|
155
|
+
to avoid typing a selector twice trades a whole stylesheet for a little
|
|
156
|
+
repetition; `&` removes the repetition without leaving css:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
// prefer
|
|
160
|
+
import.meta.css = /* css */ `
|
|
161
|
+
.navi_button {
|
|
162
|
+
color: black;
|
|
163
|
+
&[data-loading] {
|
|
164
|
+
opacity: 0.5;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**A variant → a data attribute, both branches written out.** A condition in JS
|
|
171
|
+
picking a declaration hides the css; a condition picking an attribute does not:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
// avoid
|
|
175
|
+
import.meta.css = `.badge { color: ${tone === "danger" ? "red" : "blue"}; }`;
|
|
176
|
+
|
|
177
|
+
// prefer
|
|
178
|
+
import.meta.css = /* css */ `
|
|
179
|
+
.badge {
|
|
180
|
+
color: blue;
|
|
181
|
+
&[data-tone="danger"] {
|
|
182
|
+
color: red;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**A block of declarations repeated in several rules → a selector list.** A css
|
|
189
|
+
fragment held in a JS constant and interpolated into three rules is three blind
|
|
190
|
+
stylesheets; the same declarations under one selector list are css:
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
// prefer
|
|
194
|
+
import.meta.css = /* css */ `
|
|
195
|
+
:root,
|
|
196
|
+
.navi_popover,
|
|
197
|
+
.navi_dialog {
|
|
198
|
+
--navi-color-hint: color-mix(
|
|
199
|
+
in srgb,
|
|
200
|
+
currentColor var(--navi-color-hint-mix),
|
|
201
|
+
transparent
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**A whole stylesheet shared between modules → a module exposing an install
|
|
208
|
+
function**, as shown at the top of this section.
|
|
209
|
+
|
|
210
|
+
### Beware when moving a shared sheet out
|
|
211
|
+
|
|
212
|
+
Splitting a chunk out of a template changes **cascade order**: the extracted
|
|
213
|
+
sheet is adopted by the install call, which runs before the component's own
|
|
214
|
+
assignment, so it now comes _before_ that sheet instead of after it. That matters only when the two carry
|
|
215
|
+
the same selector at the same specificity and in the same layer — check for that
|
|
216
|
+
before splitting. In navi, `popup_css.js` and `surface_text_css.js` overlap
|
|
217
|
+
`.navi_dialog` and `.navi_popover` with dialog's and popover's own css, which is
|
|
218
|
+
why they are still concatenated.
|
|
219
|
+
|
|
220
|
+
### The build target has to match the css
|
|
221
|
+
|
|
222
|
+
The css is transpiled for the runtimes the build targets, so a target older than
|
|
223
|
+
what the css uses is silently lowered. `light-dark()` under a target below Chrome
|
|
224
|
+
123 becomes `var(--lightningcss-light, …) var(--lightningcss-dark, …)`, an
|
|
225
|
+
invalid declaration — the color is simply not applied. navi's own build targets
|
|
226
|
+
Chrome 123 because navi's css uses `light-dark()`; an app targeting older
|
|
227
|
+
browsers must not use css newer than its target.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
13
231
|
## Layer structure
|
|
14
232
|
|
|
15
233
|
```
|
|
@@ -41,7 +259,7 @@ This means an app can override a Navi default from its own unlayered CSS, withou
|
|
|
41
259
|
}
|
|
42
260
|
```
|
|
43
261
|
|
|
44
|
-
Targeting the same element is not a detail — see [`--navi-*` vs `--component-*`](#--navi
|
|
262
|
+
Targeting the same element is not a detail — see [`--navi-*` vs `--component-*`](#--navi--vs---component--where-the-override-has-to-go) below.
|
|
45
263
|
|
|
46
264
|
### Why actual rules stay outside any layer
|
|
47
265
|
|
package/docs/navigation.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
How to build navigation with `@jsenv/navi`: declaring routes, rendering them,
|
|
4
4
|
linking to them, and turning them into tabs.
|
|
5
5
|
|
|
6
|
+
- [The rule that decides everything else: the position belongs in the URL](#the-rule-that-decides-everything-else-the-position-belongs-in-the-url)
|
|
7
|
+
- [Declaring routes](#declaring-routes)
|
|
8
|
+
- [A section is allowed to be a route of its own](#a-section-is-allowed-to-be-a-route-of-its-own)
|
|
9
|
+
- [Which values a param accepts](#which-values-a-param-accepts)
|
|
10
|
+
- [An address that only sends elsewhere](#an-address-that-only-sends-elsewhere)
|
|
11
|
+
- [Search params](#search-params)
|
|
12
|
+
- [Rendering routes](#rendering-routes)
|
|
13
|
+
- [Loading data](#loading-data)
|
|
14
|
+
- [Links and tab rows](#links-and-tab-rows)
|
|
15
|
+
- [The back arrow: `navBack`](#the-back-arrow-navback)
|
|
16
|
+
- [Tabs that travel: `RouteTravel`](#tabs-that-travel-routetravel)
|
|
17
|
+
- [Where a navigation lands: the scroll](#where-a-navigation-lands-the-scroll)
|
|
18
|
+
- [Creating something, then editing it](#creating-something-then-editing-it)
|
|
19
|
+
- [Tabs that are not routes](#tabs-that-are-not-routes)
|
|
20
|
+
- [A `SlideContainer` in the URL: a position that is not a place one came from](#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from)
|
|
21
|
+
- [A state whose values ARE places: `history: "push"`](#a-state-whose-values-are-places-history-push)
|
|
22
|
+
|
|
6
23
|
## The rule that decides everything else: the position belongs in the URL
|
|
7
24
|
|
|
8
25
|
Where the user is — which section, which tab, which sub-page — is state. Put it
|
|
@@ -21,8 +38,10 @@ retrofitted later:
|
|
|
21
38
|
So the default shape of a tab row is routes: `<Nav>` + `<Link route>` +
|
|
22
39
|
`<RouteTravel>`. `SlideContainer` is the exception, not the starting point — see
|
|
23
40
|
[Tabs that are not routes](#tabs-that-are-not-routes) for the cases that
|
|
24
|
-
genuinely are one, and
|
|
25
|
-
|
|
41
|
+
genuinely are one, and
|
|
42
|
+
[A `SlideContainer` in the URL](#a-slidecontainer-in-the-url-a-position-that-is-not-a-place-one-came-from)
|
|
43
|
+
for the middle answer: a position READ from the URL and restored on reload,
|
|
44
|
+
without a route and without a history entry per step.
|
|
26
45
|
|
|
27
46
|
## Declaring routes
|
|
28
47
|
|
|
@@ -289,6 +308,11 @@ Declared on the **root route**, a search param is a position that holds wherever
|
|
|
289
308
|
one is in the application — a view mode that survives moving from page to page.
|
|
290
309
|
Declared on one route, it exists only there.
|
|
291
310
|
|
|
311
|
+
Writing it AMENDS the history entry one is on rather than stacking a new one: a
|
|
312
|
+
param that qualifies a screen is not a place, and one entry per write turns a
|
|
313
|
+
single back-press into as many as the user moved. A state whose values ARE
|
|
314
|
+
places says so — see [`history: "push"`](#a-state-whose-values-are-places-history-push).
|
|
315
|
+
|
|
292
316
|
## Rendering routes
|
|
293
317
|
|
|
294
318
|
`<Route>` is the only primitive. With `children` it is a container that renders
|
|
@@ -575,9 +599,9 @@ match at once; the first matching branch wins).
|
|
|
575
599
|
## Tabs that are not routes
|
|
576
600
|
|
|
577
601
|
`SlideContainer` holds slides that replace one another in one box, with the same
|
|
578
|
-
gestures and the same travelling bar, and — unless it is
|
|
579
|
-
below — nothing written to the URL. Use it when the
|
|
580
|
-
place one should be able to link to:
|
|
602
|
+
gestures and the same travelling bar, and — unless the signal it is bound to is
|
|
603
|
+
one the URL holds, see below — nothing written to the URL. Use it when the
|
|
604
|
+
position genuinely is not a place one should be able to link to:
|
|
581
605
|
|
|
582
606
|
- the steps of a wizard, or the screens of a picker, inside a dialog or a popover
|
|
583
607
|
— a popup is promoted to the browser's top layer, so no container can hold two
|
|
@@ -604,7 +628,7 @@ page. It reads which slide is on screen from the container itself, and its bar
|
|
|
604
628
|
follows the slides, a finger dragging them included. `<Link slide>` has no href
|
|
605
629
|
and behaves like a button: this is not a link to anywhere.
|
|
606
630
|
|
|
607
|
-
###
|
|
631
|
+
### A `SlideContainer` in the URL: a position that is not a place one came from
|
|
608
632
|
|
|
609
633
|
"Should a link be able to open the app on this?" has a third answer, and a wizard
|
|
610
634
|
is exactly it: **yes for reading and for reloading, no for history.** The step one
|
|
@@ -615,48 +639,111 @@ and it should NOT stack an entry per step, because the back arrow of a form mean
|
|
|
615
639
|
back-press into four, and walk the reader backwards through a form they thought
|
|
616
640
|
they had left.
|
|
617
641
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
guards. A plain `SlideContainer` writes nothing at all.
|
|
642
|
+
There is nothing to invent for it: a search param already IS a position in the
|
|
643
|
+
URL that replaces rather than pushes ([Search params](#search-params)). Declare
|
|
644
|
+
the step as one, and hand its signal to the container:
|
|
622
645
|
|
|
623
|
-
|
|
624
|
-
|
|
646
|
+
```js
|
|
647
|
+
const stepSignal = stateSignal("when", {
|
|
648
|
+
id: "step",
|
|
649
|
+
oneOf: ["when", "where", "who", "recap", "done"],
|
|
650
|
+
// the step qualifies THIS visit, not the screen: a link built to the editor
|
|
651
|
+
// does not inherit the step one happens to be on, and it goes back to the
|
|
652
|
+
// default when the route stops matching
|
|
653
|
+
weak: true,
|
|
654
|
+
});
|
|
655
|
+
export const ALERT_EDIT_ROUTE = route("/alerts/:alertId/edit", {
|
|
656
|
+
searchParams: { step: stepSignal },
|
|
657
|
+
});
|
|
658
|
+
```
|
|
625
659
|
|
|
626
660
|
```jsx
|
|
627
|
-
<SlideContainer
|
|
661
|
+
<SlideContainer signal={stepSignal}>
|
|
628
662
|
```
|
|
629
663
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
- it
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
the
|
|
641
|
-
|
|
664
|
+
That is the whole wiring, and every half of it is the piece that already
|
|
665
|
+
existed. Worth naming, because each answers a question a wizard actually has:
|
|
666
|
+
|
|
667
|
+
- **where it opens is the state's own default** — the first argument of
|
|
668
|
+
`stateSignal`. The container has a `defaultCurrent` for when it owns its
|
|
669
|
+
position, but a bound container does not own it: one place says where the step
|
|
670
|
+
starts, and it is the same place a reset goes back to;
|
|
671
|
+
- **the param is absent while the step IS that default** (route.js prunes it), so
|
|
672
|
+
`/alerts/W-123/edit` is clean on the first step, gains `?step=where` on the
|
|
673
|
+
second, and loses it again coming back. The address carries what differs from
|
|
674
|
+
the usual answer and nothing else;
|
|
675
|
+
- **the container walks to the step rather than jumping to it.** The address comes
|
|
676
|
+
from outside the box (typed, shared, kept from a session that has moved on), so
|
|
677
|
+
every slide between here and there is asked to let go the way a key going that
|
|
678
|
+
way would ask it, and the first one that holds is where one stops. `?step=done`
|
|
679
|
+
cannot open a confirmation screen for something nobody sent. The signal is then
|
|
680
|
+
written with the area actually shown, so the address never says one is
|
|
681
|
+
somewhere one is not;
|
|
682
|
+
- **only the travels that HAPPENED are written.** A travel a lock refused, or one
|
|
683
|
+
`onCurrentChange` refused late, never reaches the signal — or is written back
|
|
684
|
+
when it does.
|
|
642
685
|
|
|
643
686
|
A container remembers nothing across a reload, so a step whose `required` the app
|
|
644
687
|
knows is already satisfied says so itself (`required={!alreadyFilled}`); the same
|
|
645
688
|
holds for a hold that a finished job lifts (`preventNavNext={!published}`).
|
|
646
689
|
|
|
647
|
-
|
|
648
|
-
|
|
690
|
+
**A start that depends on the page one is on** — a wizard creating something
|
|
691
|
+
opens on its first question, the same wizard editing something opens on its
|
|
692
|
+
summary — is said by the ROUTE, not by the state and not by the container:
|
|
649
693
|
|
|
650
|
-
```
|
|
651
|
-
|
|
694
|
+
```js
|
|
695
|
+
const stepSignal = stateSignal("when", { id: "step", weak: true });
|
|
696
|
+
route("/alerts/create", { searchParams: { step: stepSignal } });
|
|
697
|
+
route("/alerts/:alertId/edit", {
|
|
698
|
+
searchParams: { step: { signal: stepSignal, default: "recap" } },
|
|
699
|
+
});
|
|
652
700
|
```
|
|
653
701
|
|
|
654
|
-
|
|
655
|
-
|
|
702
|
+
Everything that asks "is this the default" then asks the route one is on: both
|
|
703
|
+
addresses stay clean on the step they open on — `/alerts/create` on "when",
|
|
704
|
+
`/alerts/W-123/edit` on "recap" — an address naming no step puts the state back
|
|
705
|
+
on that page's own, and going from one to the other moves the step with it.
|
|
656
706
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
route
|
|
707
|
+
Not a `computed` over the route's `matchingSignal`: a state and the route that
|
|
708
|
+
declares it cannot name each other — whichever is written first, the other is
|
|
709
|
+
not initialised yet, and a dynamic default is read while `stateSignal` is being
|
|
710
|
+
built. The route is the right place regardless; it is the thing that knows which
|
|
711
|
+
page one is on.
|
|
712
|
+
|
|
713
|
+
Two containers on one screen are two signals, and that is the whole answer to
|
|
714
|
+
"which one owns the param": the one holding the route's signal. A gallery of
|
|
715
|
+
seven wizards side by side hands each of them a `useSignal` of its own, and
|
|
716
|
+
nothing goes into the address.
|
|
717
|
+
|
|
718
|
+
### A state whose values ARE places: `history: "push"`
|
|
719
|
+
|
|
720
|
+
Replacement is the default because most URL-held state qualifies the screen one
|
|
721
|
+
is on. A state whose values are places one came from — the photo being looked at
|
|
722
|
+
in a gallery — says so where the state is declared:
|
|
723
|
+
|
|
724
|
+
```js
|
|
725
|
+
const photoSignal = stateSignal(undefined, { id: "photo", history: "push" });
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
Every write of it then stacks an entry, wherever the write comes from. Except
|
|
729
|
+
where the writer knows this particular move is not one: a slide reached by
|
|
730
|
+
DRAGGING replaces even in a container that pushes, because swiping back and forth
|
|
731
|
+
with a thumb is browsing, not a trail one wants to walk home along. That is said
|
|
732
|
+
at the write rather than declared:
|
|
733
|
+
|
|
734
|
+
```js
|
|
735
|
+
photoSignal.set(nextPhoto, { history: "replace" });
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
`SlideContainer` already does exactly that for its own drags, so a gallery gets
|
|
739
|
+
it by declaring the state and nothing else.
|
|
740
|
+
|
|
741
|
+
### What this is not
|
|
742
|
+
|
|
743
|
+
It is not a route. Nothing is declared for the steps themselves, nothing matches
|
|
744
|
+
on them, and no page transition plays — a transition needs the page to change
|
|
745
|
+
([route_transitions.md](./route_transitions.md)), and a search param moving is
|
|
746
|
+
not one. What travels is the box, and the address is a label on where the box
|
|
747
|
+
stands. A position several parts of the app must react to is still a route.
|
|
661
748
|
|
|
662
749
|
Demo: [../src/layout/demos/8_slide_container_demo.html](../src/layout/demos/8_slide_container_demo.html).
|