@jsenv/navi 0.29.27 → 0.29.29
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 +4 -0
- package/dist/jsenv_navi.js +17628 -16861
- package/dist/jsenv_navi.js.map +159 -150
- package/docs/AI_INSTRUCTIONS.md +18 -0
- package/docs/i18n.md +183 -0
- package/docs/interactions.md +375 -0
- package/docs/resource.md +11 -11
- package/package.json +4 -4
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -47,6 +47,15 @@ consistency across the app, not from any single call site.
|
|
|
47
47
|
- `docs/z_index.md` — stacking: why DOM order is the first tool, what a
|
|
48
48
|
`z-index` without `isolation: isolate` actually competes against, and the
|
|
49
49
|
values navi's own popups/bars/tables use. Read it before writing a `z-index`.
|
|
50
|
+
- `docs/i18n.md` — where the texts an app displays live: `interpolateText` /
|
|
51
|
+
`<Interpolate>` for one sentence, `createI18n` for the app's registry,
|
|
52
|
+
`naviI18n` for navi's own texts. Read it before writing a user-visible
|
|
53
|
+
sentence, and before overriding a navi message.
|
|
54
|
+
- `docs/interactions.md` — the `interactions` prop: making a component answer a
|
|
55
|
+
swipe, a held press, a shortcut, and registering a gesture navi does not have.
|
|
56
|
+
Read it before reading the pointer by hand — who owns a press between nested
|
|
57
|
+
boxes, and what a touch may do, are decided before the first pixel moves and
|
|
58
|
+
cannot be got right from outside navi.
|
|
50
59
|
- `docs/MOBILE_LAYOUT_PITFALLS.md` — mobile-specific layout gotchas (viewport
|
|
51
60
|
units, virtual keyboard, safe areas).
|
|
52
61
|
- `docs/navigation.md` — how to build navigation: declaring routes
|
|
@@ -80,6 +89,15 @@ consistency across the app, not from any single call site.
|
|
|
80
89
|
- **Field components** (`Input`, `Select`, `Checkbox`, etc.) take an `action`
|
|
81
90
|
prop to respond to interaction — this is the standard wiring, not
|
|
82
91
|
`onChange` + manual state.
|
|
92
|
+
- **A gesture is named, not read by hand**: `interactions={{ swipe_right: … }}`
|
|
93
|
+
on any `Box` (so on any component). Never a `pointerdown` listener of your own
|
|
94
|
+
— see `docs/interactions.md`.
|
|
95
|
+
- **Texts**: a user-visible sentence containing a value is written as one
|
|
96
|
+
template with `[placeholder]`s (`interpolateText` / `<Interpolate>`), not cut
|
|
97
|
+
into JSX fragments or concatenations. Beyond a handful of texts, an app
|
|
98
|
+
declares them in its own `createI18n()` instance — using the English text
|
|
99
|
+
itself as the key, whereas navi's `naviI18n` uses opaque keys. Application
|
|
100
|
+
texts never go into `naviI18n`. See `docs/i18n.md`.
|
|
83
101
|
- **View transitions**: navi components animate their own changes
|
|
84
102
|
(`itemTransition` on `List`, `RouteTravel` for routes) and never decide for
|
|
85
103
|
the whole document. Two things are the application's call, not navi's:
|
package/docs/i18n.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# Texts and i18n
|
|
2
|
+
|
|
3
|
+
Every text an app displays is a value it owns, like any other. The goal here is
|
|
4
|
+
that those values live somewhere findable and are written as whole sentences —
|
|
5
|
+
not cut into JSX fragments, not concatenated at the call site, not scattered so
|
|
6
|
+
widely that changing the tone of the app means grepping for punctuation.
|
|
7
|
+
|
|
8
|
+
Translation is a consequence of doing that, not the reason to do it. An app with
|
|
9
|
+
one language still benefits from one place holding its wording; and once it does,
|
|
10
|
+
adding a second language becomes a data change instead of a refactor.
|
|
11
|
+
|
|
12
|
+
Three tools, from smallest commitment to largest:
|
|
13
|
+
|
|
14
|
+
| Tool | Gives you |
|
|
15
|
+
| ----------------------- | ---------------------------------------- |
|
|
16
|
+
| `interpolateText(t, v)` | one readable sentence with values in it |
|
|
17
|
+
| `<Interpolate>` | the same, with JSX allowed in the values |
|
|
18
|
+
| `createI18n()` | a registry of the app's texts |
|
|
19
|
+
| `naviI18n` | navi's own texts, to override |
|
|
20
|
+
|
|
21
|
+
## Writing a sentence: `interpolateText` / `<Interpolate>`
|
|
22
|
+
|
|
23
|
+
Use these the moment a sentence contains a value. They need no i18n instance and
|
|
24
|
+
no setup, so there is no reason to avoid them in a single-language app.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { interpolateText } from "@jsenv/navi";
|
|
28
|
+
|
|
29
|
+
interpolateText("Deleting [name]…", { name: file.name });
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
In JSX, `<Interpolate>` keeps the sentence readable when part of it is styled:
|
|
33
|
+
|
|
34
|
+
```jsx
|
|
35
|
+
<Interpolate radiusKm={<Text bold>{radius} km</Text>} zoneName={zone}>
|
|
36
|
+
Data limited to [radiusKm] around [zoneName].
|
|
37
|
+
</Interpolate>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The point is that the full sentence is visible in one place. The alternative —
|
|
41
|
+
`Data limited to <Text bold>{radius} km</Text> around {zone}.` — reads as
|
|
42
|
+
fragments, and cannot later become a translation key, because there is no
|
|
43
|
+
single string to be the key.
|
|
44
|
+
|
|
45
|
+
Placeholders are `[name]`. The delimiter is `[]` rather than `{}`/`{{}}` because
|
|
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.
|
|
51
|
+
|
|
52
|
+
## Centralizing texts: `createI18n`
|
|
53
|
+
|
|
54
|
+
Once several components display text, give the app a registry. One instance for
|
|
55
|
+
the whole app, exported from one module:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
// src/app_i18n.js
|
|
59
|
+
import { createI18n } from "@jsenv/navi";
|
|
60
|
+
|
|
61
|
+
export const i18n = createI18n({ keyLang: "en" });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then call it wherever a text is needed. It is callable directly:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import { i18n } from "@/app_i18n.js";
|
|
68
|
+
|
|
69
|
+
i18n("Deleting [name]…", { name: file.name });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### In an app, the text is the key
|
|
73
|
+
|
|
74
|
+
`keyLang: "en"` means "keys are already written in English". The key doubles as
|
|
75
|
+
its own English template, so a single-language app registers nothing at all and
|
|
76
|
+
still gets readable output, and adding French later touches only the registry:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
i18n.add("Deleting [name]…", { fr: "Suppression de [name]…" });
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is the recommended style for applications, because it keeps the call site
|
|
83
|
+
readable. `i18n("Deleting [name]…")` says what appears on screen;
|
|
84
|
+
`i18n("file.delete.progress")` requires a lookup to know. The cost — changing
|
|
85
|
+
the English wording changes the key, so its translations must be updated
|
|
86
|
+
alongside — is the smaller problem in practice, and it is a mechanical one.
|
|
87
|
+
|
|
88
|
+
### In navi, the key is opaque
|
|
89
|
+
|
|
90
|
+
`naviI18n` uses opaque keys (`"list.empty"`, `"constraint.required.email"`)
|
|
91
|
+
because a library's texts are addressed from the outside: an app overriding a
|
|
92
|
+
message needs a stable name that does not move when navi rewords its default
|
|
93
|
+
English. Same reasoning applies to any shared component library — the trade-off
|
|
94
|
+
flips with who owns the text.
|
|
95
|
+
|
|
96
|
+
Without `keyLang`, every language is explicit, English included:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const i18n = createI18n();
|
|
100
|
+
i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" });
|
|
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. When nothing matches it returns the key itself, so a missing
|
|
121
|
+
translation degrades to readable text rather than blank space or a crash. Pass
|
|
122
|
+
`fallbackLang` at creation to fall through to another language first, per key —
|
|
123
|
+
a half-translated language stays usable.
|
|
124
|
+
|
|
125
|
+
`i18n.has(key)` is the way to tell "no translation" apart from "translation that
|
|
126
|
+
happens to equal the key".
|
|
127
|
+
|
|
128
|
+
## Which language
|
|
129
|
+
|
|
130
|
+
Nothing needs configuring for navi and app texts to agree on a language: both
|
|
131
|
+
default to `languagesSignal`, which is read live on every lookup — the browser's
|
|
132
|
+
`navigator.languages`, plus two app-level overrides.
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
import { setSupportedLanguages, setPreferredLanguage } from "@jsenv/navi";
|
|
136
|
+
|
|
137
|
+
setSupportedLanguages(["en", "fr"]); // what the app offers, at startup
|
|
138
|
+
setPreferredLanguage("fr"); // what this user picked, from a language picker
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`setSupportedLanguages` matters more than it looks: without it, a browser
|
|
142
|
+
preferring German resolves to German, and every key missing in German falls back
|
|
143
|
+
to its key. With it, that browser lands on the app's best available language
|
|
144
|
+
instead.
|
|
145
|
+
|
|
146
|
+
Because the language is read at lookup time, a component that reads `i18n(...)`
|
|
147
|
+
during render re-renders with the new wording when the language changes — as
|
|
148
|
+
long as the call happens in render, not in a value captured once outside it.
|
|
149
|
+
|
|
150
|
+
## Changing what navi says
|
|
151
|
+
|
|
152
|
+
`naviI18n` holds navi's own texts. Register on it to override a default or to
|
|
153
|
+
add a language navi does not ship:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { naviI18n } from "@jsenv/navi";
|
|
157
|
+
|
|
158
|
+
naviI18n.add("list.empty", {
|
|
159
|
+
en: "Nothing here yet.",
|
|
160
|
+
fr: "Rien pour l'instant.",
|
|
161
|
+
});
|
|
162
|
+
naviI18n.addLangKeys("ja", { "button.close": "閉じる" });
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Its JSDoc lists the key namespaces; the exhaustive list of keys and defaults is
|
|
166
|
+
the registration block in `src/text/navi_i18n.js`, which is meant to be read.
|
|
167
|
+
|
|
168
|
+
Do not put application texts in `naviI18n`. Key collisions aside, the two have
|
|
169
|
+
different lifecycles: navi's keys come with the package version, the app's come
|
|
170
|
+
with the app.
|
|
171
|
+
|
|
172
|
+
One convention to know, since it is what `<Quantity>`/`<Unit>` look up: a unit
|
|
173
|
+
name may have `<unit>__plural` and `<unit>__short` variants, both optional
|
|
174
|
+
(missing ones fall back to the singular). An unregistered unit goes to
|
|
175
|
+
`Intl.NumberFormat`, so only units Intl gets wrong or does not know need
|
|
176
|
+
registering at all.
|
|
177
|
+
|
|
178
|
+
## Choosing, in one line
|
|
179
|
+
|
|
180
|
+
Reach for `interpolateText`/`<Interpolate>` as soon as a sentence has a value in
|
|
181
|
+
it. Reach for `createI18n` as soon as more than a handful of texts exist — the
|
|
182
|
+
migration from the first to the second is wrapping the string in a call, which
|
|
183
|
+
is why starting with plain interpolation costs nothing.
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# `interactions` — a component that answers more than a click
|
|
2
|
+
|
|
3
|
+
## What we want
|
|
4
|
+
|
|
5
|
+
An element should be able to answer a gesture — a row swiped aside to archive it,
|
|
6
|
+
a card held down to open a menu, a shortcut that sends a form — and the person
|
|
7
|
+
writing that element should only have to **name** the gesture and say what it
|
|
8
|
+
does.
|
|
9
|
+
|
|
10
|
+
Everything hard about a gesture is not the detection. It is the four things
|
|
11
|
+
around it:
|
|
12
|
+
|
|
13
|
+
- **who owns the press** when boxes are nested (a row swiped sideways inside a
|
|
14
|
+
container that travels sideways);
|
|
15
|
+
- **which of several gestures a single press turns out to be** (a swipe, a hold, a
|
|
16
|
+
click — one press, one arbiter);
|
|
17
|
+
- **the click the browser fires afterwards**, which would follow the link the
|
|
18
|
+
gesture started from;
|
|
19
|
+
- **whether the element is allowed to be interacted with at all** (disabled,
|
|
20
|
+
read-only, waiting on something).
|
|
21
|
+
|
|
22
|
+
navi owns those four. An application that reads the pointer itself gets two of
|
|
23
|
+
them wrong by construction, because two of them can only be decided from inside
|
|
24
|
+
navi and before the first pixel moves.
|
|
25
|
+
|
|
26
|
+
## The prop
|
|
27
|
+
|
|
28
|
+
`interactions` is a prop of `Box`, so it is available on anything built from
|
|
29
|
+
one — `Box`, `List.Item`, `Button`, `Link`, the field components. Its keys are
|
|
30
|
+
**event types**, its values say what that interaction does.
|
|
31
|
+
|
|
32
|
+
```jsx
|
|
33
|
+
<Box
|
|
34
|
+
interactions={{
|
|
35
|
+
"swipe_right": "request_action",
|
|
36
|
+
"swipe_left": (event) => markUnread(event),
|
|
37
|
+
"longpress": (event) => openMenu(event),
|
|
38
|
+
"keyboard:ctrl+backspace": "request_action",
|
|
39
|
+
}}
|
|
40
|
+
/>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`action` is untouched by this and keeps its own wiring — a click on a button, a
|
|
44
|
+
change on a field. `interactions` is the other half: everything that is not that
|
|
45
|
+
natural one.
|
|
46
|
+
|
|
47
|
+
### The three values
|
|
48
|
+
|
|
49
|
+
| Value | Meaning |
|
|
50
|
+
| --------------------- | ---------------------------------------------------- |
|
|
51
|
+
| `"request_action"` | ask the nearest control for its `action` prop |
|
|
52
|
+
| `"request_ui_action"` | ask it for a ui action (what says "the user acted") |
|
|
53
|
+
| a function | do this, with the interaction event as only argument |
|
|
54
|
+
|
|
55
|
+
A falsy value means "not this one", so an interaction can be declared under a
|
|
56
|
+
condition: `{ swipe_right: canArchive && archive }`.
|
|
57
|
+
|
|
58
|
+
### The interactions navi detects
|
|
59
|
+
|
|
60
|
+
| Key | Read from |
|
|
61
|
+
| ------------------------------------------------------ | ---------------------------------------------- |
|
|
62
|
+
| `mousedown` `mouseup` `click` `dblclick` `contextmenu` | the browser's own events |
|
|
63
|
+
| `swipe_left` `swipe_right` `swipe_up` `swipe_down` | a press that travels |
|
|
64
|
+
| `longpress` | a press held still |
|
|
65
|
+
| `move` `reorder` `toss` | the element carried, and what letting go means |
|
|
66
|
+
| `"keyboard:<shortcut>"` | keys, e.g. `"keyboard:ctrl+backspace"` |
|
|
67
|
+
|
|
68
|
+
A name nothing knows how to detect produces a dev warning naming the detectors
|
|
69
|
+
that exist.
|
|
70
|
+
|
|
71
|
+
## Which interaction asked
|
|
72
|
+
|
|
73
|
+
An interaction navi makes is **dispatched as an event of its own name** —
|
|
74
|
+
bubbling, cancelable, chained onto the event it was read from. So an action does
|
|
75
|
+
not need to be told which interaction asked for it: it reads the event it already
|
|
76
|
+
receives.
|
|
77
|
+
|
|
78
|
+
```jsx
|
|
79
|
+
<Button
|
|
80
|
+
action={(value, { event }) => {
|
|
81
|
+
const swipe = findEvent(event, "swipe_right");
|
|
82
|
+
if (swipe) {
|
|
83
|
+
const { axis, sign, pulled, size, progress } = swipe.detail;
|
|
84
|
+
}
|
|
85
|
+
}}
|
|
86
|
+
interactions={{ swipe_right: "request_action" }}
|
|
87
|
+
/>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`findEvent` is exported from `@jsenv/navi`. Because these are real events, an
|
|
91
|
+
ancestor can also listen for one, and `preventDefault()` on it means "not this
|
|
92
|
+
time".
|
|
93
|
+
|
|
94
|
+
The lower-level event the interaction was read from is reachable too:
|
|
95
|
+
`interactionEvent.detail.event` is the `pointerdown` a swipe or a hold was made
|
|
96
|
+
of — which is how a menu is opened at the point the press happened.
|
|
97
|
+
|
|
98
|
+
## Reaching the control
|
|
99
|
+
|
|
100
|
+
Everything goes through the interaction gate of the **nearest control** — itself,
|
|
101
|
+
an ancestor, or a descendant, in that order. So a disabled, read-only or busy
|
|
102
|
+
control answers a swipe the way it answers a click: it says why, where the
|
|
103
|
+
interaction happened, and nothing runs. A `Box` with no control anywhere near it
|
|
104
|
+
still answers a callback; only `"request_action"` has nothing to ask, and says so
|
|
105
|
+
in dev.
|
|
106
|
+
|
|
107
|
+
## What a swipe draws, and what it leaves to you
|
|
108
|
+
|
|
109
|
+
navi makes the element follow the finger — there is nothing to decide about
|
|
110
|
+
that — and says where the gesture is up to:
|
|
111
|
+
|
|
112
|
+
| Written on the element | Meaning |
|
|
113
|
+
| ---------------------------------------- | ----------------------------------------- |
|
|
114
|
+
| `--swipe-pulled` | how far it has come, signed, in px |
|
|
115
|
+
| `--swipe-progress` | the same as a fraction, signed, inherited |
|
|
116
|
+
| `[data-swiping="left\|right\|up\|down"]` | which way, while a finger holds it |
|
|
117
|
+
| `[data-swipe-past-threshold]` | letting go now would go through with it |
|
|
118
|
+
|
|
119
|
+
WHAT is revealed behind is yours: navi does not know what putting a row away
|
|
120
|
+
looks like. A trail is usually a child of the swiped element sized off
|
|
121
|
+
`--swipe-pulled` — which is what makes those values reachable from CSS at all, a
|
|
122
|
+
sibling could not read them.
|
|
123
|
+
|
|
124
|
+
```css
|
|
125
|
+
.trail {
|
|
126
|
+
position: absolute;
|
|
127
|
+
top: 0;
|
|
128
|
+
right: 100%; /* the strip the row just left */
|
|
129
|
+
bottom: 0;
|
|
130
|
+
width: var(--swipe-pulled);
|
|
131
|
+
opacity: calc(var(--swipe-progress) * 3);
|
|
132
|
+
}
|
|
133
|
+
[data-swipe-past-threshold] .trail {
|
|
134
|
+
background: var(--ok-color);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
While the answer takes time, the element **stays where the gesture left it**, and
|
|
139
|
+
comes back once it settles — a failure leaves the row in place so it can be tried
|
|
140
|
+
again. What a success does to the element is yours (a list that redemands its
|
|
141
|
+
rows, a row that leaves): navi does not make it disappear.
|
|
142
|
+
|
|
143
|
+
## Carrying something: `move`, `reorder`, `toss`
|
|
144
|
+
|
|
145
|
+
All three are the same gesture — the element is picked up and carried — and what
|
|
146
|
+
differs is the release. One detector reads them all, because it is one press.
|
|
147
|
+
|
|
148
|
+
`reorder` and `toss` **combine**: dropped on another item the element changes
|
|
149
|
+
places, thrown far and fast it is gotten rid of. `move` does **not** combine with
|
|
150
|
+
`reorder` — an element either goes where it is put or takes a place in a list,
|
|
151
|
+
and one release cannot mean both (a dev warning says so).
|
|
152
|
+
|
|
153
|
+
`move` carries the element ITSELF and leaves it where it was put; the other two
|
|
154
|
+
carry a copy and put the original back. That is the same difference said in layout
|
|
155
|
+
terms: something moved has a new place of its own, something reordered had its
|
|
156
|
+
place taken by the list.
|
|
157
|
+
|
|
158
|
+
```jsx
|
|
159
|
+
<Box
|
|
160
|
+
id={token.id}
|
|
161
|
+
interactions={{
|
|
162
|
+
move: (event) => remember(event.detail.x, event.detail.y),
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`data-drag-free` on the element or a container lets it leave; by default a `move`
|
|
168
|
+
stays inside what one can SEE of its container — which requires that container to
|
|
169
|
+
be a scroll container at all (`overflow` anything but `visible`), since there is
|
|
170
|
+
nothing else for "inside" to mean. A `move` whose answer rejects travels back, because a
|
|
171
|
+
place the application would not accept must not stay on screen as if it had.
|
|
172
|
+
|
|
173
|
+
**Declaring `toss` frees the area by itself.** What is dragged is otherwise kept
|
|
174
|
+
inside its scroll area — right for a reorder, since a row belongs to its list, and
|
|
175
|
+
fatal for a throw: the copy hits the edge of the list, no distance is ever covered,
|
|
176
|
+
so no throw can happen and no sideways movement is even visible. So the two
|
|
177
|
+
together let it leave.
|
|
178
|
+
|
|
179
|
+
```jsx
|
|
180
|
+
<List.Item
|
|
181
|
+
id={task.id}
|
|
182
|
+
data-view-transition-name={`task_${task.id}`}
|
|
183
|
+
interactions={{
|
|
184
|
+
reorder: (event) => {
|
|
185
|
+
const { fromId, toId, syncCloneWithDropTarget } = event.detail;
|
|
186
|
+
return document.startViewTransition(() => {
|
|
187
|
+
syncCloneWithDropTarget();
|
|
188
|
+
setOrder(moveBefore(order, fromId, toId));
|
|
189
|
+
}).finished;
|
|
190
|
+
},
|
|
191
|
+
toss: (event) => remove(event.detail.id),
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The gesture is `startDragTo`'s, whole: `move` carries the element itself, the
|
|
197
|
+
other two carry a copy above the page while the original keeps its place, with a
|
|
198
|
+
drop hint, drop targets found by intersection, no-op drops filtered out, and the
|
|
199
|
+
flight of a thrown copy plus its return when the answer refuses. Only what the
|
|
200
|
+
declared outcomes need runs — no copy for a move, no hint for something
|
|
201
|
+
that can only be thrown away.
|
|
202
|
+
|
|
203
|
+
Every element declaring `reorder` marks itself, so the set of items IS the set of
|
|
204
|
+
elements that declared it — no selector to pass, and an item that must not move
|
|
205
|
+
simply does not declare it. An element declaring only `toss` marks nothing: it is
|
|
206
|
+
not a place anything lands. Items are named by their `id`.
|
|
207
|
+
|
|
208
|
+
`toId` is null for a drop at the end. `syncCloneWithDropTarget` must be called
|
|
209
|
+
synchronously inside the transition callback, next to the state change, so the copy
|
|
210
|
+
is captured where it lands rather than where it was let go of.
|
|
211
|
+
|
|
212
|
+
**The promise matters in both cases**: the gesture holds its copy until the answer
|
|
213
|
+
settles. Returning the transition is what makes a landing continuous; a `toss` that
|
|
214
|
+
rejects brings the copy back, because the thing still exists and the screen has to
|
|
215
|
+
say so.
|
|
216
|
+
|
|
217
|
+
A throw is asked about before a landing: a hand that sent something across the
|
|
218
|
+
screen has not asked for it to swap places with whatever it flew over.
|
|
219
|
+
|
|
220
|
+
Starting a document transition is the application's call, not navi's: a
|
|
221
|
+
`view-transition-name` must be unique per document, so only the application can
|
|
222
|
+
name what moves.
|
|
223
|
+
|
|
224
|
+
| Attribute | Meaning |
|
|
225
|
+
| -------------------------------------------------------- | -------------------------------------- |
|
|
226
|
+
| `data-drag-axis="x"\|"y"\|"xy"` | which axes the drag walks |
|
|
227
|
+
| `data-drag-delay` `data-drag-slop` `data-drag-threshold` | when the press becomes a grab |
|
|
228
|
+
| `data-toss-distance` `data-toss-speed` | how far and how fast counts as a throw |
|
|
229
|
+
|
|
230
|
+
### Dressing the clone
|
|
231
|
+
|
|
232
|
+
What the pointer carries is a copy, and a copy of a transparent element is
|
|
233
|
+
invisible — an element has no background unless something gave it one, and a row
|
|
234
|
+
usually gets its own from the list around it, which the copy has left. So the
|
|
235
|
+
clone's look is the page's to declare, through the attributes the gesture puts on
|
|
236
|
+
it:
|
|
237
|
+
|
|
238
|
+
| Attribute | On |
|
|
239
|
+
| ------------------------- | ------------------------------------------------------ |
|
|
240
|
+
| `navi-drag-clone` | the copy being carried |
|
|
241
|
+
| `navi-drag-clone-wrapper` | what positions it (already shadowed, in the top layer) |
|
|
242
|
+
| `navi-drag-clone-source` | the original, still in place (already hidden) |
|
|
243
|
+
|
|
244
|
+
```css
|
|
245
|
+
.task[navi-drag-clone] {
|
|
246
|
+
background: white;
|
|
247
|
+
border-radius: 6px;
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Reusing the item's own class is the point: the copy is that item, so it is styled
|
|
252
|
+
as that item plus whatever being carried changes.
|
|
253
|
+
|
|
254
|
+
`data-drag-axis` says which axes the drag walks, and its default is not the same
|
|
255
|
+
for every outcome: `reorder` alone walks the list (`y`, or `x` for a list that runs
|
|
256
|
+
sideways), while a `move` goes wherever it is put and a `toss` wherever it was
|
|
257
|
+
thrown (`xy`). `data-drag-delay`, `data-drag-slop`, `data-drag-threshold` tune when
|
|
258
|
+
the press becomes a grab.
|
|
259
|
+
|
|
260
|
+
## Tuning
|
|
261
|
+
|
|
262
|
+
Read off the element or any ancestor carrying the attribute, so a whole list is
|
|
263
|
+
tuned in one place and a stylesheet can read the same value.
|
|
264
|
+
|
|
265
|
+
| Attribute | Default | Meaning |
|
|
266
|
+
| ---------------------- | ------- | ----------------------------------------- |
|
|
267
|
+
| `data-swipe-threshold` | `0.33` | fraction of the element to pull to commit |
|
|
268
|
+
| `data-longpress-delay` | `450` | ms the press must be held |
|
|
269
|
+
| `data-longpress-slop` | `8` | px the pointer may drift during the wait |
|
|
270
|
+
|
|
271
|
+
A threshold is a **fraction and never a distance**: the same gesture must mean
|
|
272
|
+
the same thing on a phone and on a wide screen. Speed answers on its own on top
|
|
273
|
+
of it — a brief flick counts whatever the distance covered.
|
|
274
|
+
|
|
275
|
+
## Registering an interaction navi does not have
|
|
276
|
+
|
|
277
|
+
The registry holds no detector of its own: navi's swipes, holds and shortcuts go
|
|
278
|
+
through the same door an application uses.
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
import { defineInteractionDetector } from "@jsenv/navi";
|
|
282
|
+
|
|
283
|
+
defineInteractionDetector({
|
|
284
|
+
name: "triple_click",
|
|
285
|
+
claims: (type) => type === "triple_click",
|
|
286
|
+
setup: (element, trigger) => {
|
|
287
|
+
let count = 0;
|
|
288
|
+
let timeout = null;
|
|
289
|
+
const onClick = (clickEvent) => {
|
|
290
|
+
count++;
|
|
291
|
+
clearTimeout(timeout);
|
|
292
|
+
timeout = setTimeout(() => {
|
|
293
|
+
count = 0;
|
|
294
|
+
}, 1000);
|
|
295
|
+
if (count < 3) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
count = 0;
|
|
299
|
+
trigger(clickEvent);
|
|
300
|
+
};
|
|
301
|
+
element.addEventListener("click", onClick);
|
|
302
|
+
return () => {
|
|
303
|
+
clearTimeout(timeout);
|
|
304
|
+
element.removeEventListener("click", onClick);
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`setup(element, trigger, { types, readConfig })` runs **once per element** and
|
|
311
|
+
returns how to undo whatever it did. Listeners, attributes, anything: it is a
|
|
312
|
+
plain setup and teardown, so a detector counts what it needs in its own closure
|
|
313
|
+
and nothing has to hold state on its behalf.
|
|
314
|
+
|
|
315
|
+
`claims` takes a **set** of names rather than one, because interactions sharing an
|
|
316
|
+
input have to be arbitrated together — a swipe, a hold and a click dispute the
|
|
317
|
+
same press, and read apart they walk over each other. `types` (third argument) is
|
|
318
|
+
which of them were actually declared here.
|
|
319
|
+
|
|
320
|
+
`trigger(type, originalEvent, detail)` says the interaction happened. Called with
|
|
321
|
+
a single event — `trigger(event)` — the type is the detector's own, which only
|
|
322
|
+
works when exactly one of its names is declared. When `originalEvent.type` is
|
|
323
|
+
already the interaction's name (a native one), that event IS the interaction and
|
|
324
|
+
no second one is dispatched.
|
|
325
|
+
|
|
326
|
+
It returns **`null` when nothing ran** (the gate refused, no control to ask, the
|
|
327
|
+
interaction event was prevented) and otherwise a **promise**: resolved once the
|
|
328
|
+
effect worked, rejected when it did not. Those two answers are not the same and a
|
|
329
|
+
detector usually treats them differently — a row pulled out comes back either
|
|
330
|
+
way, something thrown off the screen only comes back if the throw failed.
|
|
331
|
+
|
|
332
|
+
`readConfig(attribute, defaultValue)` reads a number off the element or any
|
|
333
|
+
ancestor carrying that attribute, so a whole list is tuned in one place.
|
|
334
|
+
|
|
335
|
+
A detector that reads the pointer must mark itself in the DOM so a travelling
|
|
336
|
+
container above it does not take the gesture:
|
|
337
|
+
`element.setAttribute("data-no-drag-travel", "")`, undone in the teardown (see
|
|
338
|
+
`docs/drag_to_travel.md`). navi's own swipes do the equivalent with
|
|
339
|
+
`data-travel-by-drag`.
|
|
340
|
+
|
|
341
|
+
## Things worth knowing before guessing
|
|
342
|
+
|
|
343
|
+
- **A hold does not take the context menu.** Declaring `longpress` says what a
|
|
344
|
+
held finger does; a right click comes from the other button and keeps opening
|
|
345
|
+
the browser's menu. Declare `contextmenu` beside it to make the right click do
|
|
346
|
+
the same thing. (A held _finger_ is the system's own context-menu gesture, and
|
|
347
|
+
that one is refused while the wait runs.)
|
|
348
|
+
- **A swipe cannot also be dragged out of the page.** An element declaring a
|
|
349
|
+
swipe gets `draggable={false}` and its `dragstart` refused — a native drag _is_
|
|
350
|
+
press-and-move, and a link or an image is draggable without anyone asking. One
|
|
351
|
+
gesture cannot mean both.
|
|
352
|
+
- **`interactions` adds, it does not replace.** A control's own wiring stays:
|
|
353
|
+
`actionEvent` / `actionOnMouseDown` are still how you change what triggers
|
|
354
|
+
`action` by default.
|
|
355
|
+
- **A popup can open while the finger is still down.** navi's `Popover` is
|
|
356
|
+
`popover="manual"` and owns its dismissal, so the `pointerup` ending a hold is
|
|
357
|
+
not read as an interaction outside it — a menu can appear under a waiting
|
|
358
|
+
finger, which is the native gesture. To place it at the press point rather than
|
|
359
|
+
on the element:
|
|
360
|
+
`triggerNaviCommand(target, "--navi-open", interactionEvent, { anchor })`.
|
|
361
|
+
- **A swipe has no keyboard equivalent.** There is nothing to press that means
|
|
362
|
+
"swipe right", so a swipe is only reachable if something else on the element
|
|
363
|
+
offers the same thing — a `"keyboard:<shortcut>"`, a `contextmenu`, or the
|
|
364
|
+
control's own action.
|
|
365
|
+
|
|
366
|
+
## Reference
|
|
367
|
+
|
|
368
|
+
- `src/control/interaction/interaction_registry.js` — the prop, the three values,
|
|
369
|
+
the registry.
|
|
370
|
+
- `src/control/interaction/interaction_press.js` — swipes and holds, and what a
|
|
371
|
+
swipe writes on the element.
|
|
372
|
+
- `src/control/interaction/interaction_keyboard.js`,
|
|
373
|
+
`interaction_native.js` — the other two detectors.
|
|
374
|
+
- `src/control/demos/38_interactions_demo.html` — every case above, plus a
|
|
375
|
+
mailbox and a custom `swipe_out` gesture registered from the page.
|
package/docs/resource.md
CHANGED
|
@@ -25,7 +25,7 @@ Each callback returns the data to upsert into the store:
|
|
|
25
25
|
| GET / POST / PUT / PATCH | the full item object, `{ id, … }` |
|
|
26
26
|
| DELETE | the id, or `{ id }` |
|
|
27
27
|
| GET_MANY / POST_MANY / … | an array of item objects |
|
|
28
|
-
|
|
|
28
|
+
| GET_RANGE | `{ items, start, count }` (below) |
|
|
29
29
|
|
|
30
30
|
Actions are read in components through the action system (`useAsyncData`,
|
|
31
31
|
`<Button action>`, …) — see [actions.md](./actions.md).
|
|
@@ -48,18 +48,18 @@ it yourself is the point:
|
|
|
48
48
|
else to land (see [When the backend answers a sub-route with the whole
|
|
49
49
|
parent](#when-the-backend-answers-a-sub-route-with-the-whole-parent)).
|
|
50
50
|
|
|
51
|
-
A list that loads its rows
|
|
52
|
-
`
|
|
51
|
+
A list that loads its rows a slice at a time is **not** one of those cases — that is
|
|
52
|
+
`GET_RANGE`, right below.
|
|
53
53
|
|
|
54
|
-
## `
|
|
54
|
+
## `GET_RANGE`: feeding a list that loads as it scrolls
|
|
55
55
|
|
|
56
56
|
A `<List.Items>` asks for the rows it is about to draw and keeps what it gets.
|
|
57
|
-
`
|
|
57
|
+
`GET_RANGE` is the resource's answer to that question — one slice at a time:
|
|
58
58
|
|
|
59
59
|
```js
|
|
60
60
|
const GAME = resource("game", {
|
|
61
61
|
GET: ({ id }) => fetchJson(`/games/${id}`),
|
|
62
|
-
|
|
62
|
+
GET_RANGE: ({ radar, start, limit }) =>
|
|
63
63
|
fetchJson(`/radars/${radar.id}/games?start=${start}&limit=${limit}`),
|
|
64
64
|
// { items: [{ id, … }, …], start: 20, count: 137 }
|
|
65
65
|
});
|
|
@@ -68,7 +68,7 @@ const GAME = resource("game", {
|
|
|
68
68
|
```jsx
|
|
69
69
|
<List.Items
|
|
70
70
|
count={radar.match_count}
|
|
71
|
-
itemsAction={GAME.
|
|
71
|
+
itemsAction={GAME.GET_RANGE.bindParams({ radar })}
|
|
72
72
|
renderItem={(game) => <GameCard game={game} />}
|
|
73
73
|
/>
|
|
74
74
|
```
|
|
@@ -76,7 +76,7 @@ const GAME = resource("game", {
|
|
|
76
76
|
The callback receives the bound params merged with the range the list asks for
|
|
77
77
|
(`start`, `end`, `limit`, `before`, `after`, `around`, `count`), and a `signal`
|
|
78
78
|
as second argument — aborted when the list stops wanting those rows. It returns
|
|
79
|
-
a
|
|
79
|
+
a range the way a `Content-Range` does: **`{ items, start, count }`** — these
|
|
80
80
|
rows, at this place, out of that many. `start` may be omitted when the list
|
|
81
81
|
asked for a positive one; `count` defaults to `start + items.length` (a source
|
|
82
82
|
that does not know its total). The items are upserted on their way in, so the
|
|
@@ -98,12 +98,12 @@ makes a change detectable), so the one the list is holding is the one it was
|
|
|
98
98
|
given. Relations are not concerned: they are keyed by owner, and a row reading
|
|
99
99
|
`game.candidates` reads the shared collection whatever object carries it.
|
|
100
100
|
|
|
101
|
-
`
|
|
101
|
+
`GET_RANGE` is a **reader, not an action**. It keeps no value and takes no place
|
|
102
102
|
in the rerun graph, which is what makes it usable per slice:
|
|
103
103
|
|
|
104
|
-
- the list already holds the
|
|
104
|
+
- the list already holds the slices it received and glues them back together —
|
|
105
105
|
a second memory holding one of them would fight it;
|
|
106
|
-
- a `POST` invalidating "the collection" would otherwise send every
|
|
106
|
+
- a `POST` invalidating "the collection" would otherwise send every slice ever
|
|
107
107
|
loaded back to the network at once.
|
|
108
108
|
|
|
109
109
|
What it does not give is membership: an item that leaves the collection stays on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.29",
|
|
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.10",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.2"
|
|
35
35
|
},
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"@jsenv/snapshot": "../../tooling/snapshot",
|
|
42
42
|
"@jsenv/terminal-table": "../../tooling/terminal-table",
|
|
43
43
|
"@jsenv/urls": "../../tooling/urls",
|
|
44
|
-
"@preact/signals": "2.
|
|
45
|
-
"playwright": "1.
|
|
44
|
+
"@preact/signals": "2.11.1",
|
|
45
|
+
"playwright": "1.62.1",
|
|
46
46
|
"preact": "11.0.0-beta.2"
|
|
47
47
|
},
|
|
48
48
|
"publishConfig": {
|