@jsenv/navi 0.29.26 → 0.29.28
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 +4 -0
- package/dist/jsenv_navi.js +243 -87
- package/dist/jsenv_navi.js.map +11 -11
- package/docs/AI_INSTRUCTIONS.md +10 -0
- package/docs/i18n.md +183 -0
- package/docs/navigation.md +41 -0
- package/docs/resource.md +11 -11
- package/package.json +2 -2
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -47,6 +47,10 @@ consistency across the app, not from any single call site.
|
|
|
47
47
|
- `docs/z_index.md` — stacking: why DOM order is the first tool, what a
|
|
48
48
|
`z-index` without `isolation: isolate` actually competes against, and the
|
|
49
49
|
values navi's own popups/bars/tables use. Read it before writing a `z-index`.
|
|
50
|
+
- `docs/i18n.md` — where the texts an app displays live: `interpolateText` /
|
|
51
|
+
`<Interpolate>` for one sentence, `createI18n` for the app's registry,
|
|
52
|
+
`naviI18n` for navi's own texts. Read it before writing a user-visible
|
|
53
|
+
sentence, and before overriding a navi message.
|
|
50
54
|
- `docs/MOBILE_LAYOUT_PITFALLS.md` — mobile-specific layout gotchas (viewport
|
|
51
55
|
units, virtual keyboard, safe areas).
|
|
52
56
|
- `docs/navigation.md` — how to build navigation: declaring routes
|
|
@@ -80,6 +84,12 @@ consistency across the app, not from any single call site.
|
|
|
80
84
|
- **Field components** (`Input`, `Select`, `Checkbox`, etc.) take an `action`
|
|
81
85
|
prop to respond to interaction — this is the standard wiring, not
|
|
82
86
|
`onChange` + manual state.
|
|
87
|
+
- **Texts**: a user-visible sentence containing a value is written as one
|
|
88
|
+
template with `[placeholder]`s (`interpolateText` / `<Interpolate>`), not cut
|
|
89
|
+
into JSX fragments or concatenations. Beyond a handful of texts, an app
|
|
90
|
+
declares them in its own `createI18n()` instance — using the English text
|
|
91
|
+
itself as the key, whereas navi's `naviI18n` uses opaque keys. Application
|
|
92
|
+
texts never go into `naviI18n`. See `docs/i18n.md`.
|
|
83
93
|
- **View transitions**: navi components animate their own changes
|
|
84
94
|
(`itemTransition` on `List`, `RouteTravel` for routes) and never decide for
|
|
85
95
|
the whole document. Two things are the application's call, not navi's:
|
package/docs/i18n.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Texts and i18n
|
|
2
|
+
|
|
3
|
+
Every text an app displays is a value it owns, like any other. The goal here is
|
|
4
|
+
that those values live somewhere findable and are written as whole sentences —
|
|
5
|
+
not cut into JSX fragments, not concatenated at the call site, not scattered so
|
|
6
|
+
widely that changing the tone of the app means grepping for punctuation.
|
|
7
|
+
|
|
8
|
+
Translation is a consequence of doing that, not the reason to do it. An app with
|
|
9
|
+
one language still benefits from one place holding its wording; and once it does,
|
|
10
|
+
adding a second language becomes a data change instead of a refactor.
|
|
11
|
+
|
|
12
|
+
Three tools, from smallest commitment to largest:
|
|
13
|
+
|
|
14
|
+
| Tool | Gives you |
|
|
15
|
+
| ----------------------- | ---------------------------------------- |
|
|
16
|
+
| `interpolateText(t, v)` | one readable sentence with values in it |
|
|
17
|
+
| `<Interpolate>` | the same, with JSX allowed in the values |
|
|
18
|
+
| `createI18n()` | a registry of the app's texts |
|
|
19
|
+
| `naviI18n` | navi's own texts, to override |
|
|
20
|
+
|
|
21
|
+
## Writing a sentence: `interpolateText` / `<Interpolate>`
|
|
22
|
+
|
|
23
|
+
Use these the moment a sentence contains a value. They need no i18n instance and
|
|
24
|
+
no setup, so there is no reason to avoid them in a single-language app.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { interpolateText } from "@jsenv/navi";
|
|
28
|
+
|
|
29
|
+
interpolateText("Deleting [name]…", { name: file.name });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
In JSX, `<Interpolate>` keeps the sentence readable when part of it is styled:
|
|
33
|
+
|
|
34
|
+
```jsx
|
|
35
|
+
<Interpolate radiusKm={<Text bold>{radius} km</Text>} zoneName={zone}>
|
|
36
|
+
Data limited to [radiusKm] around [zoneName].
|
|
37
|
+
</Interpolate>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The point is that the full sentence is visible in one place. The alternative —
|
|
41
|
+
`Data limited to <Text bold>{radius} km</Text> around {zone}.` — reads as
|
|
42
|
+
fragments, and cannot later become a translation key, because there is no
|
|
43
|
+
single string to be the key.
|
|
44
|
+
|
|
45
|
+
Placeholders are `[name]`. The delimiter is `[]` rather than `{}`/`{{}}` because
|
|
46
|
+
it collides with neither JSX nor template literals nor ordinary punctuation.
|
|
47
|
+
Details worth knowing (all in `interpolateText`'s JSDoc): a value can be a
|
|
48
|
+
dot-path (`[item.label]`), a function (called only if the placeholder is
|
|
49
|
+
present), and a placeholder with no value is left visible as `[name]` rather
|
|
50
|
+
than disappearing.
|
|
51
|
+
|
|
52
|
+
## Centralizing texts: `createI18n`
|
|
53
|
+
|
|
54
|
+
Once several components display text, give the app a registry. One instance for
|
|
55
|
+
the whole app, exported from one module:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// src/app_i18n.js
|
|
59
|
+
import { createI18n } from "@jsenv/navi";
|
|
60
|
+
|
|
61
|
+
export const i18n = createI18n({ keyLang: "en" });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then call it wherever a text is needed. It is callable directly:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { i18n } from "@/app_i18n.js";
|
|
68
|
+
|
|
69
|
+
i18n("Deleting [name]…", { name: file.name });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### In an app, the text is the key
|
|
73
|
+
|
|
74
|
+
`keyLang: "en"` means "keys are already written in English". The key doubles as
|
|
75
|
+
its own English template, so a single-language app registers nothing at all and
|
|
76
|
+
still gets readable output, and adding French later touches only the registry:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
i18n.add("Deleting [name]…", { fr: "Suppression de [name]…" });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is the recommended style for applications, because it keeps the call site
|
|
83
|
+
readable. `i18n("Deleting [name]…")` says what appears on screen;
|
|
84
|
+
`i18n("file.delete.progress")` requires a lookup to know. The cost — changing
|
|
85
|
+
the English wording changes the key, so its translations must be updated
|
|
86
|
+
alongside — is the smaller problem in practice, and it is a mechanical one.
|
|
87
|
+
|
|
88
|
+
### In navi, the key is opaque
|
|
89
|
+
|
|
90
|
+
`naviI18n` uses opaque keys (`"list.empty"`, `"constraint.required.email"`)
|
|
91
|
+
because a library's texts are addressed from the outside: an app overriding a
|
|
92
|
+
message needs a stable name that does not move when navi rewords its default
|
|
93
|
+
English. Same reasoning applies to any shared component library — the trade-off
|
|
94
|
+
flips with who owns the text.
|
|
95
|
+
|
|
96
|
+
Without `keyLang`, every language is explicit, English included:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const i18n = createI18n();
|
|
100
|
+
i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Registering
|
|
104
|
+
|
|
105
|
+
Three ways in, all accumulative — re-registering a key replaces that one key and
|
|
106
|
+
leaves the rest alone:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
i18n.add("Save", { fr: "Enregistrer" }); // one key, many languages
|
|
110
|
+
i18n.addAll({ Save: { fr: "Enregistrer" }, Cancel: { fr: "Annuler" } });
|
|
111
|
+
i18n.addLangKeys("fr", frenchPack); // a whole language, e.g. a fetched JSON file
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A regional variant inherits from its parent for every key it does not override,
|
|
115
|
+
resolved at registration time — so register `"fr"` before `"fr-CA"`.
|
|
116
|
+
|
|
117
|
+
### What comes out
|
|
118
|
+
|
|
119
|
+
`i18n(key, values, { lang })` returns the translation with placeholders
|
|
120
|
+
replaced. When nothing matches it returns the key itself, so a missing
|
|
121
|
+
translation degrades to readable text rather than blank space or a crash. Pass
|
|
122
|
+
`fallbackLang` at creation to fall through to another language first, per key —
|
|
123
|
+
a half-translated language stays usable.
|
|
124
|
+
|
|
125
|
+
`i18n.has(key)` is the way to tell "no translation" apart from "translation that
|
|
126
|
+
happens to equal the key".
|
|
127
|
+
|
|
128
|
+
## Which language
|
|
129
|
+
|
|
130
|
+
Nothing needs configuring for navi and app texts to agree on a language: both
|
|
131
|
+
default to `languagesSignal`, which is read live on every lookup — the browser's
|
|
132
|
+
`navigator.languages`, plus two app-level overrides.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
import { setSupportedLanguages, setPreferredLanguage } from "@jsenv/navi";
|
|
136
|
+
|
|
137
|
+
setSupportedLanguages(["en", "fr"]); // what the app offers, at startup
|
|
138
|
+
setPreferredLanguage("fr"); // what this user picked, from a language picker
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`setSupportedLanguages` matters more than it looks: without it, a browser
|
|
142
|
+
preferring German resolves to German, and every key missing in German falls back
|
|
143
|
+
to its key. With it, that browser lands on the app's best available language
|
|
144
|
+
instead.
|
|
145
|
+
|
|
146
|
+
Because the language is read at lookup time, a component that reads `i18n(...)`
|
|
147
|
+
during render re-renders with the new wording when the language changes — as
|
|
148
|
+
long as the call happens in render, not in a value captured once outside it.
|
|
149
|
+
|
|
150
|
+
## Changing what navi says
|
|
151
|
+
|
|
152
|
+
`naviI18n` holds navi's own texts. Register on it to override a default or to
|
|
153
|
+
add a language navi does not ship:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { naviI18n } from "@jsenv/navi";
|
|
157
|
+
|
|
158
|
+
naviI18n.add("list.empty", {
|
|
159
|
+
en: "Nothing here yet.",
|
|
160
|
+
fr: "Rien pour l'instant.",
|
|
161
|
+
});
|
|
162
|
+
naviI18n.addLangKeys("ja", { "button.close": "閉じる" });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Its JSDoc lists the key namespaces; the exhaustive list of keys and defaults is
|
|
166
|
+
the registration block in `src/text/navi_i18n.js`, which is meant to be read.
|
|
167
|
+
|
|
168
|
+
Do not put application texts in `naviI18n`. Key collisions aside, the two have
|
|
169
|
+
different lifecycles: navi's keys come with the package version, the app's come
|
|
170
|
+
with the app.
|
|
171
|
+
|
|
172
|
+
One convention to know, since it is what `<Quantity>`/`<Unit>` look up: a unit
|
|
173
|
+
name may have `<unit>__plural` and `<unit>__short` variants, both optional
|
|
174
|
+
(missing ones fall back to the singular). An unregistered unit goes to
|
|
175
|
+
`Intl.NumberFormat`, so only units Intl gets wrong or does not know need
|
|
176
|
+
registering at all.
|
|
177
|
+
|
|
178
|
+
## Choosing, in one line
|
|
179
|
+
|
|
180
|
+
Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
|
|
181
|
+
it. Reach for `createI18n` as soon as more than a handful of texts exist — the
|
|
182
|
+
migration from the first to the second is wrapping the string in a call, which
|
|
183
|
+
is why starting with plain interpolation costs nothing.
|
package/docs/navigation.md
CHANGED
|
@@ -98,6 +98,47 @@ const sectionSignal = stateSignal("to_come", {
|
|
|
98
98
|
export const GAMES_SECTION_ROUTE = route(`/games/:section=${sectionSignal}`);
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
#### Declaring the sections is what makes them places
|
|
102
|
+
|
|
103
|
+
Both forms can be written together, and they say different things — which is
|
|
104
|
+
why declaring the literals is not decoration:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
export const MY_GAMES_ROUTE = route(`/games/me/:section=${sectionSignal}`);
|
|
108
|
+
export const MY_GAMES_TO_COME_ROUTE = route("/games/me"); // the default: no segment
|
|
109
|
+
export const MY_GAMES_CANDIDATE_ROUTE = route("/games/me/candidate");
|
|
110
|
+
export const MY_GAMES_DONE_ROUTE = route("/games/me/done");
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Standing on `/games/me/done`:
|
|
114
|
+
|
|
115
|
+
- `MY_GAMES_ROUTE.buildUrl()` → `/games/me/done`. The parameterized route reads
|
|
116
|
+
its signal, so a link to "my games" from the bottom bar **reopens the section
|
|
117
|
+
you were looking at**. That is what the signal is for, and `persists` makes it
|
|
118
|
+
survive the night;
|
|
119
|
+
- `MY_GAMES_TO_COME_ROUTE.buildUrl()` → `/games/me`, always. A tab must point at
|
|
120
|
+
its own section, never at the one already open — a tab pointing at the current
|
|
121
|
+
page is a tab that cannot be clicked.
|
|
122
|
+
|
|
123
|
+
The default section is the delicate one: it has no segment of its own, so its
|
|
124
|
+
literal route is the **parent** of the parameterized one. It still means the
|
|
125
|
+
default section and does not inherit the param.
|
|
126
|
+
|
|
127
|
+
What tells navi these values name pages rather than qualify one is precisely
|
|
128
|
+
that the literal routes exist. Where no literal is declared, the value stays a
|
|
129
|
+
qualifier and an ancestor url keeps it:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const tabSignal = stateSignal("general", { id: "settings_tab" });
|
|
133
|
+
export const ADMIN_ROUTE = route(`/admin/:section=${sectionSignal}/`);
|
|
134
|
+
export const ADMIN_SETTINGS_ROUTE = route(`/admin/settings/:tab=${tabSignal}`);
|
|
135
|
+
// nobody declared /admin/settings/advanced, so on tab "advanced":
|
|
136
|
+
// ADMIN_ROUTE.buildUrl() → /admin/settings/advanced — "admin, where you left it"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
So the rule is the one you would want: name a section and it becomes a place;
|
|
140
|
+
leave it unnamed and it stays a setting carried along.
|
|
141
|
+
|
|
101
142
|
### Search params
|
|
102
143
|
|
|
103
144
|
A param that qualifies a page rather than naming it — a zoom level, a sort, a
|
package/docs/resource.md
CHANGED
|
@@ -25,7 +25,7 @@ Each callback returns the data to upsert into the store:
|
|
|
25
25
|
| GET / POST / PUT / PATCH | the full item object, `{ id, … }` |
|
|
26
26
|
| DELETE | the id, or `{ id }` |
|
|
27
27
|
| GET_MANY / POST_MANY / … | an array of item objects |
|
|
28
|
-
|
|
|
28
|
+
| GET_RANGE | `{ items, start, count }` (below) |
|
|
29
29
|
|
|
30
30
|
Actions are read in components through the action system (`useAsyncData`,
|
|
31
31
|
`<Button action>`, …) — see [actions.md](./actions.md).
|
|
@@ -48,18 +48,18 @@ it yourself is the point:
|
|
|
48
48
|
else to land (see [When the backend answers a sub-route with the whole
|
|
49
49
|
parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)).
|
|
50
50
|
|
|
51
|
-
A list that loads its rows
|
|
52
|
-
`
|
|
51
|
+
A list that loads its rows a slice at a time is **not** one of those cases — that is
|
|
52
|
+
`GET_RANGE`, right below.
|
|
53
53
|
|
|
54
|
-
## `
|
|
54
|
+
## `GET_RANGE`: feeding a list that loads as it scrolls
|
|
55
55
|
|
|
56
56
|
A `<List.Items>` asks for the rows it is about to draw and keeps what it gets.
|
|
57
|
-
`
|
|
57
|
+
`GET_RANGE` is the resource's answer to that question — one slice at a time:
|
|
58
58
|
|
|
59
59
|
```js
|
|
60
60
|
const GAME = resource("game", {
|
|
61
61
|
GET: ({ id }) => fetchJson(`/games/${id}`),
|
|
62
|
-
|
|
62
|
+
GET_RANGE: ({ radar, start, limit }) =>
|
|
63
63
|
fetchJson(`/radars/${radar.id}/games?start=${start}&limit=${limit}`),
|
|
64
64
|
// { items: [{ id, … }, …], start: 20, count: 137 }
|
|
65
65
|
});
|
|
@@ -68,7 +68,7 @@ const GAME = resource("game", {
|
|
|
68
68
|
```jsx
|
|
69
69
|
<List.Items
|
|
70
70
|
count={radar.match_count}
|
|
71
|
-
itemsAction={GAME.
|
|
71
|
+
itemsAction={GAME.GET_RANGE.bindParams({ radar })}
|
|
72
72
|
renderItem={(game) => <GameCard game={game} />}
|
|
73
73
|
/>
|
|
74
74
|
```
|
|
@@ -76,7 +76,7 @@ const GAME = resource("game", {
|
|
|
76
76
|
The callback receives the bound params merged with the range the list asks for
|
|
77
77
|
(`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and a `signal`
|
|
78
78
|
as second argument — aborted when the list stops wanting those rows. It returns
|
|
79
|
-
a
|
|
79
|
+
a range the way a `Content-Range` does: **`{ items, start, count }`** — these
|
|
80
80
|
rows, at this place, out of that many. `start` may be omitted when the list
|
|
81
81
|
asked for a positive one; `count` defaults to `start + items.length` (a source
|
|
82
82
|
that does not know its total). The items are upserted on their way in, so the
|
|
@@ -98,12 +98,12 @@ makes a change detectable), so the one the list is holding is the one it was
|
|
|
98
98
|
given. Relations are not concerned: they are keyed by owner, and a row reading
|
|
99
99
|
`game.candidates` reads the shared collection whatever object carries it.
|
|
100
100
|
|
|
101
|
-
`
|
|
101
|
+
`GET_RANGE` is a **reader, not an action**. It keeps no value and takes no place
|
|
102
102
|
in the rerun graph, which is what makes it usable per slice:
|
|
103
103
|
|
|
104
|
-
- the list already holds the
|
|
104
|
+
- the list already holds the slices it received and glues them back together —
|
|
105
105
|
a second memory holding one of them would fight it;
|
|
106
|
-
- a `POST` invalidating "the collection" would otherwise send every
|
|
106
|
+
- a `POST` invalidating "the collection" would otherwise send every slice ever
|
|
107
107
|
loaded back to the network at once.
|
|
108
108
|
|
|
109
109
|
What it does not give is membership: an item that leaves the collection stays on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.28",
|
|
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.9",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|