@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.
@@ -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
- - [Docked on a small touch screen: fields are withdrawn](#docked-on-a-small-touch-screen-fields-are-withdrawn)
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 (step 2 above
69
- comes before step 4). The two can be stated together without a conflict to
70
- resolve: the surface says where the focus goes by default, a field that really
71
- is what the user came for says so itself.
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
- ## Docked on a small touch screen: fields are withdrawn
83
+ ## On a touch device: the surface is what one arrives on
81
84
 
82
- A `Dialog` with `dockedOnSmallTouchScreen` becomes a bottom sheet on a phone
83
- the one shape the keyboard hurts most, since the sheet starts at the very edge
84
- the keyboard covers. There, step 3 of the ladder does not consider fields at
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
- Nothing to pass, and nothing to remember per call site. A field that wants the
89
- keyboard on a phone still says so with its own `autoFocus`, which is where that
90
- decision belongs.
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 on a
95
- desktop, the one field of a one-field popup.
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
- What arrives a moment later is allowed to take it: an opening that placed
111
- nothing owes the focus to whatever appears next, and an `autoFocus` in that
112
- content is honored rather than deferring to a transfer that never happened.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.80",
3
+ "version": "0.29.82",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {