@jsenv/navi 0.29.110 → 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
@@ -108,6 +108,9 @@ read (`required`, `data-single-space`, `data-displayable`…) exist because thos
108
108
  constraints are global and need a per-field switch; a constraint written for
109
109
  one call site does not.
110
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
+
111
114
  ## What a failing action does
112
115
 
113
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.
@@ -215,6 +215,20 @@ an emoji (see the dense cases in `text_emoji_demo.html`). A `lineHeight` is a
215
215
  typographic choice for the text itself — a paragraph that wants air — never a
216
216
  workaround for a glyph that grew too tall; that glyph gets `emojiAsIcon`.
217
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
+
218
232
  ## Text that must not move when its style changes
219
233
 
220
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.110",
3
+ "version": "0.29.111",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "dependencies": {
32
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",