@jsenv/navi 0.29.143 → 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 +6172 -9042
- package/dist/jsenv_navi.js.map +38 -39
- package/dist/jsenv_navi_side_effects.js +129 -491
- package/dist/jsenv_navi_side_effects.js.map +2 -4
- package/docs/AI_INSTRUCTIONS.md +3 -0
- package/docs/actions.md +41 -4
- package/docs/css_architecture.md +218 -0
- package/docs/navigation.md +18 -13
- package/docs/popup_open.md +117 -17
- 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
|
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
|
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
|
```
|
package/docs/navigation.md
CHANGED
|
@@ -687,23 +687,28 @@ A container remembers nothing across a reload, so a step whose `required` the ap
|
|
|
687
687
|
knows is already satisfied says so itself (`required={!alreadyFilled}`); the same
|
|
688
688
|
holds for a hold that a finished job lifts (`preventNavNext={!published}`).
|
|
689
689
|
|
|
690
|
-
**A start that depends on
|
|
691
|
-
its first question, the same wizard editing something opens on its
|
|
692
|
-
|
|
693
|
-
and follows it as long as nobody has answered otherwise.
|
|
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:
|
|
694
693
|
|
|
695
694
|
```js
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
);
|
|
701
|
-
const stepSignal = stateSignal(stepDefaultSignal, { id: "step", weak: true });
|
|
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
|
+
});
|
|
702
700
|
```
|
|
703
701
|
|
|
704
|
-
|
|
705
|
-
on — `/alerts/create` on "when",
|
|
706
|
-
|
|
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.
|
|
706
|
+
|
|
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.
|
|
707
712
|
|
|
708
713
|
Two containers on one screen are two signals, and that is the whole answer to
|
|
709
714
|
"which one owns the param": the one holding the route's signal. A gallery of
|
package/docs/popup_open.md
CHANGED
|
@@ -11,7 +11,8 @@ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
|
|
|
11
11
|
- [A press that opens a popup and acts on it](#a-press-that-opens-a-popup-and-acts-on-it)
|
|
12
12
|
- [Reacting to open and close](#reacting-to-open-and-close)
|
|
13
13
|
- [Escape cancels, the other gestures keep](#escape-cancels-the-other-gestures-keep)
|
|
14
|
-
- [When
|
|
14
|
+
- [When the app holds the open state](#when-the-app-holds-the-open-state)
|
|
15
|
+
- [A popup that loads data](#a-popup-that-loads-data)
|
|
15
16
|
- [What the popup holds while it is closed](#what-the-popup-holds-while-it-is-closed)
|
|
16
17
|
|
|
17
18
|
Where the focus goes once it is open is its own subject — see
|
|
@@ -38,13 +39,15 @@ Escape, the backdrop, a close button — all of them ask, and the busy control
|
|
|
38
39
|
answers, the same way it would answer anyone else.
|
|
39
40
|
|
|
40
41
|
So the question is never "should this popup be controlled?" but "what triggers
|
|
41
|
-
the opening?"
|
|
42
|
+
the opening?" — and, when the answer is the application rather than a gesture,
|
|
43
|
+
where that state lives:
|
|
42
44
|
|
|
43
|
-
| what opens it | how
|
|
44
|
-
| ---------------------------------- |
|
|
45
|
-
| a button | `command` / `commandfor` attributes, no `open`
|
|
46
|
-
| a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open`
|
|
47
|
-
|
|
|
45
|
+
| what opens it | how |
|
|
46
|
+
| ---------------------------------- | ------------------------------------------------------ |
|
|
47
|
+
| a button | `command` / `commandfor` attributes, no `open` |
|
|
48
|
+
| a gesture, an event, a JS decision | `triggerNaviCommand(...)`, still no `open` |
|
|
49
|
+
| a state the app holds | `signal` — [below](#when-the-app-holds-the-open-state) |
|
|
50
|
+
| where the user is | `navState`, or a route `stateSignal` given to `signal` |
|
|
48
51
|
|
|
49
52
|
## A button opens it: the attributes
|
|
50
53
|
|
|
@@ -459,24 +462,121 @@ A dialog picker's cancel also goes back in history, so anything written to the
|
|
|
459
462
|
url while it was open (a route `stateSignal`, a search param) goes back with it
|
|
460
463
|
— one more reason Escape and the click outside are not interchangeable.
|
|
461
464
|
|
|
462
|
-
## When
|
|
465
|
+
## When the app holds the open state
|
|
463
466
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
decides to show
|
|
467
|
+
A popup whose being-open is a fact about the application rather than about the
|
|
468
|
+
user's last gesture — a sheet an address can be reloaded into, an error the app
|
|
469
|
+
decides to show — needs somewhere to keep that fact. Three props say it, and
|
|
470
|
+
what separates them is where the state lives, not how hard the popup is driven:
|
|
471
|
+
all three go through the same `requestClose`, so a busy control inside still
|
|
472
|
+
refuses to be left mid-action. What changes is whether anyone hears the refusal.
|
|
467
473
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
+
### `signal` — the app holds it, both ways
|
|
475
|
+
|
|
476
|
+
```jsx
|
|
477
|
+
<Dialog signal={groupSheetOpenSignal} />
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
The popup opens and closes to match the signal, and writes into it whenever it
|
|
481
|
+
opens or closes on its own: Escape, the backdrop, a `--navi-close` command — and
|
|
482
|
+
the close a busy control denied, after which the signal says "open", because
|
|
483
|
+
that is what is true. One binding both drives the popup and knows where it is,
|
|
484
|
+
which is what every navi control does with its value (see
|
|
485
|
+
[state_binding.md](./state_binding.md)); `Dialog`, `Popover` and what is built
|
|
486
|
+
on them (`SidePanel`) all take it. A signal already `true` at mount means the
|
|
487
|
+
popup was already open when the page appeared: no entrance plays.
|
|
488
|
+
|
|
489
|
+
### `navState` — the history entry holds it
|
|
490
|
+
|
|
491
|
+
```jsx
|
|
492
|
+
<Dialog id="group-sheet" navState={{ type: "push" }} />
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
The open state goes into the history entry, so a screen left and come back to
|
|
496
|
+
finds the popup as it was, and so does a reload. `true` stores it under the
|
|
497
|
+
popup's own `id`; a string names the key instead. `{ type: "push" }` makes the
|
|
498
|
+
opening an entry of its own — the back button then closes the popup rather than
|
|
499
|
+
leaving the screen, and the cancel takes back with it whatever was written to
|
|
500
|
+
the url while it was open. A `Picker` needs none of this: its popup's open state
|
|
501
|
+
is nav state by construction.
|
|
502
|
+
|
|
503
|
+
The two meet when the signal IS a route's: a search-param `stateSignal` given to
|
|
504
|
+
`signal` puts the open state in the address itself, where a link can point at
|
|
505
|
+
it.
|
|
506
|
+
|
|
507
|
+
### `open`, and what it costs
|
|
508
|
+
|
|
509
|
+
`open` is the one-way half of `signal` — the parent re-renders with a boolean
|
|
510
|
+
and the popup follows — and the refusal is what it costs. `open={false}` goes
|
|
511
|
+
through the same `requestClose`, and when a busy control denies it, the parent's
|
|
512
|
+
state says closed while the popup stayed open. The two disagree from then on:
|
|
513
|
+
the effect only reacts to `open` _changing_, so setting it back to `true`
|
|
514
|
+
matches the popup's real state and does nothing, and the popup can no longer be
|
|
515
|
+
closed by the prop at all until it closes on its own. A `signal` has no such
|
|
516
|
+
gap, since the popup writes back what really happened.
|
|
474
517
|
|
|
475
518
|
`defaultOpen` is the middle ground: mount-only, and the popup owns everything
|
|
476
519
|
afterwards. `defaultOpen="interaction"` means the mount _is_ the opening (the
|
|
477
520
|
entrance animation plays); any other truthy value means it was already open when
|
|
478
521
|
the page appeared (no entrance).
|
|
479
522
|
|
|
523
|
+
## A popup that loads data
|
|
524
|
+
|
|
525
|
+
Being open is where the user is, and what a popup draws belongs to the screen
|
|
526
|
+
exactly the way a page's own data does. So a popup that loads something binds
|
|
527
|
+
its open state (above) and asks for its data with a `routeAction` whose params
|
|
528
|
+
are `false` while it is closed:
|
|
529
|
+
|
|
530
|
+
```js
|
|
531
|
+
const groupSheetOpenSignal = stateSignal(false, {
|
|
532
|
+
id: "group_sheet",
|
|
533
|
+
type: "boolean",
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
export const GROUP_MEMBERS = routeAction(GAME_ROUTE, USER.GET_MANY, () => {
|
|
537
|
+
if (!groupSheetOpenSignal.value) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
return { group: groupSignal.value };
|
|
541
|
+
});
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
```jsx
|
|
545
|
+
<Dialog signal={groupSheetOpenSignal}>
|
|
546
|
+
<GroupMembers />
|
|
547
|
+
</Dialog>;
|
|
548
|
+
|
|
549
|
+
const GroupMembers = () => {
|
|
550
|
+
const [members] = useAsyncData(GROUP_MEMBERS); // reads, never runs
|
|
551
|
+
…
|
|
552
|
+
};
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
What the popup gains is everything a page has:
|
|
556
|
+
|
|
557
|
+
- the request leaves **with the screen**, in parallel with the rest of what the
|
|
558
|
+
address needs, instead of behind the gesture that opens the popup;
|
|
559
|
+
- `useAsyncData` reads it — the wait delegated to `<Loading>`, the failure to
|
|
560
|
+
`<ErrorBoundary>`, `onLoad` for what is seeded once (see
|
|
561
|
+
[actions.md](./actions.md#reading-an-action));
|
|
562
|
+
- the action layer's rerun rules, its dependencies
|
|
563
|
+
([resource_dependencies.md](./resource_dependencies.md)) and the
|
|
564
|
+
aborted-not-reset treatment a route action gets when the screen is left
|
|
565
|
+
([offline.md](./offline.md));
|
|
566
|
+
- a reload keeps the open state, so it keeps the request too;
|
|
567
|
+
- and the routes keep saying what an address needs, which is why they declare
|
|
568
|
+
it.
|
|
569
|
+
|
|
570
|
+
The fallback — the popup owning its request, `useAsyncData(action, { run: true })`
|
|
571
|
+
— is for the parameter chosen inside the popup and dying with it: a filter the
|
|
572
|
+
sheet itself holds and nothing else remembers. It cannot do better than the
|
|
573
|
+
gesture that opened the popup, since it starts with the component that draws it;
|
|
574
|
+
a route action left with the screen. Hand-written (`bindParams` in the component,
|
|
575
|
+
`run()` from a `useEffect`, `data === undefined` as the loading flag) it looks
|
|
576
|
+
harmless, and that is the problem: nothing at the call site says which of the two
|
|
577
|
+
it is, so the day the parameter comes from the address, screen data has quietly
|
|
578
|
+
become a popup's private request — asked for late, and asked for alone.
|
|
579
|
+
|
|
480
580
|
## What the popup holds while it is closed
|
|
481
581
|
|
|
482
582
|
A closed popup builds nothing: `children` are mounted on the first open, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.144",
|
|
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.35",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.3"
|
|
35
35
|
},
|