@jsenv/navi 0.29.417 → 0.29.419
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 +1 -1
- package/dist/dev/jsenv_navi.js +347 -83
- package/dist/dev/jsenv_navi.js.map +12 -12
- package/dist/jsenv_navi.js +345 -81
- package/dist/jsenv_navi.js.map +12 -12
- package/docs/AI_INSTRUCTIONS.md +177 -258
- package/docs/actions.md +164 -251
- package/docs/autofocus.md +66 -83
- package/docs/control_object.md +117 -214
- package/docs/control_value.md +121 -185
- package/docs/create_and_edit.md +159 -254
- package/docs/css_architecture.md +196 -260
- package/docs/data_states.md +41 -44
- package/docs/dialog_shape.md +91 -134
- package/docs/drag_interactions.md +291 -454
- package/docs/drag_to_travel.md +152 -748
- package/docs/dynamic_import.md +119 -136
- package/docs/error_handling.md +75 -127
- package/docs/field_validation.md +49 -89
- package/docs/form_changed.md +61 -78
- package/docs/group.md +141 -0
- package/docs/i18n.md +31 -76
- package/docs/interactions.md +170 -326
- package/docs/list_action.md +36 -51
- package/docs/list_refresh.md +73 -115
- package/docs/mobile_touch.md +56 -62
- package/docs/navigation.md +377 -723
- package/docs/network_policy.md +61 -75
- package/docs/pan_zoom.md +62 -103
- package/docs/popup_backdrop.md +128 -168
- package/docs/popup_lift.md +179 -268
- package/docs/popup_open.md +380 -651
- package/docs/resource.md +228 -408
- package/docs/route_transitions.md +333 -473
- package/docs/safe_area.md +102 -89
- package/docs/scroll.md +175 -317
- package/docs/state_binding.md +40 -84
- package/docs/testid.md +18 -23
- package/docs/typography.md +111 -141
- package/docs/view_transitions.md +106 -104
- package/docs/z_index.md +61 -109
- package/package.json +2 -2
- package/docs/badge_list.md +0 -83
- package/docs/control_group.md +0 -168
- package/docs/mobile_layout_pitfalls.md +0 -145
package/docs/form_changed.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# What a form sends, and what it measures against
|
|
2
2
|
|
|
3
3
|
A form answers a question. Asked again with the same answer, it has nothing to
|
|
4
|
-
say — so by default **a `<Form>` sends nothing when nothing changed**. This
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
say — so by default **a `<Form>` sends nothing when nothing changed**. This file
|
|
5
|
+
is about what "changed" is measured against, what counts as an answer the form
|
|
6
|
+
already holds, and what to do on a screen whose fields are filled a request
|
|
7
|
+
later.
|
|
8
8
|
|
|
9
9
|
- [Sending nothing is the default](#sending-nothing-is-the-default)
|
|
10
10
|
- [What follows a send](#what-follows-a-send)
|
|
@@ -21,8 +21,9 @@ hold their defaults, one reopened and left alone — runs **no action**. No
|
|
|
21
21
|
request, nothing in the network tab.
|
|
22
22
|
|
|
23
23
|
Everything around the action still happens: the constraints are checked, and
|
|
24
|
-
what follows the send still follows it
|
|
25
|
-
The user is done either way; there was simply nothing to
|
|
24
|
+
what follows the send still follows it — the popup closes, the slide moves on,
|
|
25
|
+
the page changes. The user is done either way; there was simply nothing to
|
|
26
|
+
send.
|
|
26
27
|
|
|
27
28
|
`canSendWhileUnchanged` turns that off, for a form where sending the same thing
|
|
28
29
|
twice is the point — a single button firing a notification, an action whose
|
|
@@ -35,18 +36,24 @@ duplicates are fine.
|
|
|
35
36
|
## What follows a send
|
|
36
37
|
|
|
37
38
|
The form has answered its question; `command` says what the screen does about
|
|
38
|
-
it —
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
form
|
|
43
|
-
|
|
39
|
+
it — `--navi-close`, `--navi-left`…, `--navi-nav-to:/games/42`,
|
|
40
|
+
`--navi-nav-back:/games` (back to the page the reader came from),
|
|
41
|
+
`--navi-reset` (empty itself and stay), `--navi-void` (stay put as it is). Left
|
|
42
|
+
out, the surface the form sits in decides: a popup closes, a slide goes on, a
|
|
43
|
+
form on a page does nothing. A submit button says its own with `formCommand`,
|
|
44
|
+
which wins over the form's: in a form with two ways out, "save" stays and
|
|
45
|
+
"delete" goes back to the list.
|
|
46
|
+
|
|
47
|
+
It is a prop, decided before the send, because it runs **whether or not there
|
|
48
|
+
was anything to send**. Nothing runs when the send fails, or when a constraint
|
|
49
|
+
refuses it: the form stays in front of the person, showing what it is waiting
|
|
50
|
+
for.
|
|
44
51
|
|
|
45
52
|
### A popup that stays open
|
|
46
53
|
|
|
47
|
-
A
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
A popup closes once a form inside it is sent. When it is a place one keeps
|
|
55
|
+
working in — a panel where a push is sent, two fields are fixed, an account is
|
|
56
|
+
linked — the form says it stays:
|
|
50
57
|
|
|
51
58
|
```jsx
|
|
52
59
|
<SidePanel signal={openUserSignal} value={user.id}>
|
|
@@ -56,13 +63,9 @@ fields are fixed, an account is linked — the form says it stays:
|
|
|
56
63
|
</SidePanel>
|
|
57
64
|
```
|
|
58
65
|
|
|
59
|
-
The form says it, not the popup
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
inside it would make every form in it that should close say so again, which
|
|
63
|
-
is the same repetition in the other direction. Nor does a `SidePanel` get a
|
|
64
|
-
different default from a `Dialog`: the same content in either one should
|
|
65
|
-
behave the same, and on a small screen a `SidePanel` often _is_ a dialog.
|
|
66
|
+
The form says it, not the popup: the same panel can hold a form edited in over
|
|
67
|
+
and over and a "delete" whose send has to close it, and the same content should
|
|
68
|
+
behave the same in a `SidePanel` or a `Dialog`.
|
|
66
69
|
|
|
67
70
|
When the popup's open state is a url param (`signal` bound to a route's search
|
|
68
71
|
param), closing it takes the param out of the url. A form that forgot
|
|
@@ -71,28 +74,20 @@ is what a reload would have brought back.
|
|
|
71
74
|
|
|
72
75
|
`--navi-reset` is the one to reach for when the form is a place one comes back
|
|
73
76
|
to — an entry created, then the next one: the fields go back to their
|
|
74
|
-
`defaultValue
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
`defaultValue`. What the form is measured against stays what was just sent (see
|
|
78
|
+
below), so the emptied form reads as changed, and the fields' own constraints
|
|
79
|
+
are what keep an empty one from being sent.
|
|
77
80
|
|
|
78
81
|
```jsx
|
|
79
82
|
<Form action={createPlaceGroup} command="--navi-reset">
|
|
80
83
|
```
|
|
81
84
|
|
|
82
|
-
It runs **whether or not there was anything to send** — that is the other half
|
|
83
|
-
of the rule above: the person is done either way, and a submit that ran no
|
|
84
|
-
action still closes the popup, still moves on, still navigates. Which is why
|
|
85
|
-
this is a prop, decided before the send: the form has to know where it goes even
|
|
86
|
-
when nothing happened.
|
|
87
|
-
|
|
88
|
-
Nothing runs when the send fails, or when a constraint refuses it. The form then
|
|
89
|
-
stays in front of the person, showing what it is waiting for.
|
|
90
|
-
|
|
91
85
|
### When only the response knows where to go
|
|
92
86
|
|
|
93
87
|
A creation lands on the page the server just made, and its id comes back with
|
|
94
88
|
the response — too late for a prop. Do it in the action, which is where the
|
|
95
|
-
answer is
|
|
89
|
+
answer is (a creation always has something to send, so there is no "the press
|
|
90
|
+
did nothing" case for `command` to cover):
|
|
96
91
|
|
|
97
92
|
```jsx
|
|
98
93
|
<Form
|
|
@@ -103,13 +98,10 @@ answer is:
|
|
|
103
98
|
>
|
|
104
99
|
```
|
|
105
100
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
whatever a command does on that surface), the form carries what follows the send
|
|
111
|
-
as `data-after-send`, read once the send has succeeded — so an action can write
|
|
112
|
-
it while it runs:
|
|
101
|
+
To go through the command machinery all the same (to reuse whatever a command
|
|
102
|
+
does on that surface), write it where the form keeps what follows the send,
|
|
103
|
+
`data-after-send`, read once the send has succeeded — an action can write it
|
|
104
|
+
while it runs:
|
|
113
105
|
|
|
114
106
|
```js
|
|
115
107
|
formRef.current.setAttribute("data-after-send", `--navi-nav-to:/games/${id}`);
|
|
@@ -151,19 +143,17 @@ An age that is usually 18, a duration that is usually 1h30 — the form holds
|
|
|
151
143
|
nothing there, and confirming the suggestion IS an answer ("yes, 18"), which
|
|
152
144
|
must be sendable. A bound signal falls on whichever side its content puts it: a
|
|
153
145
|
signal restored from the url or set by whoever fills the screen carries an
|
|
154
|
-
answer
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
The same question is asked of a single control by `isUIStateHeld`
|
|
158
|
-
(`src/control/held_ui_state.js`) — a form asks it once per field.
|
|
146
|
+
answer. A `stateSignal` that declares a default is never empty — the default is
|
|
147
|
+
what it holds until something else is written, and what writing `undefined`
|
|
148
|
+
puts back — so its field counts as held from the start.
|
|
159
149
|
|
|
160
150
|
## A screen filled after it opened: `pristineKey`
|
|
161
151
|
|
|
162
|
-
The baseline
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
152
|
+
The baseline taken as the fields register is right for a form whose values are
|
|
153
|
+
there on the first render — and wrong for a screen that modifies something: the
|
|
154
|
+
resource arrives a request later and fills the fields, so the form opens
|
|
155
|
+
**already changed**. Its submit is live, and pressing it sends back the resource
|
|
156
|
+
untouched.
|
|
167
157
|
|
|
168
158
|
`pristineKey` takes the baseline again. Pass whatever says the filling is done:
|
|
169
159
|
|
|
@@ -171,23 +161,19 @@ Its submit is live, and pressing it sends back the resource untouched.
|
|
|
171
161
|
<Form pristineKey={game && players && places ? "loaded" : undefined}>
|
|
172
162
|
```
|
|
173
163
|
|
|
174
|
-
Change it **once**, when the screen is ready
|
|
175
|
-
typing, it would call what they wrote the reference.
|
|
176
|
-
|
|
177
|
-
No need to delay it by a tick: the reference is taken when the fields have
|
|
178
|
-
settled, and again at the end of that same tick — so a row that arrives in a
|
|
179
|
-
render of its own (a value computed from signals, a memoized row) is part of it
|
|
180
|
-
without the screen having to know which of its fields settle late.
|
|
164
|
+
Change it **once**, when the screen is ready: taken again after someone started
|
|
165
|
+
typing, it would call what they wrote the reference. There is no tick to wait
|
|
166
|
+
for — fields that settle in a render of their own are part of it.
|
|
181
167
|
|
|
182
168
|
Do not use a `key` on the `<Form>` for this: it remounts every control and every
|
|
183
169
|
popup inside it, and anything half-typed goes with them.
|
|
184
170
|
|
|
185
171
|
## A submit that says it is waiting
|
|
186
172
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
173
|
+
A submit that sends nothing is still accepted — in a dialog or a slide it closes
|
|
174
|
+
or moves on all the same. In a form that goes nowhere on its own, the press
|
|
175
|
+
would visibly do nothing; `readOnlyWhileFormUnchanged` on the button holds it
|
|
176
|
+
back and says what it is waiting for.
|
|
191
177
|
|
|
192
178
|
```jsx
|
|
193
179
|
<Button type="submit" readOnlyWhileFormUnchanged>
|
|
@@ -208,26 +194,23 @@ something, a list acting on every touch. They say so with `standalone`:
|
|
|
208
194
|
```
|
|
209
195
|
|
|
210
196
|
`standalone` is not a filter, it is the absence of a relationship: the control
|
|
211
|
-
does not register with the group around it. Nothing goes up
|
|
212
|
-
collected,
|
|
213
|
-
|
|
214
|
-
cascade.
|
|
197
|
+
does not register with the group around it. Nothing goes up (its value is not
|
|
198
|
+
collected, the form stays unchanged whatever it does) and nothing comes down
|
|
199
|
+
(the form's reset, its validation cascade).
|
|
215
200
|
|
|
216
201
|
What it does **not** say is "ignore everything around me". `disabled`,
|
|
217
202
|
`readOnly` and `loading` travel on their own contexts and go on reaching it,
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
each, one prop each (see
|
|
203
|
+
deliberately: a door with no value of its own can still write into the form
|
|
204
|
+
through what its popup does, and a read-only form must still shut it. An
|
|
205
|
+
affordance that genuinely writes nowhere says so separately
|
|
206
|
+
(`whenSelfInteractionsBlocked="ignore"`), as it says whose press it is with
|
|
207
|
+
`selfInteractions`, and a control whose action is meant to be left running
|
|
208
|
+
with `actionStandalone` — one question each, one prop each (see
|
|
225
209
|
[interactions.md](./interactions.md#the-third-question-whose-value-is-it)).
|
|
226
210
|
|
|
227
|
-
Every control takes the prop, groups included — `<Form standalone
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
answer (see
|
|
211
|
+
Every control takes the prop, groups included — `<Form standalone>`, or a
|
|
212
|
+
`<List standalone>` sitting beside the one that IS the answer in a picker's
|
|
213
|
+
popup (see
|
|
231
214
|
[control_object.md](./control_object.md#a-picker-whose-value-is-an-object)).
|
|
232
215
|
|
|
233
216
|
## See also
|
package/docs/group.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Grouping controls (`<Group>`)
|
|
2
|
+
|
|
3
|
+
What we want: **several controls reading as one object.** A search input and
|
|
4
|
+
its button, a row of segmented buttons, a column of setting rows — when they
|
|
5
|
+
belong together, the eye should see one frame with divisions inside it, not
|
|
6
|
+
three boxes that happen to touch: one border along each seam instead of two side
|
|
7
|
+
by side, and a radius only on the four outer corners.
|
|
8
|
+
|
|
9
|
+
`<Group>` is what produces that. Reach for it whenever you place controls
|
|
10
|
+
against each other and any of them has a border or a radius — never hand-write
|
|
11
|
+
negative margins, `border-radius: 0` overrides, or a `borderRadius` prop set to
|
|
12
|
+
`0` on the middle members. Those spellings look right on the case you are
|
|
13
|
+
looking at and break on the next one: a member added at the end, a member
|
|
14
|
+
hidden by a condition (the "first" is then the second child), a switch from row
|
|
15
|
+
to column.
|
|
16
|
+
|
|
17
|
+
```jsx
|
|
18
|
+
<Group>
|
|
19
|
+
<Input name="search" placeholder="Search…" />
|
|
20
|
+
<Button>Go</Button>
|
|
21
|
+
</Group>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Stacked, one setting per row:
|
|
25
|
+
|
|
26
|
+
```jsx
|
|
27
|
+
<Group row>
|
|
28
|
+
<Picker name="side" ui={…} expandX />
|
|
29
|
+
<Picker name="level" ui={…} expandX />
|
|
30
|
+
<Picker name="city" ui={…} expandX />
|
|
31
|
+
</Group>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`row` (or `vertical`, the same prop — each member is a row, so they stack
|
|
35
|
+
vertically and meet along horizontal seams) is the only prop that changes the
|
|
36
|
+
arrangement; everything else is read off the members themselves. Say it rather
|
|
37
|
+
than `flex="y"`: the seams follow `row`, not the layout.
|
|
38
|
+
|
|
39
|
+
Live examples: `src/control/demos/15_group_demo.html`.
|
|
40
|
+
|
|
41
|
+
## What Group does to its members
|
|
42
|
+
|
|
43
|
+
- **Seams**: a member with a member before it is pulled back by one border
|
|
44
|
+
width, so the two borders along a seam become one line — the member's own
|
|
45
|
+
`--border-width` when it declares one, else the group's `--group-border-width`
|
|
46
|
+
(default: `--navi-control-border-width`).
|
|
47
|
+
- **Corners**: a member loses the radius on each side where it faces another
|
|
48
|
+
member. A single member keeps its own radius — a group of one looks like the
|
|
49
|
+
control alone.
|
|
50
|
+
- **Members only**: a child out of the group's flow is none of its business. A
|
|
51
|
+
popup renders inside its opener's subtree, so a `Dialog` or `Popover` written
|
|
52
|
+
next to the button that opens it is a child of the group, backdrop included; a
|
|
53
|
+
callout anchored on a `<button>` is mounted in that button's parent. They mark
|
|
54
|
+
themselves `navi-out-of-flow`, and the group skips them: none takes a corner,
|
|
55
|
+
none moves anyone else's (a row of one button and the dialog it opens is still
|
|
56
|
+
a group of one), none is pulled back by a border width.
|
|
57
|
+
- **Overlap order**: the member hovered, the one showing a focus ring (on itself
|
|
58
|
+
or inside it), and the one holding something open (`aria-expanded="true"`)
|
|
59
|
+
paint above their neighbours, so their border and ring are not sliced
|
|
60
|
+
(`--navi-z-index-control-hovered` / `-focused` / `-expanded`, expanded
|
|
61
|
+
highest). The group is deliberately not isolated — see
|
|
62
|
+
[z_index.md](./z_index.md#why-a-group-member-is-not-isolated).
|
|
63
|
+
|
|
64
|
+
Nothing else: a group does not restyle its members, does not impose a size,
|
|
65
|
+
and takes any `Box` prop for its own layout.
|
|
66
|
+
|
|
67
|
+
## Writing a control that belongs in a group
|
|
68
|
+
|
|
69
|
+
A group never writes a selector that reaches inside a member — a member's
|
|
70
|
+
subtree holds more than the member (a `Picker` renders its popup inside itself;
|
|
71
|
+
a control carries buttons of its own, like the clear cross in a slot), and a
|
|
72
|
+
rule matching "some descendant" finds all of them. It asks for a square corner
|
|
73
|
+
in two forms instead, and a control answers with whichever fits.
|
|
74
|
+
|
|
75
|
+
**The property, on the member itself.** A control declares the radius of its
|
|
76
|
+
frame on its own root, and whatever inner element paints that frame takes
|
|
77
|
+
`border-radius: inherit`:
|
|
78
|
+
|
|
79
|
+
```css
|
|
80
|
+
.navi_thing {
|
|
81
|
+
/* Declared here even though the box below is what draws it */
|
|
82
|
+
border-radius: var(--thing-border-radius);
|
|
83
|
+
|
|
84
|
+
.navi_thing_box {
|
|
85
|
+
border: ...;
|
|
86
|
+
border-radius: inherit;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**The custom property, which travels.** A member is not always the control that
|
|
92
|
+
carries the frame: a button can arrive wrapped in a tooltip or a link, at any
|
|
93
|
+
depth. So the group also sets `--x-corner-top-left-radius` and its three
|
|
94
|
+
siblings on the member, and a control that can arrive wrapped reads them as an
|
|
95
|
+
override of its own radius. The `--x-` prefix marks navi's internal wiring, not
|
|
96
|
+
a surface an app writes to (an app changes a radius with the `borderRadius`
|
|
97
|
+
prop, which lands in the fallback):
|
|
98
|
+
|
|
99
|
+
```css
|
|
100
|
+
.navi_thing {
|
|
101
|
+
border-top-left-radius: var(
|
|
102
|
+
--x-corner-top-left-radius,
|
|
103
|
+
var(--thing-border-radius)
|
|
104
|
+
);
|
|
105
|
+
/* …and the three others */
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**Whoever answers the ask also stops it.** Custom properties inherit all the
|
|
110
|
+
way down, so the control that consumed a corner sets the four back to `initial`
|
|
111
|
+
on the first element inside it — otherwise a button in a slot, or the Save
|
|
112
|
+
button of a form in an open popup, reads a corner meant for the row that opened
|
|
113
|
+
it. `Popover` and `Dialog` stop it at their own root. **A member drawing the
|
|
114
|
+
frame itself stops it too**: a `Box` that paints a background or a border, or
|
|
115
|
+
insets what it holds with padding, is what the outer corner belongs to, and
|
|
116
|
+
marks itself `navi-box-frame` to stop the claims for everything inside. A
|
|
117
|
+
wrapper adding neither — a tooltip, a link, a bare `<Box>` — lets them through
|
|
118
|
+
to the control that really carries the frame.
|
|
119
|
+
|
|
120
|
+
A control that declares its radius on an inner element instead is invisible to
|
|
121
|
+
`Group`: it keeps round corners in the middle of the row, and no rule in
|
|
122
|
+
`group.jsx` can reach it without naming that private class. Fix the control, not
|
|
123
|
+
the group. The controls shipped by navi all follow this: `Button`, `Input`,
|
|
124
|
+
`Select`, `Picker`, `Spin`, checkbox, range.
|
|
125
|
+
|
|
126
|
+
## When it is not a Group
|
|
127
|
+
|
|
128
|
+
- Controls separated by space, each with its own frame — that is a `Box` with
|
|
129
|
+
`spacing`, they were never one object.
|
|
130
|
+
- A label and its control — that is `Field`.
|
|
131
|
+
- Several controls making ONE value between them (an address out of three
|
|
132
|
+
fields, a day and two wheels out of one moment) — that is `ControlGroup`,
|
|
133
|
+
which is about the value and draws nothing. Same word, other subject: see
|
|
134
|
+
[control_object.md](./control_object.md). Both at once is fine — a `Group`
|
|
135
|
+
around the members of a `ControlGroup`.
|
|
136
|
+
- Radio buttons or checkboxes sharing a name and a validation — that is
|
|
137
|
+
`RadioGroup` / `CheckboxGroup`, which is about the value, not the frame. They
|
|
138
|
+
can be put inside a `Group` if you also want them to share a frame.
|
|
139
|
+
- Controls that are ONE value between them, with a word written inside the frame
|
|
140
|
+
(the hours and the minutes of "07h30") — that is `SpinGroup`, which drops the
|
|
141
|
+
members' frames rather than joining them.
|
package/docs/i18n.md
CHANGED
|
@@ -9,7 +9,7 @@ Translation is a consequence of doing that, not the reason to do it. An app with
|
|
|
9
9
|
one language still benefits from one place holding its wording; and once it does,
|
|
10
10
|
adding a second language becomes a data change instead of a refactor.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Four tools, from smallest commitment to largest:
|
|
13
13
|
|
|
14
14
|
| Tool | Gives you |
|
|
15
15
|
| ----------------------- | ---------------------------------------- |
|
|
@@ -18,6 +18,11 @@ Three tools, from smallest commitment to largest:
|
|
|
18
18
|
| `createI18n()` | a registry of the app's texts |
|
|
19
19
|
| `naviI18n` | navi's own texts, to override |
|
|
20
20
|
|
|
21
|
+
Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
|
|
22
|
+
it, and for `createI18n` as soon as more than a handful of texts exist — the
|
|
23
|
+
migration from the first to the second is wrapping the string in a call, which
|
|
24
|
+
is why starting with plain interpolation costs nothing.
|
|
25
|
+
|
|
21
26
|
## Writing a sentence: `interpolateText` / `<Interpolate>`
|
|
22
27
|
|
|
23
28
|
Use these the moment a sentence contains a value. They need no i18n instance and
|
|
@@ -40,14 +45,9 @@ In JSX, `<Interpolate>` keeps the sentence readable when part of it is styled:
|
|
|
40
45
|
The point is that the full sentence is visible in one place. The alternative —
|
|
41
46
|
`Data limited to <Text bold>{radius} km</Text> around {zone}.` — reads as
|
|
42
47
|
fragments, and cannot later become a translation key, because there is no
|
|
43
|
-
single string to be the key.
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
48
|
+
single string to be the key. Placeholders are `[name]`; what a value can be (a
|
|
49
|
+
dot-path, a function) and what a missing one does are in `interpolateText`'s
|
|
50
|
+
JSDoc.
|
|
51
51
|
|
|
52
52
|
## Centralizing texts: `createI18n`
|
|
53
53
|
|
|
@@ -93,44 +93,13 @@ message needs a stable name that does not move when navi rewords its default
|
|
|
93
93
|
English. Same reasoning applies to any shared component library — the trade-off
|
|
94
94
|
flips with who owns the text.
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
i18n.
|
|
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. The language is resolved per key: the preferred languages in order
|
|
121
|
-
(`lang`, or the whole `languagesSignal` list by default), then the
|
|
122
|
-
`fallbackLang` given at creation, and the first one translating the key wins. A
|
|
123
|
-
half-translated language therefore shows its own words where it has them and the
|
|
124
|
-
next language's everywhere else.
|
|
125
|
-
|
|
126
|
-
When none of them translates the key, it comes back as is, so a missing
|
|
127
|
-
translation degrades to readable text rather than blank space or a crash. With
|
|
128
|
-
opaque keys that text is the key's name, which is why an instance with opaque
|
|
129
|
-
keys wants a `fallbackLang` holding every key — `naviI18n` falls back to
|
|
130
|
-
English.
|
|
131
|
-
|
|
132
|
-
`i18n.has(key)` is the way to tell "no translation" apart from "translation that
|
|
133
|
-
happens to equal the key".
|
|
96
|
+
A key no language translates comes back as is, so a missing translation
|
|
97
|
+
degrades to readable text rather than blank space or a crash — with opaque keys,
|
|
98
|
+
to the key's name. That is why an instance with opaque keys wants a
|
|
99
|
+
`fallbackLang` holding every key (`naviI18n` falls back to English), and
|
|
100
|
+
`i18n.has(key)` is how to tell "no translation" apart from "a translation that
|
|
101
|
+
happens to equal the key". Registering (`add`, `addAll`, `addLangKeys`) and how
|
|
102
|
+
the language is resolved key by key are in `createI18n`'s JSDoc.
|
|
134
103
|
|
|
135
104
|
## Which language
|
|
136
105
|
|
|
@@ -153,13 +122,13 @@ languages. With it, that browser lands on the app's best available language for
|
|
|
153
122
|
all of them.
|
|
154
123
|
|
|
155
124
|
It decides more than the wording: `<Time>` and the format helpers
|
|
156
|
-
(`formatNumber`, `formatTime`, …) read the same signal, so **a document that
|
|
157
|
-
dates and numbers included** —
|
|
158
|
-
machine and "01/09/2026" on a French
|
|
159
|
-
|
|
160
|
-
at startup even in an app with a
|
|
161
|
-
|
|
162
|
-
from zero.
|
|
125
|
+
(`formatNumber`, `formatTime`, …) read the same signal, so **a document that
|
|
126
|
+
never calls it follows the browser, dates and numbers included** —
|
|
127
|
+
`2026-09-01` reads "09/01/2026" on an en-US machine and "01/09/2026" on a French
|
|
128
|
+
one, from the same code and the same data. Nothing on screen says which reading
|
|
129
|
+
is being applied, so this is worth one call at startup even in an app with a
|
|
130
|
+
single language, and in every document of the repo (an admin panel, a demo
|
|
131
|
+
page), each of which starts from zero.
|
|
163
132
|
|
|
164
133
|
Because the language is read at lookup time, a component that reads `i18n(...)`
|
|
165
134
|
during render re-renders with the new wording when the language changes — as
|
|
@@ -177,28 +146,14 @@ naviI18n.add("list.empty", {
|
|
|
177
146
|
en: "Nothing here yet.",
|
|
178
147
|
fr: "Rien pour l'instant.",
|
|
179
148
|
});
|
|
180
|
-
naviI18n.addLangKeys("ja", { "button.close": "閉じる" });
|
|
181
149
|
```
|
|
182
150
|
|
|
183
|
-
Its JSDoc lists the key namespaces
|
|
184
|
-
the
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
overriding either
|
|
151
|
+
Its JSDoc lists the key namespaces and the unit names `<Quantity>`/`<Unit>`
|
|
152
|
+
look up; the exhaustive list of keys and defaults is the registration block in
|
|
153
|
+
`src/text/navi_i18n.js`, plus the `time.*` wording the formatters use,
|
|
154
|
+
registered by `@jsenv/humanize`. Both go into the one registry `naviI18n` is,
|
|
155
|
+
so overriding either is the same call.
|
|
188
156
|
|
|
189
|
-
Do not put application texts in `naviI18n
|
|
190
|
-
different lifecycles: navi's keys come with the package version, the app's
|
|
191
|
-
with the app.
|
|
192
|
-
|
|
193
|
-
One convention to know, since it is what `<Quantity>`/`<Unit>` look up: a unit
|
|
194
|
-
name may have `<unit>__plural` and `<unit>__short` variants, both optional
|
|
195
|
-
(missing ones fall back to the singular). An unregistered unit goes to
|
|
196
|
-
`Intl.NumberFormat`, so only units Intl gets wrong or does not know need
|
|
197
|
-
registering at all.
|
|
198
|
-
|
|
199
|
-
## Choosing, in one line
|
|
200
|
-
|
|
201
|
-
Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
|
|
202
|
-
it. Reach for `createI18n` as soon as more than a handful of texts exist — the
|
|
203
|
-
migration from the first to the second is wrapping the string in a call, which
|
|
204
|
-
is why starting with plain interpolation costs nothing.
|
|
157
|
+
**Do not put application texts in `naviI18n`.** Key collisions aside, the two
|
|
158
|
+
have different lifecycles: navi's keys come with the package version, the app's
|
|
159
|
+
come with the app.
|