@jsenv/navi 0.29.103 → 0.29.105
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/dist/jsenv_navi.js +1268 -863
- package/dist/jsenv_navi.js.map +184 -155
- package/docs/AI_INSTRUCTIONS.md +7 -2
- package/docs/actions.md +33 -0
- package/docs/badge_list.md +3 -2
- package/docs/interactions.md +57 -11
- package/docs/popup_open.md +74 -0
- package/docs/typography.md +43 -2
- package/package.json +2 -2
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -202,7 +202,8 @@ consistency across the app, not from any single call site.
|
|
|
202
202
|
`Text`, `maxLines` as the one way to truncate (and why `lineClamp={1}` is
|
|
203
203
|
not it), what makes truncation actually happen in a flex row, and the two
|
|
204
204
|
opposite shapes of "icon | text | icon" — the end icon kept outside the
|
|
205
|
-
truncating text, or `attachLastChild` so it never lands alone on a line
|
|
205
|
+
truncating text, or `attachLastChild` so it never lands alone on a line —
|
|
206
|
+
and why an emoji belongs only in free text, rendered with `emojiAsIcon`.
|
|
206
207
|
Read it before writing an overflowing label or a row with a trailing icon.
|
|
207
208
|
- `docs/i18n.md` — where the texts an app displays live: `interpolateText` /
|
|
208
209
|
`<Interpolate>` for one sentence, `createI18n` for the app's registry,
|
|
@@ -217,7 +218,11 @@ consistency across the app, not from any single call site.
|
|
|
217
218
|
- `docs/interactions.md` — the `interactions` prop: making a component answer a
|
|
218
219
|
swipe, a held press, a shortcut, and registering a gesture navi does not have.
|
|
219
220
|
It also holds `ownTarget`, for an affordance an application draws inside a
|
|
220
|
-
zone that belongs to another control — a chip's cross, an eye, a diskette
|
|
221
|
+
zone that belongs to another control — a chip's cross, an eye, a diskette:
|
|
222
|
+
the three modes and the question that picks one (does it write to the control
|
|
223
|
+
it sits in?), the `Box` form that claims the press and nothing more (so an
|
|
224
|
+
affordance keeps its own drawing instead of becoming a control), and what
|
|
225
|
+
`ownTarget` does NOT stop (a plain `onClick` on an ancestor).
|
|
221
226
|
Read it before reading the pointer by hand — who owns a press between nested
|
|
222
227
|
boxes, and what a touch may do, are decided before the first pixel moves and
|
|
223
228
|
cannot be got right from outside navi — and before stopping the propagation of
|
package/docs/actions.md
CHANGED
|
@@ -168,6 +168,39 @@ To merely REMEMBER the value rather than send it, neither is the answer: bind a
|
|
|
168
168
|
signal and drop the callback entirely — see
|
|
169
169
|
[control_value.md](./control_value.md).
|
|
170
170
|
|
|
171
|
+
## A press that opens something and waits for the answer
|
|
172
|
+
|
|
173
|
+
A press that runs work is an `action`; one that reports a value is a `uiAction`;
|
|
174
|
+
one that asks something of a control near it is a `command` (a value proposed is
|
|
175
|
+
`--navi-update`, see
|
|
176
|
+
[control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
|
|
177
|
+
Reaching for a plain `onClick` usually means one of those was missed.
|
|
178
|
+
|
|
179
|
+
The press that looks like a fourth case is the one that opens something and then
|
|
180
|
+
does something with what came of it — "save this guest", pressed on a row,
|
|
181
|
+
replacing the guest once the profile exists. It is not a fourth case and it is
|
|
182
|
+
not a dialog plus a way home: it is a `Picker`, whose whole shape is a trigger,
|
|
183
|
+
a popup, and an `action` that runs on what the popup settled.
|
|
184
|
+
|
|
185
|
+
```jsx
|
|
186
|
+
<Picker variant="icon" rightSlotIcon={<DisketteSvg />} action={onCreated}>
|
|
187
|
+
<GuestSavePrompt kind="player" name={guest.name} />
|
|
188
|
+
</Picker>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Written this way, what the popup needs to know travels as props rather than
|
|
192
|
+
through the press, and the popup is built the first time it opens rather than
|
|
193
|
+
once per row. See
|
|
194
|
+
[popup_open.md](./popup_open.md#a-press-that-opens-a-popup-and-acts-on-it).
|
|
195
|
+
|
|
196
|
+
What is left for an `onClick` is what no value can express — imperative work
|
|
197
|
+
with nothing to open and nothing to send. And one nuance worth knowing: on an
|
|
198
|
+
`ownTarget`, a caller's `onClick` runs inside that control's own interaction
|
|
199
|
+
gate rather than firing from the DOM (see
|
|
200
|
+
[interactions.md](./interactions.md#an-affordance-inside-somebody-elses-box-owntarget)),
|
|
201
|
+
so the usual objection — an `onClick` fires on a read-only control — does not
|
|
202
|
+
apply there. Everywhere else it does.
|
|
203
|
+
|
|
171
204
|
## `uiAction` mirrors the state, it does not report a gesture
|
|
172
205
|
|
|
173
206
|
`uiAction` fires whenever the control's state changes, whoever changed it. The
|
package/docs/badge_list.md
CHANGED
|
@@ -10,8 +10,9 @@ for, and nothing is set up for a case that cannot happen:
|
|
|
10
10
|
- **`shrinkWrap`** — a hidden clone of the list is laid out to find the widest
|
|
11
11
|
wrapped row, and the list is narrowed to it so the last row isn't ragged.
|
|
12
12
|
Opt-in outside a `Picker` (a picker draws a border around the list, so the
|
|
13
|
-
ragged edge shows; elsewhere the work would often go unseen)
|
|
14
|
-
|
|
13
|
+
ragged edge shows; elsewhere the work would often go unseen). With `maxLines`
|
|
14
|
+
the rows are read first, at the full width, and the list is narrowed once the
|
|
15
|
+
surplus is gone — in place, no clone.
|
|
15
16
|
- **`maxLines`** — every badge is laid out once, hidden; where the rows fell is
|
|
16
17
|
read; the list is rendered again with the badges that fit and a `+N more`
|
|
17
18
|
badge for the rest. Both renders land in the same frame. A width change of
|
package/docs/interactions.md
CHANGED
|
@@ -531,15 +531,11 @@ sideways), while a `move` goes wherever it is put, a `land` wherever the board h
|
|
|
531
531
|
places and a `toss` wherever it was thrown (`xy`). `data-drag-delay`,
|
|
532
532
|
`data-drag-slop`, `data-drag-threshold` tune when the press becomes a grab.
|
|
533
533
|
|
|
534
|
-
###
|
|
534
|
+
### An affordance inside somebody else's box: `ownTarget`
|
|
535
535
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
the mousedown or the click. It also follows the interactivity of the zone around
|
|
540
|
-
it: read-only, disabled or busy, the affordance goes rather than greys (pass
|
|
541
|
-
`ownTarget="refuse"` for one whose presence is information in itself), and its
|
|
542
|
-
`onClick` waits for its own gate instead of firing from the DOM.
|
|
536
|
+
A chip's cross inside a carried piece, an eye on a row that travels, a diskette
|
|
537
|
+
on a picker's façade. It is aimed AT, not merely inside, and the press belongs
|
|
538
|
+
to it alone:
|
|
543
539
|
|
|
544
540
|
```jsx
|
|
545
541
|
<Badge.Button ownTarget onClick={() => remove(id)}>
|
|
@@ -547,9 +543,59 @@ it: read-only, disabled or busy, the affordance goes rather than greys (pass
|
|
|
547
543
|
</Badge.Button>
|
|
548
544
|
```
|
|
549
545
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
546
|
+
No gesture starts under it, no navi control above it answers the mousedown or
|
|
547
|
+
the click, and its `onClick` waits for its own interaction gate instead of
|
|
548
|
+
firing from the DOM.
|
|
549
|
+
|
|
550
|
+
#### Does it write to the control it sits in?
|
|
551
|
+
|
|
552
|
+
That question, and nothing else, picks the mode:
|
|
553
|
+
|
|
554
|
+
| what it does | mode | on a read-only / disabled / busy zone |
|
|
555
|
+
| ------------------------------------------------------------ | -------------------- | ----------------------------------------- |
|
|
556
|
+
| writes to it (a cross that removes, a stepper) | `ownTarget` | it goes |
|
|
557
|
+
| writes to it, and its presence says there is something there | `ownTarget="refuse"` | it stays and refuses with a callout |
|
|
558
|
+
| never touches it (a diskette saving into MY address book) | `ownTarget="always"` | nothing changes: still lit, still pressed |
|
|
559
|
+
|
|
560
|
+
A greyed cross that still removes is worse than no cross — hence the default.
|
|
561
|
+
`"always"` is the other extreme and the caller owns it: the zone's read-only is
|
|
562
|
+
about a value the affordance does not write, so answering "read-only" to a
|
|
563
|
+
gesture that was never going to write anything says nothing true. Use it only
|
|
564
|
+
when that is really the case.
|
|
565
|
+
|
|
566
|
+
#### On something you draw yourself
|
|
567
|
+
|
|
568
|
+
`ownTarget` is a `Box` prop too, so an affordance does not have to become a
|
|
569
|
+
control to claim its press — a pastille positioned in a card's corner by its own
|
|
570
|
+
class stays exactly what it was drawn as:
|
|
571
|
+
|
|
572
|
+
```jsx
|
|
573
|
+
<Box as="button" ownTarget className="court_side" onClick={explain}>
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
On a box the prop does exactly one thing: it writes `data-own-target`. That
|
|
577
|
+
attribute is the claim — it is what the controls above read, and what the
|
|
578
|
+
gesture readers read (`data-drag-handle`, `data-drag-ignore` and friends are the
|
|
579
|
+
same vocabulary). Writing it by hand on an element navi does not render works
|
|
580
|
+
and is the last resort: a typo there is silent, whereas the prop is spelled
|
|
581
|
+
once.
|
|
582
|
+
|
|
583
|
+
The modes above are the other half, and they belong to controls: they are about
|
|
584
|
+
a gate, a callout and a control's own read-only, none of which a box has. A box
|
|
585
|
+
claims the press and nothing more; put the affordance on a control when what it
|
|
586
|
+
does about a held zone matters.
|
|
587
|
+
|
|
588
|
+
#### navi steps back; a plain `onClick` does not
|
|
589
|
+
|
|
590
|
+
What `ownTarget` stops is navi answering: the controls above it, and the
|
|
591
|
+
gestures. It does **not** stop the event — the propagation is left whole, so
|
|
592
|
+
that everything which is not a navi interaction still sees the press it always
|
|
593
|
+
saw. A raw `onClick` on an ancestor is one of those, and still fires; stop it
|
|
594
|
+
there yourself if it must not.
|
|
595
|
+
|
|
596
|
+
`data-drag-ignore` says a narrower thing, to the gesture alone: the press there
|
|
597
|
+
is none of the gesture's business, and the element keeps both its cursor and its
|
|
598
|
+
text selection.
|
|
553
599
|
|
|
554
600
|
### What says a thing can be picked up
|
|
555
601
|
|
package/docs/popup_open.md
CHANGED
|
@@ -8,6 +8,7 @@ What opens a `Dialog` or a `Popover`, and who owns the fact that it is open.
|
|
|
8
8
|
- [Which element receives the command](#which-element-receives-the-command)
|
|
9
9
|
- [The anchor](#the-anchor)
|
|
10
10
|
- [Opening it ON something](#opening-it-on-something)
|
|
11
|
+
- [A press that opens a popup and acts on it](#a-press-that-opens-a-popup-and-acts-on-it)
|
|
11
12
|
- [Reacting to open and close](#reacting-to-open-and-close)
|
|
12
13
|
- [Escape cancels, the other gestures keep](#escape-cancels-the-other-gestures-keep)
|
|
13
14
|
- [When `open` is the right answer, and what it costs](#when-open-is-the-right-answer-and-what-it-costs)
|
|
@@ -202,6 +203,79 @@ of the popup acting on it — but it is ordered against the popup's own handler
|
|
|
202
203
|
registration, and it has to be attached in an effect on a ref. `onOpen` is that
|
|
203
204
|
moment, said as a prop.
|
|
204
205
|
|
|
206
|
+
## A press that opens a popup and acts on it
|
|
207
|
+
|
|
208
|
+
A press that opens something and then does something with what came of it — a
|
|
209
|
+
"save this guest" prompt on a row, which replaces the guest once the profile
|
|
210
|
+
exists — is not a dialog plus a way home. It is a `Picker`: a trigger, a popup,
|
|
211
|
+
and an `action` that runs on what the popup settled.
|
|
212
|
+
|
|
213
|
+
```jsx
|
|
214
|
+
// one per row: the trigger IS the thing that receives the answer
|
|
215
|
+
<Picker
|
|
216
|
+
variant="icon"
|
|
217
|
+
rightSlotIcon={<DisketteSvg />}
|
|
218
|
+
action={async (created) => {
|
|
219
|
+
await USERS.GET_MANY.rerun();
|
|
220
|
+
replaceGuest(guest, created);
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<GuestSavePrompt kind="player" name={guest.name} />
|
|
224
|
+
</Picker>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Two things fall out of writing it this way, and both are the reason to prefer it
|
|
228
|
+
over a shared dialog opened by `--navi-open`:
|
|
229
|
+
|
|
230
|
+
- **what the popup needs to know travels as props**, because the popup is
|
|
231
|
+
written where the press is. No value to carry through the command, nothing to
|
|
232
|
+
read back out of an event;
|
|
233
|
+
- **the popup is built the first time it opens**, not once per row on the render
|
|
234
|
+
that draws the list (see [what a popup holds while it is
|
|
235
|
+
closed](#what-the-popup-holds-while-it-is-closed)). A hundred rows is a
|
|
236
|
+
hundred triggers, not a hundred dialogs.
|
|
237
|
+
|
|
238
|
+
The same component can of course be written once and used in every picker —
|
|
239
|
+
`<GuestSavePrompt>` above is one — so "the prompt exists once" is a question
|
|
240
|
+
about components, not about the DOM.
|
|
241
|
+
|
|
242
|
+
### Composing a value, or doing work
|
|
243
|
+
|
|
244
|
+
A picker mirrors **one** control in its popup — the first one that is not a
|
|
245
|
+
button, a link or a control saying it is not the answer (`allowNameless`). That
|
|
246
|
+
mirror is what makes `<Picker><List selectable/></Picker>` work with nothing
|
|
247
|
+
wired: the picker's value IS the list's, both ways, and the picker's `action`
|
|
248
|
+
runs on it when the popup closes.
|
|
249
|
+
|
|
250
|
+
That is the shape for a popup that **composes a value**. A popup that **does
|
|
251
|
+
work** — creates a profile, uploads a file — is the other shape, and it does not
|
|
252
|
+
need the picker's `action` at all: the work is written where the press is, so
|
|
253
|
+
its callback already has everything around it.
|
|
254
|
+
|
|
255
|
+
```jsx
|
|
256
|
+
<Picker variant="icon" rightSlotIcon={<DisketteSvg />}>
|
|
257
|
+
<Form
|
|
258
|
+
action={async (fields) => {
|
|
259
|
+
const created = await USERS.POST(fields);
|
|
260
|
+
replaceGuest(guest, created); // the row is right here
|
|
261
|
+
}}
|
|
262
|
+
>
|
|
263
|
+
…
|
|
264
|
+
</Form>
|
|
265
|
+
</Picker>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Nothing travels back, because nothing left. This is the difference a shared
|
|
269
|
+
dialog hides: a popup written once, far from every press that opens it, has to
|
|
270
|
+
be told what it is about and has to answer somebody — and neither question
|
|
271
|
+
exists once the popup is written where it is used.
|
|
272
|
+
|
|
273
|
+
Do not mix the two. A `<Form>` at the root of a picker's popup IS the mirrored
|
|
274
|
+
control, so its value is the picker's value: handing the picker something else
|
|
275
|
+
(a created profile, say) pushes it back down into the form's named fields and
|
|
276
|
+
comes back as the form's aggregate. When the popup does work, let the work keep
|
|
277
|
+
its result.
|
|
278
|
+
|
|
205
279
|
## Reacting to open and close
|
|
206
280
|
|
|
207
281
|
`onOpen` is called on every open, before the popup builds anything (see
|
package/docs/typography.md
CHANGED
|
@@ -28,7 +28,7 @@ same names — `size="l"` is a font size, `padding="l"` is a gap.
|
|
|
28
28
|
|
|
29
29
|
Live examples: `src/text/demos/*_demo.html` — one page per concern
|
|
30
30
|
(`text_overflow_demo.html`, `text_spacing_demo.html`, `text_loading_demo.html`,
|
|
31
|
-
`text_attach_last_child_demo.html`, `icon_demo.html`).
|
|
31
|
+
`text_attach_last_child_demo.html`, `text_emoji_demo.html`, `icon_demo.html`).
|
|
32
32
|
|
|
33
33
|
## Truncating: `maxLines`, and nothing else
|
|
34
34
|
|
|
@@ -61,7 +61,9 @@ width must say it too**, or the whole chain grows instead of truncating:
|
|
|
61
61
|
|
|
62
62
|
```jsx
|
|
63
63
|
<Box flex width="300" spacing="s">
|
|
64
|
-
<Box flex expandX minWidth="0">
|
|
64
|
+
<Box flex expandX minWidth="0">
|
|
65
|
+
{" "}
|
|
66
|
+
{/* without minWidth the row just grows */}
|
|
65
67
|
<Text maxLines={1}>…</Text>
|
|
66
68
|
</Box>
|
|
67
69
|
</Box>
|
|
@@ -153,6 +155,45 @@ Two things opt out of that flow:
|
|
|
153
155
|
the space characters too. This replaces them with padding-based spaces, so the
|
|
154
156
|
underline stops at the text. `Link` sets it.
|
|
155
157
|
|
|
158
|
+
## Emoji
|
|
159
|
+
|
|
160
|
+
**An emoji is not a character the layout can absorb.** The system emoji fonts
|
|
161
|
+
(Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji) have a taller
|
|
162
|
+
ascent/descent than any text font, so the moment an emoji sits in a line, that
|
|
163
|
+
line is taller than the ones around it: a row shifts down next to its
|
|
164
|
+
neighbours, a paragraph's lines are unevenly spaced, a truncated label no
|
|
165
|
+
longer lines up with its siblings.
|
|
166
|
+
|
|
167
|
+
So, first: **an emoji is only expected in free text a user typed** — a message,
|
|
168
|
+
a comment, a description. It has no business in a name, a title, an identifier,
|
|
169
|
+
a label. Validate those fields so it never gets in, rather than teaching every
|
|
170
|
+
row of the app to survive it.
|
|
171
|
+
|
|
172
|
+
Where it is expected, `emojiAsIcon`:
|
|
173
|
+
|
|
174
|
+
```jsx
|
|
175
|
+
<Text emojiAsIcon>Salut 👋 on se retrouve au parc 🌳 ?</Text>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Every emoji found in the string children is rendered inside an `Icon`, which
|
|
179
|
+
caps it at `1em` and centers it on the line like any glyph icon — the line keeps
|
|
180
|
+
the height of its text, on one line or several. This is what chat apps do (an
|
|
181
|
+
emoji is a 1em box, never a glyph with its own metrics). `Button` and
|
|
182
|
+
`MessageBox` have it on by default — a label is one line whose height
|
|
183
|
+
everything around it relies on, a message is free text; `Badge` forwards it,
|
|
184
|
+
opt-in.
|
|
185
|
+
|
|
186
|
+
What it does not do, and that is accepted: under `maxLines` the `Text` clips at
|
|
187
|
+
its own box — that is what truncation is — and an emoji drawn a little beyond
|
|
188
|
+
its 1em box can lose a sliver at the top. The line stays aligned, which is the
|
|
189
|
+
part that matters.
|
|
190
|
+
|
|
191
|
+
**Do not raise `lineHeight` because of emoji.** The default line height keeps
|
|
192
|
+
text compact and, with `emojiAsIcon`, holds up even when nearly every word is
|
|
193
|
+
an emoji (see the dense cases in `text_emoji_demo.html`). A `lineHeight` is a
|
|
194
|
+
typographic choice for the text itself — a paragraph that wants air — never a
|
|
195
|
+
workaround for a glyph that grew too tall; that glyph gets `emojiAsIcon`.
|
|
196
|
+
|
|
156
197
|
## Text that must not move when its style changes
|
|
157
198
|
|
|
158
199
|
A label that becomes bold when its row is selected reflows everything around it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.105",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Library of components including navigation to create frontend applications",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"prepublishOnly": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@jsenv/dom": "0.17.
|
|
32
|
+
"@jsenv/dom": "0.17.28",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|