@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.
@@ -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 and does not suspend on it: a
111
- folding panel, a slice a button asks for. That one reads `action.dataSignal` and
112
- `action.errorSignal` (or `useActionStatus`) directly, `run()`s the action from an
113
- effect, and draws its own skeleton until the data is there.
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
@@ -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
  ```
@@ -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 where one is** — a wizard creating something opens on
691
- its first question, the same wizard editing something opens on its summary — is
692
- not a prop on the container either: a `stateSignal` takes a SIGNAL as its default
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 stepDefaultSignal = computed(() =>
697
- // `matchingSignal`, not `matching`: the plain property is a mirror, the
698
- // signal is what a computed can follow
699
- ALERT_EDIT_ROUTE.matchingSignal.value ? "recap" : "when",
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
- The pruning follows it too, so both addresses stay clean on the step they open
705
- on — `/alerts/create` on "when", `/alerts/W-123/edit` on "recap" — and a step
706
- someone actually chose survives the default moving under it.
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
@@ -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 `open` is the right answer, and what it costs](#when-open-is-the-right-answer-and-what-it-costs)
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
- | it is a piece of application state | `open` — see the cost below |
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 `open` is the right answer, and what it costs
465
+ ## When the app holds the open state
463
466
 
464
- `open` is for a popup whose being-open is a fact about the application, not
465
- about the user's last gesture: a route that _is_ a dialog, an error the app
466
- decides to show. Use it there, and know what it changes.
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
- The busy arbitration still runs — `open={false}` goes through the same
469
- `requestClose` — but nobody hears the refusal. The parent's state says closed,
470
- the popup stayed open, and the two disagree from then on: the effect only reacts
471
- to `open` _changing_, so setting it back to `true` matches the popup's real
472
- state and does nothing, and the popup can no longer be closed by the prop at
473
- all until it closes on its own.
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.143",
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.34",
32
+ "@jsenv/dom": "0.17.35",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.3"
35
35
  },