@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.
@@ -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.
@@ -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
- | GET_PAGE | `{ items, start, count }` (below) |
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 page by page is **not** one of those cases — that is
52
- `GET_PAGE`, right below.
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
- ## `GET_PAGE`: feeding a list that loads as it scrolls
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
- `GET_PAGE` is the resource's answer to that question — one slice at a time:
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
- GET_PAGE: ({ radar, start, limit }) =>
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.GET_PAGE.bindParams({ radar })}
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 page the way a `Content-Range` does: **`{ items, start, count }`** — these
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
- `GET_PAGE` is a **reader, not an action**. It keeps no value and takes no place
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 pages it received and glues them back together —
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 page ever
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.26",
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.8",
32
+ "@jsenv/dom": "0.17.9",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.2"
35
35
  },