@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/state_binding.md
CHANGED
|
@@ -32,8 +32,6 @@ was missed.
|
|
|
32
32
|
|
|
33
33
|
## The callback that only remembers
|
|
34
34
|
|
|
35
|
-
The same mistake, in four places:
|
|
36
|
-
|
|
37
35
|
```jsx
|
|
38
36
|
// ✗ the app is told where the slides went, and writes it back down
|
|
39
37
|
<SlideContainer
|
|
@@ -45,18 +43,6 @@ The same mistake, in four places:
|
|
|
45
43
|
// ✓
|
|
46
44
|
<SlideContainer signal={stepSignal} />
|
|
47
45
|
|
|
48
|
-
// ✗ the control is told what it now holds, and the app writes it back down
|
|
49
|
-
<Picker value={side} uiAction={(value) => (sideSignal.value = value)} />
|
|
50
|
-
// ✓
|
|
51
|
-
<Picker signal={sideSignal} />
|
|
52
|
-
|
|
53
|
-
// ✗ a button proposing a value to a control, straight into the signal
|
|
54
|
-
<Button onClick={() => (durationSignal.value = ninetyMinutes)}>1h30</Button>
|
|
55
|
-
// ✓
|
|
56
|
-
<Button command="--navi-update" commandFor="duration" value={ninetyMinutes}>
|
|
57
|
-
1h30
|
|
58
|
-
</Button>
|
|
59
|
-
|
|
60
46
|
// ✗ the popup is told it opened, and the app writes it back down
|
|
61
47
|
<Dialog
|
|
62
48
|
open={isOpen}
|
|
@@ -69,7 +55,8 @@ The same mistake, in four places:
|
|
|
69
55
|
|
|
70
56
|
In each pair the second line is not a tidier spelling of the first: it is the
|
|
71
57
|
binding the component already implements, and the first is a partial
|
|
72
|
-
re-implementation of it.
|
|
58
|
+
re-implementation of it. The same pair for a control's value, and for a button
|
|
59
|
+
proposing one, is in [control_value.md](./control_value.md).
|
|
73
60
|
|
|
74
61
|
## Why it is not merely shorter
|
|
75
62
|
|
|
@@ -77,22 +64,20 @@ A binding is written by **everything that moves the state**, not only by the
|
|
|
77
64
|
gesture the caller thought of. A `SlideContainer`'s signal is written by a drag,
|
|
78
65
|
by an arrow key, by a `--navi-right` command sent from a chevron outside the
|
|
79
66
|
box, by the address when the signal is a route's own, by a step refusing to be
|
|
80
|
-
left. A control's
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
added.
|
|
67
|
+
left. A control's signal is written by the user typing, by a group placing its
|
|
68
|
+
children, by a picker filling its popup or putting back what it held on Escape,
|
|
69
|
+
by a `--navi-update` from a shortcut button. A hand-written callback catches the
|
|
70
|
+
cases it was written for and silently misses the rest — and the drift only shows
|
|
71
|
+
on the screen where a second gesture was added.
|
|
86
72
|
|
|
87
73
|
It reads in the other direction too, which no callback gives at all: writing the
|
|
88
74
|
signal MOVES the component. The wheels roll, the slides travel, the popup opens.
|
|
89
75
|
That is what replaces the `key` or the `value` prop pushed down to force a
|
|
90
76
|
component into a state.
|
|
91
77
|
|
|
92
|
-
And
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
allowed to change.
|
|
78
|
+
And a `command` goes through navi's interaction gate, which an `onClick` writing
|
|
79
|
+
the same state does not (see
|
|
80
|
+
[control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
|
|
96
81
|
|
|
97
82
|
## What is left for the callback
|
|
98
83
|
|
|
@@ -113,13 +98,10 @@ Binding the state does not remove the callback — it removes one job from it:
|
|
|
113
98
|
## The callback also fires for the state's own changes
|
|
114
99
|
|
|
115
100
|
A bound container walks to whatever its signal holds, and tells the callback
|
|
116
|
-
about that walk — `cause: "state"`, `event: null`.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
flows back INTO the signal — a server whose response is upserted into the store,
|
|
121
|
-
a `useSignalSync` on the row that re-renders from it — makes every answer a
|
|
122
|
-
change, and every change a write:
|
|
101
|
+
about that walk — `cause: "state"`, `event: null`. The trap is a callback that
|
|
102
|
+
writes somewhere whose answer flows back INTO the signal — a server whose
|
|
103
|
+
response is upserted into the store, a `useSignalSync` on the row that
|
|
104
|
+
re-renders from it:
|
|
123
105
|
|
|
124
106
|
```jsx
|
|
125
107
|
// ✗ the answer of the write feeds the signal the write came from
|
|
@@ -130,49 +112,30 @@ const styleSignal = useSignalSync(user.share_image_style);
|
|
|
130
112
|
/>;
|
|
131
113
|
```
|
|
132
114
|
|
|
133
|
-
|
|
134
|
-
already holds
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
t=200 notch → A signal=A PUT A ────────────┐ │
|
|
140
|
-
t=300 B answers, store=B, signal=B, the box │ │
|
|
141
|
-
walks to B, onCurrentChange(B, "state"), │ │
|
|
142
|
-
save(B) → PUT B ───────────────────────┐ │ │
|
|
143
|
-
t=500 A answers … save(A) → PUT A │ │ │
|
|
144
|
-
t=600 B answers … save(B) → PUT B │ │ │
|
|
145
|
-
…
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Nothing was reordered — it is enough that an answer lands after a newer write
|
|
149
|
-
was sent. Every answer is then the answer to a request the user has already
|
|
150
|
-
moved past, the container obeys the model, the callback saves what it was just
|
|
151
|
-
told, and that write's answer starts the next turn.
|
|
115
|
+
One write at a time shows nothing: the answer carries the value the signal
|
|
116
|
+
already holds. Two in flight — two notches in quick succession, nothing
|
|
117
|
+
reordered, only an answer landing after a newer write was sent — and every
|
|
118
|
+
answer is to a request the user has already moved past: the container walks to
|
|
119
|
+
it, the callback saves it, and that write's answer starts the next turn. The
|
|
120
|
+
loop never closes.
|
|
152
121
|
|
|
153
122
|
Decide what the signal IS:
|
|
154
123
|
|
|
155
124
|
- **The signal is the choice, the server only confirms it.** Seed it once with
|
|
156
|
-
`useSignal(model)` and never sync it
|
|
157
|
-
picture
|
|
158
|
-
|
|
159
|
-
|
|
125
|
+
`useSignal(model)` and never sync it: a late answer changes the store, not the
|
|
126
|
+
picture, and a write that fails is not undone on screen unless the callback
|
|
127
|
+
undoes it. Right for a setting saved on the gesture, with no "Save" button.
|
|
128
|
+
That is a `SlideContainer`'s shape; a control says it with
|
|
129
|
+
`defaultValue={model}` and an `action` (see
|
|
130
|
+
[control_value.md](./control_value.md#a-defaultvalue-follows-what-it-was-read-from)).
|
|
160
131
|
- **The model is the truth, the control only proposes.** Keep `useSignalSync`
|
|
161
|
-
and ignore `cause: "state"`
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
132
|
+
and ignore `cause: "state"` — a change the state asked for is not a choice to
|
|
133
|
+
save. A late answer still pulls the picture back for one round trip. Right
|
|
134
|
+
when another client or a background refresh may change the row while it is on
|
|
135
|
+
screen.
|
|
165
136
|
- **Never two writes in flight for one field.** Debounce the write, or cancel
|
|
166
|
-
the one before it (`createRequestCanceller`), so no answer
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Measured on the same screen, the same two notches, a 300ms round trip:
|
|
170
|
-
|
|
171
|
-
| what the signal is | writes sent | slide changes |
|
|
172
|
-
| ----------------------------------------- | --------------- | ------------- |
|
|
173
|
-
| `useSignalSync` + save every change | 39, still going | 39 |
|
|
174
|
-
| `useSignal`, seeded once | 2 | 2 |
|
|
175
|
-
| `useSignalSync` + ignore `cause: "state"` | 2 | 4 |
|
|
137
|
+
the one before it (`createRequestCanceller`), so no answer is older than the
|
|
138
|
+
latest intent. Right when the write is the expensive part.
|
|
176
139
|
|
|
177
140
|
## What binds itself
|
|
178
141
|
|
|
@@ -189,13 +152,10 @@ Measured on the same screen, the same two notches, a 300ms round trip:
|
|
|
189
152
|
|
|
190
153
|
"Who writes the score" has three answers — a player of the game, the organizer
|
|
191
154
|
alone, nobody — and only two of them are rows: "nobody" is a switch under the
|
|
192
|
-
list.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
The finding is elsewhere: **the list is not asking that question**. It asks
|
|
197
|
-
"which of these two", the switch asks "anybody at all", and the answer the
|
|
198
|
-
screen sends is made of both:
|
|
155
|
+
list. Bound to the state holding all three, the list shows nothing whenever the
|
|
156
|
+
switch is on. The fix is not `value` + `uiAction`: **the list is not asking that
|
|
157
|
+
question**. It asks "which of these two", the switch asks "anybody at all", and
|
|
158
|
+
the answer the screen sends is made of both:
|
|
199
159
|
|
|
200
160
|
```jsx
|
|
201
161
|
const scorerSignal = signal("player"); // what the list asks: one of ITS rows
|
|
@@ -210,13 +170,10 @@ const whoWritesSignal = computed(() =>
|
|
|
210
170
|
```
|
|
211
171
|
|
|
212
172
|
Each control binds the state it is about, and the value that spans them is
|
|
213
|
-
computed from those — never held a second time
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
The rule underneath: a control shows values, so the state it binds is the one
|
|
218
|
-
whose values it can show. A state holding more than that is more than one
|
|
219
|
-
question.
|
|
173
|
+
computed from those — never held a second time (the row chosen before the switch
|
|
174
|
+
went on is still chosen when it goes off: nothing cleared it). A control binds
|
|
175
|
+
the state whose values it can show; a state holding more than that is more than
|
|
176
|
+
one question.
|
|
220
177
|
|
|
221
178
|
## When there is no binding
|
|
222
179
|
|
|
@@ -235,7 +192,6 @@ the next screen copies, and by then it is the app's pattern.
|
|
|
235
192
|
|
|
236
193
|
- [control_value.md](./control_value.md) — who holds a control's value, and what
|
|
237
194
|
a bound signal holds control by control
|
|
238
|
-
- [actions.md](./actions.md#action-or-uiaction) — `action` or `uiAction`
|
|
239
195
|
- [popup_open.md](./popup_open.md) — who owns the fact that a popup is open
|
|
240
196
|
- [interactions.md](./interactions.md) — a gesture is named, not read by hand:
|
|
241
197
|
the same rule, one layer down
|
package/docs/testid.md
CHANGED
|
@@ -71,12 +71,10 @@ on. A testid on the wrapper would answer a click with a hit on the padding.
|
|
|
71
71
|
</span>
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
`
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
so a `data-test-id` or a `data-qa` of your own would name the wrapper, which is
|
|
79
|
-
the second reason to keep the standard spelling.
|
|
74
|
+
Navi hands the host the props it knows belong there (`id`, `name`, `type`,
|
|
75
|
+
`value`, `tabIndex`, `data-testid`, …); any prop it does not know lands on the
|
|
76
|
+
root box instead — so a `data-test-id` or a `data-qa` of your own would name
|
|
77
|
+
the wrapper, which is the second reason to keep the standard spelling.
|
|
80
78
|
|
|
81
79
|
`aria-label` and `aria-labelledby` take the same road, and for the reason the
|
|
82
80
|
section above gives: the name has to be where the role is. The root box has no
|
|
@@ -124,16 +122,16 @@ error message, a section, a row — put the testid on the surrounding component
|
|
|
124
122
|
(`<Field data-testid="email-field">`, `<Box data-testid="cart-row">`); anything
|
|
125
123
|
built on `Box` forwards it to its own element.
|
|
126
124
|
|
|
127
|
-
A selectable `List.Item` is a control too, and the rule holds there
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
waits for it to show, targets what the row draws: a testid on its content.
|
|
125
|
+
A selectable `List.Item` is a control too, and the rule holds there: its host is
|
|
126
|
+
the real checkbox or radio the row selects with, an input navi draws invisible
|
|
127
|
+
and lays under the row's content. A `data-testid` on the item names that input —
|
|
128
|
+
what `toBeChecked()` reads, not what the eye sees. A test that presses the row,
|
|
129
|
+
or waits for it to show, targets what the row draws: a testid on its content.
|
|
133
130
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
What navi builds itself is named through props. A `data-testid` on a `<Picker>`
|
|
132
|
+
names the trigger; `popupTestId` names the popup it builds (the callout, under
|
|
133
|
+
`mode="callout"`), and `confirmTestId` / `cancelTestId` the yes and no buttons
|
|
134
|
+
of a `type="confirm"` picker's default body:
|
|
137
135
|
|
|
138
136
|
```jsx
|
|
139
137
|
<Picker data-testid="tie-break" popupTestId="tie-break-sheet">
|
|
@@ -141,13 +139,11 @@ nothing names the popup. `popupTestId` does:
|
|
|
141
139
|
</Picker>
|
|
142
140
|
```
|
|
143
141
|
|
|
144
|
-
Most tests do not need
|
|
142
|
+
Most tests do not need `popupTestId`: a testid on what the popup holds (`<Box
|
|
145
143
|
data-testid="place-pick">` among the children) names the screen, not the frame,
|
|
146
144
|
and is the better name for a test that reads or clicks the content. Reach for
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
every mode, including `mode="callout"`, where the frame is a callout rather
|
|
150
|
-
than a sheet.
|
|
145
|
+
it when the frame IS what the test looks at — a screenshot of the popup's
|
|
146
|
+
surface, its position, its size, its backdrop.
|
|
151
147
|
|
|
152
148
|
## A callout is a role first
|
|
153
149
|
|
|
@@ -168,9 +164,8 @@ for it is what a live region is for, and the role is what makes a screen reader
|
|
|
168
164
|
read it out. A test targeting it asserts that.
|
|
169
165
|
|
|
170
166
|
When the role is not enough — two callouts up at once, a screenshot of one
|
|
171
|
-
particular surface — `openCallout` takes a `testId
|
|
172
|
-
|
|
173
|
-
to replace: it is a navi class like any other.
|
|
167
|
+
particular surface — `openCallout` takes a `testId`. Waiting on `.navi_callout`
|
|
168
|
+
is the thing to replace: it is a navi class like any other.
|
|
174
169
|
|
|
175
170
|
`<ControlSwap.Side>` answers the same situation the other way: the cap at the
|
|
176
171
|
end of the row is drawn by navi, and every prop the side does not use for
|
package/docs/typography.md
CHANGED
|
@@ -38,11 +38,11 @@ same names — `size="l"` is a font size, `padding="l"` is a gap.
|
|
|
38
38
|
|
|
39
39
|
Four of the five are formulas on `currentColor`, so they follow whatever ink
|
|
40
40
|
their container writes in — a dark card sets `color` and nothing else. Never
|
|
41
|
-
|
|
42
|
-
name, and an invented one (`var(--navi-text-color-discrete)`) resolves to
|
|
43
|
-
nothing and silently inherits.
|
|
44
|
-
why a surface re-declares
|
|
45
|
-
[css_architecture.md](./css_architecture.md#ink-ratio-paper-the-color-keywords).
|
|
41
|
+
read the `--navi-color-*` variable behind a keyword with `var()`: the keyword is
|
|
42
|
+
the name, and an invented one (`var(--navi-text-color-discrete)`) resolves to
|
|
43
|
+
nothing and silently inherits. Declare one only to pin a container's paper —
|
|
44
|
+
what the ratios are, how a theme changes them and why a surface re-declares
|
|
45
|
+
them: [css_architecture.md](./css_architecture.md#ink-ratio-paper-the-color-keywords).
|
|
46
46
|
|
|
47
47
|
Live examples: `src/text/demos/*_demo.html` — one page per concern
|
|
48
48
|
(`text_overflow_demo.html`, `text_spacing_demo.html`, `text_loading_demo.html`,
|
|
@@ -61,59 +61,19 @@ implements them with two unrelated mechanisms:
|
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Do **not** write `lineClamp={1}`. `lineClamp` and `overflowEllipsis` are raw
|
|
64
|
-
`Box` style props
|
|
65
|
-
`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
### Who takes `maxLines`
|
|
78
|
-
|
|
79
|
-
The prop is the same everywhere: a number of lines, cut with an ellipsis.
|
|
80
|
-
|
|
81
|
-
- **`Text`** and everything built on it — `Title`, `Paragraph`, `Caption`,
|
|
82
|
-
`Link`, `Time`, `Quantity`… — the prop flows through. `Badge` is `maxLines={1}`
|
|
83
|
-
by construction.
|
|
84
|
-
- **`Picker`** — its value, `maxLines={1}` by default; `variant="text"` and
|
|
85
|
-
`variant="bare"` draw the caller's own thing and are not clamped, a `Text` in
|
|
86
|
-
their `ui` cuts itself with its own `maxLines`.
|
|
87
|
-
- **`Spin`** (`DaySpin`, `NumberSpin`…) — its value; left out, a long value
|
|
88
|
-
wraps and the box grows.
|
|
89
|
-
- **`Binder`** — its tab labels, `maxLines={1}` by default, `false` lets them
|
|
90
|
-
wrap; overridable per item.
|
|
91
|
-
- **`BadgeList`** — its rows, measured (a wrapped flex row is not a line box):
|
|
92
|
-
the badges that fit and a "+N" badge for the rest. Inside a `Picker` it
|
|
93
|
-
inherits the picker's count, see [badge_list.md](./badge_list.md).
|
|
94
|
-
|
|
95
|
-
On a plain `Box`, `maxLines` is the raw CSS mapping described above: it clamps
|
|
96
|
-
whatever the box holds, and states nothing else.
|
|
97
|
-
|
|
98
|
-
### Truncation only happens if something says "you may shrink"
|
|
99
|
-
|
|
100
|
-
A flex or grid item refuses to become narrower than its content unless it is
|
|
101
|
-
told it may. `maxLines` sets `min-width: 0` on the `Text` itself, so the leaf is
|
|
102
|
-
covered — but **every `Box` between it and the element that actually has a
|
|
103
|
-
width must say it too**, or the whole chain grows instead of truncating:
|
|
104
|
-
|
|
105
|
-
```jsx
|
|
106
|
-
<Box flex width="300" spacing="s">
|
|
107
|
-
{/* without minWidth the row just grows */}
|
|
108
|
-
<Box flex expandX minWidth="0">
|
|
109
|
-
<Text maxLines={1}>…</Text>
|
|
110
|
-
</Box>
|
|
111
|
-
</Box>
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Symmetrically, whatever must keep its full size says so with `shrink={false}`.
|
|
115
|
-
A row where the text gives way and nothing else does is the normal shape — see
|
|
116
|
-
the recipes below.
|
|
64
|
+
`Box` style props, mapped one-to-one to CSS for an element that is not a `Text`.
|
|
65
|
+
`maxLines` also states the `white-space` its line count needs — one line does
|
|
66
|
+
not wrap, n lines may — and that is what makes it hold anywhere: `white-space`
|
|
67
|
+
is inherited, and a raw clamp put under a single-line ancestor — a `Picker`'s
|
|
68
|
+
value, a `Time` — inherits `nowrap`, gets one line to cut, and lets the text run
|
|
69
|
+
past its box instead. An explicit `noWrap`/`pre`/`preLine` beside `maxLines`
|
|
70
|
+
still wins.
|
|
71
|
+
|
|
72
|
+
The prop is the same wherever it is taken — a `Picker`'s value (1 by default),
|
|
73
|
+
a `Spin`'s value, `Binder`'s tab labels, a `BadgeList`'s rows (see
|
|
74
|
+
[BadgeList](#badgelist)); `Badge` is `maxLines={1}` by construction. On a plain
|
|
75
|
+
`Box`, `maxLines` is the raw CSS mapping: it clamps whatever the box holds, and
|
|
76
|
+
states nothing else.
|
|
117
77
|
|
|
118
78
|
## A row: icon, text, icon
|
|
119
79
|
|
|
@@ -145,22 +105,34 @@ The three parts of it, and each is load-bearing: `shrink={false}` on the icons
|
|
|
145
105
|
(they are the fixed part), `expandX` on the text (it is the part that gives),
|
|
146
106
|
`maxLines={1}` (what giving way means for text). Drop any one and the row fails
|
|
147
107
|
in a different way — icons squashed, ellipsis never appearing, or the row
|
|
148
|
-
overflowing its container.
|
|
108
|
+
overflowing its container. Same shape for a count, a badge or a status kept to
|
|
109
|
+
the right of the ellipsis: a sibling with `shrink={false}`.
|
|
110
|
+
|
|
111
|
+
A flex or grid item refuses to become narrower than its content unless it is
|
|
112
|
+
told it may. `maxLines` sets `min-width: 0` on the `Text` itself, but **every
|
|
113
|
+
`Box` between it and the element that actually has a width must say it too**,
|
|
114
|
+
or the whole chain grows instead of truncating:
|
|
149
115
|
|
|
150
|
-
|
|
151
|
-
|
|
116
|
+
```jsx
|
|
117
|
+
<Box flex width="300" spacing="s">
|
|
118
|
+
{/* without minWidth the row just grows */}
|
|
119
|
+
<Box flex expandX minWidth="0">
|
|
120
|
+
<Text maxLines={1}>…</Text>
|
|
121
|
+
</Box>
|
|
122
|
+
</Box>
|
|
123
|
+
```
|
|
152
124
|
|
|
153
125
|
### The text wraps — the end icon must not be left alone
|
|
154
126
|
|
|
155
127
|
No truncation here: the text is allowed to take several lines. The trap is the
|
|
156
128
|
last child. An icon (or a unit, or an arrow) is an atomic inline, so the browser
|
|
157
|
-
is free to break the line right before it — and no character can prevent that;
|
|
158
|
-
word joiner does not suppress a break before an atomic inline. On the wrong
|
|
129
|
+
is free to break the line right before it — and no character can prevent that;
|
|
130
|
+
a word joiner does not suppress a break before an atomic inline. On the wrong
|
|
159
131
|
container width, the icon ends up alone on a line under the label.
|
|
160
132
|
|
|
161
133
|
`attachLastChild` fixes it: the last child and the **last word** before it are
|
|
162
|
-
put in one `white-space: nowrap` box
|
|
163
|
-
|
|
134
|
+
put in one `white-space: nowrap` box — only the last word, since wrapping the
|
|
135
|
+
whole preceding text would stop a long label from wrapping at all.
|
|
164
136
|
|
|
165
137
|
```jsx
|
|
166
138
|
<Text attachLastChild>
|
|
@@ -171,114 +143,112 @@ place where breaking is always wrong.
|
|
|
171
143
|
</Text>
|
|
172
144
|
```
|
|
173
145
|
|
|
174
|
-
Only the last word travels with the child — wrapping the whole preceding text
|
|
175
|
-
would stop a long label from wrapping at all.
|
|
176
|
-
|
|
177
146
|
`Link` does this on its own whenever it renders an end icon (`endIcon`,
|
|
178
|
-
`anchorIcon`, the external-target one)
|
|
179
|
-
|
|
180
|
-
yourself when you build such a pair outside `Link`.
|
|
147
|
+
`anchorIcon`, the external-target one). Write `attachLastChild` yourself when
|
|
148
|
+
you build such a pair outside `Link`.
|
|
181
149
|
|
|
182
150
|
## Spacing between children
|
|
183
151
|
|
|
184
152
|
`Text` injects a separator between its children whenever at least one side is an
|
|
185
|
-
element —
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
- `markAsOutsideTextFlow(Component)` — for something rendered inside a `Text`
|
|
193
|
-
that takes no room in the line (an absolutely positioned indicator, an
|
|
194
|
-
overlay). Without it, a separator is injected next to something invisible and
|
|
195
|
-
leaves a stray gap.
|
|
196
|
-
- `preventSpaceUnderlines` — inside an `<a>`, browsers draw the underline under
|
|
197
|
-
the space characters too. This replaces them with padding-based spaces, so the
|
|
198
|
-
underline stops at the text. `Link` sets it.
|
|
153
|
+
element — an icon and a label are spaced without a manual `{" "}` — and
|
|
154
|
+
`spacing` changes it. Two things opt out of that flow:
|
|
155
|
+
`markAsOutsideTextFlow(Component)`, for something rendered inside a `Text` that
|
|
156
|
+
takes no room in the line (an absolutely positioned indicator, an overlay),
|
|
157
|
+
where a separator would leave a stray gap next to something invisible; and
|
|
158
|
+
`preventSpaceUnderlines`, which `Link` sets, so an underline stops at the text
|
|
159
|
+
instead of running under the spaces.
|
|
199
160
|
|
|
200
161
|
## Emoji
|
|
201
162
|
|
|
202
163
|
**An emoji is not a character the layout can absorb.** The system emoji fonts
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
lines up with its siblings. Tighten the line to get the rows even again and the
|
|
209
|
-
glyph is clipped instead — its box is taller than a letter's, and what sticks
|
|
210
|
-
out is simply cut.
|
|
164
|
+
have a taller ascent/descent than any text font, and under `line-height: normal`
|
|
165
|
+
a line box takes the height of the tallest font it holds. So the moment an emoji
|
|
166
|
+
sits in a line, that line is taller than the ones around it: a row shifts down
|
|
167
|
+
next to its neighbours, a paragraph's lines are unevenly spaced. Tighten the
|
|
168
|
+
line to get the rows even again and the glyph is clipped instead.
|
|
211
169
|
|
|
212
170
|
**One number answers both: `--navi-line-height`, 1.25.** Tall enough to contain
|
|
213
171
|
an emoji's own box, so nothing is clipped; tight enough that a line carrying one
|
|
214
172
|
is exactly as tall as a line of plain text, so nothing moves. 1 cuts the top off
|
|
215
173
|
the glyph, 1.5 spaces the rows out more than reading them asks for. **An app
|
|
216
174
|
that displays what people typed cannot go below 1.25** — that is the floor this
|
|
217
|
-
token encodes
|
|
218
|
-
every component.
|
|
175
|
+
token encodes.
|
|
219
176
|
|
|
220
177
|
**Everything is written on that line, controls included.** The document sets it
|
|
221
|
-
on `:root`, and the
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
in
|
|
227
|
-
of pixels tall. That is what makes a
|
|
228
|
-
value keep its line, and its emoji its size, as it passes from the field it was
|
|
229
|
-
typed in to whatever displays it afterwards — an emoji typed in an input is not
|
|
230
|
-
clipped, and the same emoji displayed in a row, a bubble or a button does not
|
|
231
|
-
push anything around. `text_emoji_demo.html` shows it by swapping the field and
|
|
232
|
-
the rendering in place.
|
|
178
|
+
on `:root`, and the controls that would otherwise start from the browser's
|
|
179
|
+
`normal` — the one value the emoji breaks — are handed it by name: `Button`,
|
|
180
|
+
`Input`, `Textarea`, `Select`, and `Picker` and selectable `List` items, which
|
|
181
|
+
stand in a row with them. A form control inherits nothing from the page on its
|
|
182
|
+
own. So a value keeps its line, and its emoji its size, from the field it was
|
|
183
|
+
typed in to whatever displays it afterwards (`text_emoji_demo.html`).
|
|
233
184
|
|
|
234
185
|
A control takes it snapped to the pixel (`--navi-control-line-height`,
|
|
235
|
-
`round(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
looks too high. A whole number of pixels has no remainder to put anywhere, and
|
|
240
|
-
a `Textarea` sized in `lh` lands on the same grid as the placeholder it
|
|
241
|
-
measures. The page's text keeps the plain number: a length would stop
|
|
242
|
-
following the font size the moment it is inherited.
|
|
186
|
+
`round(calc(var(--navi-line-height) * 1em), 1px)`): a fractional line puts its
|
|
187
|
+
remainder under the glyph, which then sits a pixel above the middle of its
|
|
188
|
+
field. The page's text keeps the plain number, which follows the font size when
|
|
189
|
+
inherited.
|
|
243
190
|
|
|
244
191
|
Change it on `:root` for a whole app. Do not unset it on a component, do not let
|
|
245
192
|
one fall back to `normal`, and do not raise a local `lineHeight` because of an
|
|
246
193
|
emoji: a `lineHeight` is a typographic choice for the text itself — a paragraph
|
|
247
194
|
that wants air — never a workaround for a glyph that grew too tall.
|
|
248
195
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
somebody's real name — a name field keeps to the rules that are about what can
|
|
254
|
-
be drawn at all (`data-displayable`: marks stacked into zalgo, a value that
|
|
255
|
-
shows nothing, blank lines in series).
|
|
196
|
+
Where an emoji belongs is a separate question, and not a layout one: a field
|
|
197
|
+
refuses one with `noEmoji`, and a name field — people are called what they are
|
|
198
|
+
called — keeps to what can be drawn at all (`displayable`) rather than a
|
|
199
|
+
whitelist. See [field_validation.md](./field_validation.md#constraints).
|
|
256
200
|
|
|
257
201
|
## Text that must not move when its style changes
|
|
258
202
|
|
|
259
203
|
A label that becomes bold when its row is selected reflows everything around it.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
glyphs. Works on several lines, handles weight only.
|
|
267
|
-
|
|
268
|
-
And `shrinkWrap` for the reverse problem: an element whose box is wider than the
|
|
269
|
-
longest line it actually renders (a wrapped paragraph inside a flex or grid
|
|
270
|
-
container). It measures and pins the width to that longest line.
|
|
204
|
+
`holdSpaceForStyle={{ fontWeight: "bold" }}` reserves the space with an
|
|
205
|
+
invisible copy in the target style — any style change, single-line only (best
|
|
206
|
+
with `noWrap`); `boldStable` handles weight only, on several lines. And
|
|
207
|
+
`shrinkWrap` for the reverse problem: an element wider than the longest line it
|
|
208
|
+
renders (a wrapped paragraph inside a flex or grid container) is pinned to that
|
|
209
|
+
line.
|
|
271
210
|
|
|
272
211
|
## Loading
|
|
273
212
|
|
|
274
213
|
`loading` renders a shimmering skeleton in place of the content; `skeleton` is
|
|
275
214
|
the same bar without the animation. The children stay in the DOM (hidden), so
|
|
276
|
-
the block keeps the size the real text will have — pass the eventual text,
|
|
277
|
-
placeholder
|
|
278
|
-
|
|
279
|
-
The bar
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
over; navi declares neither, so both can be set on the element or on any
|
|
215
|
+
the block keeps the size the real text will have — **pass the eventual text,
|
|
216
|
+
not a placeholder**, whenever it is known.
|
|
217
|
+
|
|
218
|
+
The bar is painted with the text's own ink, so it is seen on whatever paper it
|
|
219
|
+
lands on without being told what that paper is. Where the surrounding ink is the
|
|
220
|
+
wrong cue, `--skeleton-color` and `--skeleton-shimmer-color` (the moving band)
|
|
221
|
+
take it over; navi declares neither, so both can be set on the element or on any
|
|
284
222
|
container above it.
|
|
223
|
+
|
|
224
|
+
## BadgeList
|
|
225
|
+
|
|
226
|
+
A row of badges that wraps. A plain one costs nothing — one element holding its
|
|
227
|
+
children as they are; only `max`, `fallback`, `maxLines` and `shrinkWrap` count
|
|
228
|
+
or measure. Once the list has something to decide (`max`, `fallback`,
|
|
229
|
+
`maxLines`), a `Badge` inside it does not render itself: it hands its props to
|
|
230
|
+
the list, which draws it. Two consequences:
|
|
231
|
+
|
|
232
|
+
- a `Badge` must be a **direct** child of the list — `<div><Badge /></div>`
|
|
233
|
+
takes the badge out of its wrapper;
|
|
234
|
+
- a badge's own `key` does not reach the badge the list draws, which is keyed
|
|
235
|
+
by position: reordering recreates the nodes rather than moving them.
|
|
236
|
+
|
|
237
|
+
Inside a `Picker`, the rows are capped by the picker's `maxLines` (the picker
|
|
238
|
+
turns its own clamp off: line-clamp never sees a wrapped flex row), and `max`
|
|
239
|
+
composes with it — the `+N` badge takes one of the `max` slots. A picker given
|
|
240
|
+
a `ui` draws that and only that, never its `placeholder`, so an empty
|
|
241
|
+
`BadgeList` there is a blank picker: pass the placeholder text as the list's
|
|
242
|
+
`fallback`.
|
|
243
|
+
|
|
244
|
+
```jsx
|
|
245
|
+
<BadgeList fallback="Select skills…">
|
|
246
|
+
{selected.map((skill) => (
|
|
247
|
+
<Badge key={skill}>{skill}</Badge>
|
|
248
|
+
))}
|
|
249
|
+
</BadgeList>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Plain text is the right shape for it: it reads at the picker's own size, and the
|
|
253
|
+
box stays the same height empty and filled. It is drawn in the normal text
|
|
254
|
+
color, not the placeholder's — a caller's `ui` is never greyed as a placeholder.
|