@jsenv/navi 0.29.109 → 0.29.111

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.
@@ -88,6 +88,17 @@ consistency across the app, not from any single call site.
88
88
  before displaying an error by hand, before writing an error boundary of your
89
89
  own, and before concluding that a dev overlay over a page that already shows
90
90
  its error is a crash.
91
+ - `docs/field_validation.md` — what a control refuses and who decides it: the
92
+ split between what only a browser can answer (a blocked keystroke, where the
93
+ callout lands, when the message appears) and « is this value acceptable »,
94
+ which is @jsenv/validity's and which a server asks about the same value. The
95
+ constraint attributes navi ships, the `charGuard`/`maxLengthGuard` props that
96
+ act before there is a value, how a message key is overridden, and the three
97
+ places an app's own rule can live — both sides via
98
+ `constraintFromValidityRule`, the server alone, the browser alone. Read it
99
+ before writing a constraint of your own: if the sentence would make sense in a
100
+ server's response, the rule belongs in validity and the constraint is only its
101
+ browser-side caller.
91
102
  - `docs/css_architecture.md` — how Navi's CSS layering works, and the
92
103
  supported ways to override component styles (props > CSS variables > direct
93
104
  rule overrides, in that preference order).
@@ -203,8 +214,11 @@ consistency across the app, not from any single call site.
203
214
  not it), what makes truncation actually happen in a flex row, and the two
204
215
  opposite shapes of "icon | text | icon" — the end icon kept outside the
