@jsenv/navi 0.29.80 → 0.29.82
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/jsenv_navi.js +157 -54
- package/dist/jsenv_navi.js.map +9 -10
- package/docs/AI_INSTRUCTIONS.md +6 -0
- package/docs/autofocus.md +61 -20
- package/docs/typography.md +176 -0
- package/package.json +1 -1
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -156,6 +156,12 @@ consistency across the app, not from any single call site.
|
|
|
156
156
|
- `docs/z_index.md` (and its tokens in `src/navi_z_indexes.js`) — stacking: why DOM order is the first tool, what a
|
|
157
157
|
`z-index` without `isolation: isolate` actually competes against, and the
|
|
158
158
|
values navi's own popups/bars/tables use. Read it before writing a `z-index`.
|
|
159
|
+
- `docs/typography.md` — text as a component: why every string goes through
|
|
160
|
+
`Text`, `maxLines` as the one way to truncate (and why `lineClamp={1}` is
|
|
161
|
+
not it), what makes truncation actually happen in a flex row, and the two
|
|
162
|
+
opposite shapes of "icon | text | icon" — the end icon kept outside the
|
|
163
|
+
truncating text, or `attachLastChild` so it never lands alone on a line.
|
|
164
|
+
Read it before writing an overflowing label or a row with a trailing icon.
|
|
159
165
|
- `docs/i18n.md` — where the texts an app displays live: `interpolateText` /
|
|
160
166
|
`<Interpolate>` for one sentence, `createI18n` for the app's registry,
|
|
161
167
|
`naviI18n` for navi's own texts. Read it before writing a user-visible
|
package/docs/autofocus.md
CHANGED
|
@@ -8,7 +8,8 @@ decision.
|
|
|
8
8
|
- [The ladder](#the-ladder)
|
|
9
9
|
- [A popup that is read before it is filled](#a-popup-that-is-read-before-it-is-filled)
|
|
10
10
|
- [The most precise wins](#the-most-precise-wins)
|
|
11
|
-
- [
|
|
11
|
+
- [On a touch device: the surface is what one arrives on](#on-a-touch-device-the-surface-is-what-one-arrives-on)
|
|
12
|
+
- [Opting a field back in](#opting-a-field-back-in)
|
|
12
13
|
- [What a field says about itself](#what-a-field-says-about-itself)
|
|
13
14
|
- [When the opening places nothing](#when-the-opening-places-nothing)
|
|
14
15
|
|
|
@@ -34,7 +35,8 @@ Whoever hands out the focus — a popup opening, a slide arriving — tries thes
|
|
|
34
35
|
in order, and stops at the first that leads somewhere focusable:
|
|
35
36
|
|
|
36
37
|
1. the element that held the focus when this container was last closed;
|
|
37
|
-
2. the first `autoFocus` — "put it here"
|
|
38
|
+
2. the first `autoFocus` — "put it here". The container's own comes last here,
|
|
39
|
+
so a field naming itself wins over the surface around it;
|
|
38
40
|
3. the first focusable element — what one came to do;
|
|
39
41
|
4. the deepest `autoFocus="last-resort"`, the container itself included;
|
|
40
42
|
5. nothing, and the caller decides what that means.
|
|
@@ -65,10 +67,11 @@ the opposite: "anything in here before me".
|
|
|
65
67
|
|
|
66
68
|
## The most precise wins
|
|
67
69
|
|
|
68
|
-
`autoFocus` on a field beats `autoFocus` on the surface around it
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
is what the user came for says so
|
|
70
|
+
`autoFocus` on a field beats `autoFocus` on the surface around it: both are step
|
|
71
|
+
2 of the ladder, and the container's own mark is tried last there. The two can
|
|
72
|
+
be stated together without a conflict to resolve — the surface says where the
|
|
73
|
+
focus goes by default, a field that really is what the user came for says so
|
|
74
|
+
itself.
|
|
72
75
|
|
|
73
76
|
```jsx
|
|
74
77
|
<Dialog autoFocus>
|
|
@@ -77,22 +80,54 @@ is what the user came for says so itself.
|
|
|
77
80
|
</Dialog>
|
|
78
81
|
```
|
|
79
82
|
|
|
80
|
-
##
|
|
83
|
+
## On a touch device: the surface is what one arrives on
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
all: rather than the first text input it finds, the opening falls through to
|
|
86
|
-
what remains, usually the surface itself.
|
|
85
|
+
Where the keyboard is a virtual one — anything answering `pointer: coarse` — a
|
|
86
|
+
popup opening drops step 3 of the ladder entirely: the focus goes where
|
|
87
|
+
something ASKED for it, and otherwise to the surface itself.
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
The condition is the device, not the popup's shape and not the gesture that
|
|
90
|
+
opened it. A virtual keyboard is a fact about the screen: it costs a third of
|
|
91
|
+
the height whichever popup raised it, and a popup opened by the page loading —
|
|
92
|
+
no pointer in it at all — is exactly the one that must not be answered "no
|
|
93
|
+
keyboard here". Docking only makes the cost more visible (a bottom sheet is
|
|
94
|
+
short, so there is less room to lose before the title goes), it is not what
|
|
95
|
+
creates it.
|
|
96
|
+
|
|
97
|
+
Withdrawing only the FIELDS would not be enough either. The first focusable is
|
|
98
|
+
wherever the content happens to put it — and in a popup that explains before it
|
|
99
|
+
asks, what comes first is the explanation, so the first focusable is far down:
|
|
100
|
+
the terms checkbox, the submit button. Landing there scrolls the popup to it and
|
|
101
|
+
the title is above the top edge again, keyboard or no keyboard. The cause
|
|
102
|
+
changes, the user sees the same thing.
|
|
103
|
+
|
|
104
|
+
Nothing to pass, and nothing to remember per call site.
|
|
105
|
+
|
|
106
|
+
### Opting a field back in
|
|
107
|
+
|
|
108
|
+
Some popups really are opened to type in: one comment box, one rename field.
|
|
109
|
+
There, the field says so itself, and that beats the device — step 2 of the
|
|
110
|
+
ladder comes before step 3 was ever skipped.
|
|
111
|
+
|
|
112
|
+
```jsx
|
|
113
|
+
<Dialog>
|
|
114
|
+
<Heading>Leave a comment</Heading>
|
|
115
|
+
<Textarea name="comment" autoFocus />
|
|
116
|
+
</Dialog>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Worth saying out loud before writing it: a popup holding one field is not
|
|
120
|
+
necessarily a popup opened to fill it — it is often opened to READ what the
|
|
121
|
+
field holds, and raising the keyboard over it then costs the reading for nothing.
|
|
122
|
+
The default answers that case; `autoFocus` on the field answers the other, and
|
|
123
|
+
saying which is which is the caller's to make because nothing about the markup
|
|
124
|
+
can tell them apart.
|
|
91
125
|
|
|
92
126
|
## What a field says about itself
|
|
93
127
|
|
|
94
|
-
- `autoFocus` — "I am what the user came for". A picker's search box
|
|
95
|
-
|
|
128
|
+
- `autoFocus` — "I am what the user came for". A picker's search box, the field
|
|
129
|
+
of a popup opened to type in it. It holds on a touch device too: it is how a
|
|
130
|
+
field opts back into a keyboard the surface would otherwise keep down.
|
|
96
131
|
- `autoFocus="restore"` — "never on a fresh open, but bring me back". A field
|
|
97
132
|
the user was typing in when a popup over it closed: reopening returns to it,
|
|
98
133
|
opening for the first time does not raise a keyboard on it.
|
|
@@ -107,9 +142,15 @@ A popup can open on content that holds nothing focusable yet — content still
|
|
|
107
142
|
being built, a screen not yet interactive. The ladder then comes back empty and
|
|
108
143
|
the opening places no focus at all.
|
|
109
144
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
145
|
+
That debt is settled two ways, whichever comes first:
|
|
146
|
+
|
|
147
|
+
- what arrives a moment later takes it — an `autoFocus` in content built during
|
|
148
|
+
the opening is honored, rather than deferring to a transfer that never
|
|
149
|
+
happened;
|
|
150
|
+
- failing that, the ladder is walked once more, one microtask later, still
|
|
151
|
+
before the browser paints and long before the user can do anything. A surface
|
|
152
|
+
that says `autoFocus` about itself is placed by that second try.
|
|
153
|
+
|
|
113
154
|
Without this, the same popup would land the focus in a different place — or
|
|
114
155
|
nowhere — depending on whether it was opened by a click or by the page loading,
|
|
115
156
|
which is the same popup behaving differently for no reason the user can see.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Typography (`<Text>` and friends)
|
|
2
|
+
|
|
3
|
+
What we want: **text is a component, not a tag.** Every string an app displays
|
|
4
|
+
goes through `Text` (or something built on it — `Title`, `Paragraph`,
|
|
5
|
+
`Caption`, `Code`, `Badge`, `Link`, a control's label). The reason is not
|
|
6
|
+
styling: it is that a line of text is rarely only text. It carries an icon, a
|
|
7
|
+
count, a unit, a loading state, a truncation, an anchor to click. Each of those
|
|
8
|
+
has exactly one correct spelling, and `Text` is where that spelling lives — so
|
|
9
|
+
that a screen written by one person and a screen written six months later break
|
|
10
|
+
lines, space icons and truncate the same way.
|
|
11
|
+
|
|
12
|
+
The corollary: when a piece of text does something the raw tag cannot express,
|
|
13
|
+
the answer is a `Text` prop, not CSS written beside it. If the prop does not
|
|
14
|
+
exist, it is missing from `Text` — that is where to add it.
|
|
15
|
+
|
|
16
|
+
```jsx
|
|
17
|
+
<Text>Hello</Text> // a span
|
|
18
|
+
<Text as="p">A paragraph</Text> // any tag
|
|
19
|
+
<Title>A heading</Title> // h1..h6, bold, spaced
|
|
20
|
+
<Caption>A discreet note</Caption> // small, dimmed
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`Text` accepts every `Box` prop (`color`, `size`, `padding`, `expandX`, …), so
|
|
24
|
+
there is never a `<div style>` wrapped around it just to place or color it.
|
|
25
|
+
`size` takes the typography tokens `xxs | xs | s | m | l | xl | xxl` (or any CSS
|
|
26
|
+
length); the spacing props take the spacing tokens of the same name. Two scales,
|
|
27
|
+
same names — `size="l"` is a font size, `padding="l"` is a gap.
|
|
28
|
+
|
|
29
|
+
Live examples: `src/text/demos/*_demo.html` — one page per concern
|
|
30
|
+
(`text_overflow_demo.html`, `text_spacing_demo.html`, `text_loading_demo.html`,
|
|
31
|
+
`text_attach_last_child_demo.html`, `icon_demo.html`).
|
|
32
|
+
|
|
33
|
+
## Truncating: `maxLines`, and nothing else
|
|
34
|
+
|
|
35
|
+
**`maxLines` is the only prop to reach for when text must not exceed a given
|
|
36
|
+
height.** One prop covers both truncations, because from the call site they are
|
|
37
|
+
one decision — "how many lines am I allowed" — even though the browser
|
|
38
|
+
implements them with two unrelated mechanisms:
|
|
39
|
+
|
|
40
|
+
```jsx
|
|
41
|
+
<Text maxLines={1}>Truncated on one line, with an ellipsis…</Text>
|
|
42
|
+
<Text maxLines={3}>Up to three lines, then an ellipsis…</Text>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Do **not** write `lineClamp={1}`. `lineClamp` and `overflowEllipsis` are raw
|
|
46
|
+
`Box` style props: they map to `-webkit-line-clamp` and to
|
|
47
|
+
`overflow/text-overflow` one-to-one, and are there for the case where an element
|
|
48
|
+
is not a `Text` (or deliberately opts out of it) and still needs that CSS. They
|
|
49
|
+
know nothing about each other, so `lineClamp={1}` gives a single-line webkit box
|
|
50
|
+
without the single-line handling `maxLines={1}` brings — `maxLines` switches the
|
|
51
|
+
element to a block, sets `min-width: 0` on itself, and keeps `white-space`
|
|
52
|
+
sensible for the tag it renders (a `<p>` keeps its line breaks). On a `Text`,
|
|
53
|
+
`maxLines` is always the right answer.
|
|
54
|
+
|
|
55
|
+
### Truncation only happens if something says "you may shrink"
|
|
56
|
+
|
|
57
|
+
A flex or grid item refuses to become narrower than its content unless it is
|
|
58
|
+
told it may. `maxLines` sets `min-width: 0` on the `Text` itself, so the leaf is
|
|
59
|
+
covered — but **every `Box` between it and the element that actually has a
|
|
60
|
+
width must say it too**, or the whole chain grows instead of truncating:
|
|
61
|
+
|
|
62
|
+
```jsx
|
|
63
|
+
<Box flex width="300" spacing="s">
|
|
64
|
+
<Box flex expandX minWidth="0"> {/* without minWidth the row just grows */}
|
|
65
|
+
<Text maxLines={1}>…</Text>
|
|
66
|
+
</Box>
|
|
67
|
+
</Box>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Symmetrically, whatever must keep its full size says so with `shrink={false}`.
|
|
71
|
+
A row where the text gives way and nothing else does is the normal shape — see
|
|
72
|
+
the recipes below.
|
|
73
|
+
|
|
74
|
+
## A row: icon, text, icon
|
|
75
|
+
|
|
76
|
+
Two rows that look alike on screen and are built the opposite way. Which one you
|
|
77
|
+
want is decided by a single question: **when there is not enough room, does the
|
|
78
|
+
text truncate or does it wrap?**
|
|
79
|
+
|
|
80
|
+
### The text truncates — the end icon stays visible
|
|
81
|
+
|
|
82
|
+
Anything that must survive truncation lives **outside** the truncating `Text`,
|
|
83
|
+
as a sibling in a flex row. Inside, it would be eaten by the ellipsis like any
|
|
84
|
+
other character.
|
|
85
|
+
|
|
86
|
+
```jsx
|
|
87
|
+
<Box flex spacing="s" alignY="center" width="300">
|
|
88
|
+
<Icon shrink={false}>
|
|
89
|
+
<StarSvg />
|
|
90
|
+
</Icon>
|
|
91
|
+
<Text maxLines={1} expandX>
|
|
92
|
+
A label long enough that it has to be cut before the trailing icon goes
|
|
93
|
+
</Text>
|
|
94
|
+
<Icon shrink={false}>
|
|
95
|
+
<ChevronSvg />
|
|
96
|
+
</Icon>
|
|
97
|
+
</Box>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The three parts of it, and each is load-bearing: `shrink={false}` on the icons
|
|
101
|
+
(they are the fixed part), `expandX` on the text (it is the part that gives),
|
|
102
|
+
`maxLines={1}` (what giving way means for text). Drop any one and the row fails
|
|
103
|
+
in a different way — icons squashed, ellipsis never appearing, or the row
|
|
104
|
+
overflowing its container.
|
|
105
|
+
|
|
106
|
+
Same shape for a count, a badge or a status kept to the right of the ellipsis:
|
|
107
|
+
it is a sibling with `shrink={false}`, not a child of the truncating text.
|
|
108
|
+
|
|
109
|
+
### The text wraps — the end icon must not be left alone
|
|
110
|
+
|
|
111
|
+
No truncation here: the text is allowed to take several lines. The trap is the
|
|
112
|
+
last child. An icon (or a unit, or an arrow) is an atomic inline, so the browser
|
|
113
|
+
is free to break the line right before it — and no character can prevent that; a
|
|
114
|
+
word joiner does not suppress a break before an atomic inline. On the wrong
|
|
115
|
+
container width, the icon ends up alone on a line under the label.
|
|
116
|
+
|
|
117
|
+
`attachLastChild` fixes it: the last child and the **last word** before it are
|
|
118
|
+
put in one `white-space: nowrap` box, the classic widow fix applied to the one
|
|
119
|
+
place where breaking is always wrong.
|
|
120
|
+
|
|
121
|
+
```jsx
|
|
122
|
+
<Text attachLastChild>
|
|
123
|
+
A title long enough to wrap onto several lines
|
|
124
|
+
<Icon>
|
|
125
|
+
<ExternalSvg />
|
|
126
|
+
</Icon>
|
|
127
|
+
</Text>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Only the last word travels with the child — wrapping the whole preceding text
|
|
131
|
+
would stop a long label from wrapping at all.
|
|
132
|
+
|
|
133
|
+
`Link` does this on its own whenever it renders an end icon (`endIcon`,
|
|
134
|
+
`anchorIcon`, the external-target one), which is why a document's table of
|
|
135
|
+
contents never drops an arrow on a line of its own. Write `attachLastChild`
|
|
136
|
+
yourself when you build such a pair outside `Link`.
|
|
137
|
+
|
|
138
|
+
## Spacing between children
|
|
139
|
+
|
|
140
|
+
`Text` injects a separator between its children whenever at least one side is an
|
|
141
|
+
element — so an icon and a label are spaced without a manual `{" "}`, and two
|
|
142
|
+
plain strings are left alone. `spacing` changes it: a size token (`"s"`, `"m"`,
|
|
143
|
+
…), a CSS length, a number (px), a string used verbatim, or `"pre"` / `0` to
|
|
144
|
+
inject nothing.
|
|
145
|
+
|
|
146
|
+
Two things opt out of that flow:
|
|
147
|
+
|
|
148
|
+
- `markAsOutsideTextFlow(Component)` — for something rendered inside a `Text`
|
|
149
|
+
that takes no room in the line (an absolutely positioned indicator, an
|
|
150
|
+
overlay). Without it, a separator is injected next to something invisible and
|
|
151
|
+
leaves a stray gap.
|
|
152
|
+
- `preventSpaceUnderlines` — inside an `<a>`, browsers draw the underline under
|
|
153
|
+
the space characters too. This replaces them with padding-based spaces, so the
|
|
154
|
+
underline stops at the text. `Link` sets it.
|
|
155
|
+
|
|
156
|
+
## Text that must not move when its style changes
|
|
157
|
+
|
|
158
|
+
A label that becomes bold when its row is selected reflows everything around it.
|
|
159
|
+
Two answers, and the one to pick depends on whether the text wraps:
|
|
160
|
+
|
|
161
|
+
- `holdSpaceForStyle={{ fontWeight: "bold" }}` — an invisible copy rendered in
|
|
162
|
+
the target style reserves the space; the visible text is layered on top. Handles
|
|
163
|
+
any style change (weight, size), single-line only. Best with `noWrap`.
|
|
164
|
+
- `boldStable` — paints normal-weight text over a bold background clipped to the
|
|
165
|
+
glyphs. Works on several lines, handles weight only.
|
|
166
|
+
|
|
167
|
+
And `shrinkWrap` for the reverse problem: an element whose box is wider than the
|
|
168
|
+
longest line it actually renders (a wrapped paragraph inside a flex or grid
|
|
169
|
+
container). It measures and pins the width to that longest line.
|
|
170
|
+
|
|
171
|
+
## Loading
|
|
172
|
+
|
|
173
|
+
`loading` renders a shimmering skeleton in place of the content; `skeleton` is
|
|
174
|
+
the same bar without the animation. The children stay in the DOM (hidden), so
|
|
175
|
+
the block keeps the size the real text will have — pass the eventual text, not a
|
|
176
|
+
placeholder, whenever it is known.
|