205
216
  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`.
207
- Read it before writing an overflowing label or a row with a trailing icon.
217
+ and why an emoji belongs only in free text, rendered with `emojiAsIcon` —
218
+ except in a control one types in, where there is no markup to wrap a glyph in
219
+ and the fixed line height is what keeps the rows even.
220
+ Read it before writing an overflowing label or a row with a trailing icon,
221
+ and before touching a text control's `line-height`.
208
222
  - `docs/i18n.md` — where the texts an app displays live: `interpolateText` /
209
223
  `<Interpolate>` for one sentence, `createI18n` for the app's registry,
210
224
  `naviI18n` for navi's own texts. Read it before writing a user-visible
package/docs/actions.md CHANGED
@@ -176,6 +176,13 @@ one that asks something of a control near it is a `command` (a value proposed is
176
176
  [control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
177
177
  Reaching for a plain `onClick` usually means one of those was missed.
178
178
 
179
+ On a link — a `Link`, a `<Button href>` or `<Button route>` — the three fire on
180
+ the press, before the navigation, and the navigation waits for none of them: a
181
+ `command="--navi-close"` closes the sheet the link leaves, an `action` that
182
+ writes a draft synchronously is found by the next page, and a request goes on
183
+ its own while the page changes. Work that decides the destination is not a
184
+ link's: it navigates itself, from a `<Button action>`.
185
+
179
186
  The press that looks like a fourth case is the one that opens something and then
180
187
  does something with what came of it — "save this guest", pressed on a row,
181
188
  replacing the guest once the profile exists. It is not a fourth case and it is
@@ -121,6 +121,15 @@ Reference: `.navi_button_content` in `button_ui.jsx` (a button reads then
121
121
  stops), `.navi_picker_box`, `.navi_input_slot`, `.navi_popover`,
122
122
  `.navi_dialog`.
123
123
 
124
+ **A member drawing the frame itself stops it too**, without being a control. A
125
+ row of settings — a `Field`, a `Label`, a `Box` — that paints a background or a
126
+ border, or that insets what it holds with padding, is what the outer corner
127
+ belongs to; the switch at the right end of that row is not on it. `Box` marks
128
+ itself with `navi-box-frame` for exactly those props and stops the claims for
129
+ everything inside. A wrapper adding neither is only an enrobage — a tooltip, a
130
+ link, a bare `<Box>` — and lets them through to the control that really carries
131
+ the frame.
132
+
124
133
  A control that declares its radius on an inner element instead is invisible to
125
134
  `Group`: it keeps round corners in the middle of the row, and no rule written
126
135
  in `group.jsx` can reach it without naming that private class — which is how a
@@ -94,6 +94,23 @@ Not an error at all, and not to be modelled as one: a value the app or the
94
94
  browser refuses is a **status on the control**, carried by navi's constraint
95
95
  validation. Never throw to reject a value — see `src/control/rules/`.
96
96
 
97
+ An app's own rule joins that set as a constraint — `{ name, check }`, where
98
+ `check(field)` reads `field.uiState` and returns a message or `null`:
99
+
100
+ ```jsx
101
+ <Input constraints={[TEXT_SHAPE_CONSTRAINT]} />; // this control
102
+ registerGlobalConstraint(TEXT_SHAPE_CONSTRAINT); // every control
103
+ ```
104
+
105
+ A constraint is an object, so it carries whatever it needs to decide — there is
106
+ nothing to pass through an attribute. The attributes the shipped constraints
107
+ read (`required`, `data-single-space`, `data-displayable`…) exist because those
108
+ constraints are global and need a per-field switch; a constraint written for
109
+ one call site does not.
110
+
111
+ Where such a rule belongs — a navi constraint, a @jsenv/validity rule the
112
+ server runs too, or both — is `docs/field_validation.md`.
113
+
97
114
  ## What a failing action does
98
115
 
99
116
  It writes the error into `errorSignal`, moves to `FAILED`, and stops.
@@ -0,0 +1,193 @@
1
+ # Field validation
2
+
3
+ What a control refuses, who decides it, and what is left for a server to say.
4
+
5
+ ## The split
6
+
7
+ A control refuses a value for two very different kinds of reason, and keeping
8
+ them apart is the whole subject.
9
+
10
+ **What only a browser can answer.** A keystroke blocked before the value exists,
11
+ a callout placed next to the field, `required` on a radio group, `data-one-of`
12
+ reading an option list out of the document, the moment a message appears
13
+ (typing? blur? submit?).
14
+ This is navi's, and it stays navi's.
15
+
16
+ **« Is this value acceptable ».** A length, a set of allowed characters, a
17
+ value that renders nothing, a business rule. This is not a DOM question — a
18
+ server asks the same one about the same value — so it lives in
19
+ [@jsenv/validity](../../../tooling/validity/README.md) and navi consumes it.
20
+
21
+ The consequence that matters when writing an app: **do not write a constraint
22
+ for something a rule already answers**. If the sentence you are about to write
23
+ in a `check()` would make sense in a server's response, the knowledge belongs in
24
+ a validity rule, and the constraint is only its browser-side caller.
25
+
26
+ ## Constraints
27
+
28
+ A constraint is `{ name, check(field) }`. `check` returns `null` when the value
29
+ passes, or the message to show — a string, or `{ message, target }` when the
30
+ callout belongs on another element than the control itself.
31
+
32
+ Navi's own constraints are switched on by an attribute on the control —
33
+ standard when the platform has one, `data-*` when it does not.
34
+
35
+ **They are written as props, in camelCase.** A constraint declares the attribute
36
+ it reads (`data-no-emoji`) and a control accepts the prop that stands for it
37
+ (`noEmoji`), putting it on the control host under the attribute name — the same
38
+ conversion `element.dataset` does. So `singleSpace` is what you write and
39
+ `data-single-space` is what ends up in the DOM (and in the devtools, and in a
40
+ test selector). The attribute form is accepted too, for a control written in
41
+ plain HTML.
42
+
43
+ | prop | attribute | refuses |
44
+ | ------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------- |
45
+ | `required`, `pattern` | same | what the platform's attributes mean |
46
+ | `minLength`, `maxLength` | same | a string too short or too long |
47
+ | `min`, `max`, `step` | same | a number, date, time or duration out of range |
48
+ | `singleSpace` | `data-single-space` | a leading or trailing space, two in a row |
49
+ | `displayable` | `data-displayable` | zalgo, a value showing nothing, blank lines in series, a joiner joining nothing |
50
+ | `maxStackedMarks` | `data-max-stacked-marks` | (parameter of `displayable`) |
51
+ | `noEmoji` | `data-no-emoji` | an emoji, where a name, an identifier or a title does not want one |
52
+ | `maxLineBreaks` | `data-max-line-breaks` | a value holding more line breaks than that |
53
+ | `oneOf` | `data-one-of` | a value outside the option list its CSS selector points at |
54
+ | `sameAs` | `data-same-as` | a value differing from the field it names |
55
+ | `minDigit`, `minUpperLetter`, … | `data-min-digit`, … | a password missing a kind of character |
56
+ | `timeAfter`, `timeMinDuration` | `data-time-after`, `data-time-min-duration` | a time span that ends before it starts, or is too short |
57
+
58
+ ```jsx
59
+ <Input required singleSpace noEmoji maxLength={80} />
60
+ <Textarea displayable singleSpace maxLineBreaks={4} />
61
+ ```
62
+
63
+ A boolean switch (`displayable`, `singleSpace`, `noEmoji`) passed as `false` is
64
+ off, so `noEmoji={settings.strictNames}` says what it looks like it says. The
65
+ others carry a value — a count, a selector — and are off by being absent.
66
+
67
+ Each constraint has a `<name>Message` prop (and a `data-<name>-message`
68
+ attribute) to replace its sentence for one field. To change it everywhere,
69
+ override the i18n key instead — see below.
70
+
71
+ Two props sit beside them and belong to the browser alone, because they act
72
+ before there is a value to validate: `charGuard` blocks a keystroke that is not
73
+ in its character class, `maxLengthGuard` blocks the one that would overflow (and
74
+ truncates a paste). Both show what they refused in a callout rather than
75
+ silently swallowing it.
76
+
77
+ `charGuard` takes a character class or one of validity's preset names, so a
78
+ field can refuse the keystroke with the same knowledge the value is checked
79
+ against: `charGuard="tel"`, `charGuard="slug"`, `charGuard="noEmoji"`. Refusing
80
+ the keystroke and refusing the value are different jobs — the pair
81
+ `maxLengthGuard`/`maxLength` is the same split — and a field usually wants both.
82
+
83
+ **A guard answers for the gesture, never for what the field already holds.** A
84
+ value can arrive already outside the class or already too long — a
85
+ `defaultValue`, a signal, a value written from elsewhere — and a guard that
86
+ re-judged the whole value would refuse every keystroke over it, blaming the
87
+ person for a character they did not type, and refuse the deletion that would
88
+ have fixed it. So a change is refused only when it makes the value worse. What
89
+ is already there is the constraint's business, and it says so at submit.
90
+
91
+ ## Reading the validity without submitting
92
+
93
+ `useConstraintValidityState(ref)` gives the control's validity as it stands,
94
+ re-read whenever it changes:
95
+
96
+ ```js
97
+ const state = useConstraintValidityState(inputRef);
98
+ state.valid; // false
99
+ state.single_space.messageString; // the sentence
100
+ state.reported; // "max_length" — the one the callout says
101
+ ```
102
+
103
+ Several constraints fail at once and only one sentence is shown: the one with
104
+ the highest priority — an `error` status first, then `required`, then the
105
+ platform's own constraints, then navi's and the app's, ties going to the first
106
+ registered. `reported` names it, so a summary drawn beside the field says the
107
+ same thing as the callout rather than picking a second one.
108
+
109
+ `src/control/demos/validation/text_rules_demo.html` is that, one rule per row: a
110
+ value that breaks it, its message read live, and a submit to see the callout.
111
+
112
+ ## Messages
113
+
114
+ Every sentence navi says is a key in `naviI18n`, and the validation ones are
115
+ `constraint.*`. An app changes one for its whole app by registering over it:
116
+
117
+ ```js
118
+ import { naviI18n } from "@jsenv/navi";
119
+
120
+ naviI18n.add("constraint.single_space.consecutive", {
121
+ fr: "Pas deux espaces d'affilée.",
122
+ });
123
+ ```
124
+
125
+ The keys of everything validity owns are validity's own key prefixed with
126
+ `constraint.` — `single_space.start` is `constraint.single_space.start`,
127
+ `char_class.slug` is `constraint.char_class.slug`. That is deliberate: the
128
+ sentence a server returns and the sentence the field shows are looked up under
129
+ one name, so making them agree is registering one key, not maintaining a
130
+ translation table. See `src/control/rules/validity_bridge.js`, and `i18n.md` for
131
+ how the registry itself works.
132
+
133
+ ## An app's own rules
134
+
135
+ An app puts its rules in the package its server reads too, and then decides, per
136
+ rule, who runs it.
137
+
138
+ **Both sides.** The rule is a validity rule; navi wears it as a constraint.
139
+
140
+ ```js
141
+ // shared/src/text_rules.js — read by the server and by the front
142
+ export const MAX_WORDS_RULE = {
143
+ name: "maxWords",
144
+ applyOn: (maxWords, value) => {
145
+ if (maxWords === undefined || typeof value !== "string") {
146
+ return null;
147
+ }
148
+ const count = value.trim().split(/\s+/).length;
149
+ if (count <= maxWords) {
150
+ return null;
151
+ }
152
+ return { key: "max_words", params: { max: maxWords, count } };
153
+ },
154
+ };
155
+ ```
156
+
157
+ ```js
158
+ // front — once, at module level: a constraint rebuilt on every render is a new
159
+ // object on every check
160
+ import { constraintFromValidityRule } from "@jsenv/navi";
161
+
162
+ const MAX_WORDS_CONSTRAINT = constraintFromValidityRule(MAX_WORDS_RULE, {
163
+ maxWords: 40,
164
+ });
165
+
166
+ <Textarea constraints={[MAX_WORDS_CONSTRAINT]} />;
167
+ ```
168
+
169
+ The rule's key is looked up in `naviI18n` under `constraint.max_words`, so
170
+ register it there — or pass `formatMessage` beside the parameters to say it
171
+ through the app's own i18n instead.
172
+
173
+ **The server alone.** A rule needing the database, another user's data or a
174
+ secret stays in `createValidity({ rules })` and never reaches the front; its
175
+ refusal arrives with the response like any other error.
176
+
177
+ **The browser alone.** A rule about the gesture rather than the value — what a
178
+ keystroke may insert, a warning shown while typing — is a plain navi constraint,
179
+ written inline and never sent anywhere:
180
+
181
+ ```js
182
+ <Input
183
+ constraints={[(field) => (field.uiState === "admin" ? "Reserved" : null)]}
184
+ />
185
+ ```
186
+
187
+ `registerGlobalConstraint(constraint)` is the same thing for every control at
188
+ once, for a rule that genuinely holds everywhere in the app.
189
+
190
+ The line to hold across all three: **the front may be laxer than the back, never
191
+ stricter**. A value the field accepted and the server refuses is a promise
192
+ broken after the fact — which is exactly what happens when the same rule is
193
+ written twice and the copies drift.
@@ -60,8 +60,15 @@ The available commands: `--navi-open`, `--navi-close`, `--navi-toggle`,
60
60
  `--navi-confirm` (says yes, then closes). What "revert" does to what is inside
61
61
  is [its own section](#escape-cancels-the-other-gestures-keep) — it is also what
62
62
  Escape says. `--navi-close:all` closes every popup above the button, nearest
63
- first a link leaving from a badge shown over a sheet leaves both; a popup
64
- that refuses to close keeps the ones above it open too.
63
+ first; a popup that refuses to close keeps the ones above it open too. A link
64
+ that leaves is the usual case a badge shown over a sheet, both left in one
65
+ press:
66
+
67
+ ```jsx
68
+ <Link href={PLAYER_ROUTE.buildUrl({ playerId })} command="--navi-close:all">
69
+ Profil
70
+ </Link>
71
+ ```
65
72
 
66
73
  ## Something else opens it: `triggerNaviCommand`
67
74
 
@@ -165,9 +165,18 @@ neighbours, a paragraph's lines are unevenly spaced, a truncated label no
165
165
  longer lines up with its siblings.
166
166
 
167
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.
168
+ a comment, a description. It has no business in a title, an identifier, a
169
+ label, and a field holding one of those can refuse it outright rather than
170
+ teaching every row of the app to survive it.
171
+
172
+ A name is the exception worth stating: people are called what they are called,
173
+ and a whitelist on a name field ends up refusing somebody's real name. An app
174
+ taking that side puts `emojiAsIcon` on everything that renders a name, and
175
+ keeps the name field itself to the rules that are about the layout rather than
176
+ the alphabet — `data-displayable`, which refuses only what cannot be drawn:
177
+ marks stacked into zalgo (the one thing `emojiAsIcon` does not rescue, since it
178
+ draws over the row above), a value that shows nothing at all, blank lines in
179
+ series.
171
180
 
172
181
  Where it is expected, `emojiAsIcon`:
173
182
 
@@ -183,6 +192,18 @@ emoji is a 1em box, never a glyph with its own metrics). `Button` and
183
192
  everything around it relies on, a message is free text; `Badge` forwards it,
184
193
  opt-in.
185
194
 
195
+ **It does not go through a component.** Only the strings the `Text` itself
196
+ receives are rewritten, so `<Text emojiAsIcon><UserName /></Text>` does
197
+ nothing at all: the string is inside `UserName`, and that is where
198
+ `renderEmojiAsIcon()` has to be called. Doing it there also spares a `Text`
199
+ that would inject a separator between the name and whatever follows it.
200
+
201
+ There is no way to turn it on for a whole app, on purpose: most of an app's
202
+ text is its own wording, where an emoji cannot appear, and the ones that do
203
+ carry a typed value are known one by one. An app that renders such a value
204
+ everywhere writes its own component around `Text` — the same place it already
205
+ decides how a name is displayed.
206
+
186
207
  What it does not do, and that is accepted: under `maxLines` the `Text` clips at
187
208
  its own box — that is what truncation is — and an emoji drawn a little beyond
188
209
  its 1em box can lose a sliver at the top. The line stays aligned, which is the
@@ -194,6 +215,20 @@ an emoji (see the dense cases in `text_emoji_demo.html`). A `lineHeight` is a
194
215
  typographic choice for the text itself — a paragraph that wants air — never a
195
216
  workaround for a glyph that grew too tall; that glyph gets `emojiAsIcon`.
196
217
 
218
+ **A control one types in is the exception, and there the line height is the
219
+ whole answer.** `emojiAsIcon` rewrites strings into markup, and there is no
220
+ markup inside a `<textarea>` or an `<input>`: the value is raw text the browser
221
+ draws itself, so no glyph in it can be wrapped in anything. Under
222
+ `line-height: normal` a line box takes the height of the tallest font it holds,
223
+ so the one line carrying an emoji stands taller than the ones around it — rows
224
+ of uneven height, and a box sized in `lh` (`minRows`/`maxRows`) that jumps as
225
+ soon as one is typed. `Textarea` therefore fixes its line height, and picks a
226
+ value tall enough to contain an emoji's own box so the glyph is not clipped
227
+ either: a tighter one would keep the rows even and cut the top off the emoji.
228
+ Do not remove it, and do not tighten it — `34_textarea_demo.html` shows the two
229
+ side by side. The rule above still holds everywhere the text is rendered rather
230
+ than typed.
231
+
197
232
  ## Text that must not move when its style changes
198
233
 
199
234
  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.109",
3
+ "version": "0.29.111",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -29,9 +29,9 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@jsenv/dom": "0.17.29",
32
+ "@jsenv/dom": "0.17.30",
33
33
  "@jsenv/humanize": "1.7.8",
34
- "@jsenv/validity": "0.4.2"
34
+ "@jsenv/validity": "0.4.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@jsenv/assert": "4.5.7",