@react-x11/components 0.11.0 → 0.13.0

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.
Files changed (126) hide show
  1. package/dist/flow/node.d.ts +14 -6
  2. package/dist/flow/node.d.ts.map +1 -1
  3. package/dist/flow/node.js +82 -64
  4. package/dist/flow/node.js.map +1 -1
  5. package/dist/html/controls.d.ts +38 -10
  6. package/dist/html/controls.d.ts.map +1 -1
  7. package/dist/html/controls.js +43 -58
  8. package/dist/html/controls.js.map +1 -1
  9. package/dist/html/css/cascade.d.ts +134 -20
  10. package/dist/html/css/cascade.d.ts.map +1 -1
  11. package/dist/html/css/cascade.js +538 -128
  12. package/dist/html/css/cascade.js.map +1 -1
  13. package/dist/html/css/color.d.ts +19 -0
  14. package/dist/html/css/color.d.ts.map +1 -1
  15. package/dist/html/css/color.js +86 -0
  16. package/dist/html/css/color.js.map +1 -1
  17. package/dist/html/css/parse.d.ts +40 -2
  18. package/dist/html/css/parse.d.ts.map +1 -1
  19. package/dist/html/css/parse.js +391 -13
  20. package/dist/html/css/parse.js.map +1 -1
  21. package/dist/html/css/style.d.ts +52 -1
  22. package/dist/html/css/style.d.ts.map +1 -1
  23. package/dist/html/css/style.js +328 -11
  24. package/dist/html/css/style.js.map +1 -1
  25. package/dist/html/css/ua.d.ts +11 -1
  26. package/dist/html/css/ua.d.ts.map +1 -1
  27. package/dist/html/css/ua.js +73 -7
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +10 -1
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js +27 -2
  32. package/dist/html/css/values.js.map +1 -1
  33. package/dist/html/css/vars.d.ts +5 -0
  34. package/dist/html/css/vars.d.ts.map +1 -1
  35. package/dist/html/css/vars.js +2 -2
  36. package/dist/html/css/vars.js.map +1 -1
  37. package/dist/html/dom.d.ts.map +1 -1
  38. package/dist/html/dom.js +38 -3
  39. package/dist/html/dom.js.map +1 -1
  40. package/dist/html/fonts.d.ts +25 -1
  41. package/dist/html/fonts.d.ts.map +1 -1
  42. package/dist/html/fonts.js +99 -6
  43. package/dist/html/fonts.js.map +1 -1
  44. package/dist/html/form.d.ts +200 -0
  45. package/dist/html/form.d.ts.map +1 -0
  46. package/dist/html/form.js +801 -0
  47. package/dist/html/form.js.map +1 -0
  48. package/dist/html/index.d.ts +12 -0
  49. package/dist/html/index.d.ts.map +1 -1
  50. package/dist/html/index.js +30 -264
  51. package/dist/html/index.js.map +1 -1
  52. package/dist/html/layout/block.d.ts +11 -2
  53. package/dist/html/layout/block.d.ts.map +1 -1
  54. package/dist/html/layout/block.js +186 -19
  55. package/dist/html/layout/block.js.map +1 -1
  56. package/dist/html/layout/boxes.d.ts +65 -7
  57. package/dist/html/layout/boxes.d.ts.map +1 -1
  58. package/dist/html/layout/boxes.js +87 -22
  59. package/dist/html/layout/boxes.js.map +1 -1
  60. package/dist/html/layout/cache.d.ts +2 -2
  61. package/dist/html/layout/cache.d.ts.map +1 -1
  62. package/dist/html/layout/cache.js +8 -3
  63. package/dist/html/layout/cache.js.map +1 -1
  64. package/dist/html/layout/flex.js +4 -1
  65. package/dist/html/layout/flex.js.map +1 -1
  66. package/dist/html/layout/floats.d.ts +4 -0
  67. package/dist/html/layout/floats.d.ts.map +1 -1
  68. package/dist/html/layout/floats.js +26 -0
  69. package/dist/html/layout/floats.js.map +1 -1
  70. package/dist/html/layout/inline.d.ts +26 -2
  71. package/dist/html/layout/inline.d.ts.map +1 -1
  72. package/dist/html/layout/inline.js +1232 -115
  73. package/dist/html/layout/inline.js.map +1 -1
  74. package/dist/html/node.d.ts +120 -15
  75. package/dist/html/node.d.ts.map +1 -1
  76. package/dist/html/node.js +993 -174
  77. package/dist/html/node.js.map +1 -1
  78. package/dist/html/paint.d.ts +75 -2
  79. package/dist/html/paint.d.ts.map +1 -1
  80. package/dist/html/paint.js +836 -200
  81. package/dist/html/paint.js.map +1 -1
  82. package/dist/html/surfaces.d.ts +7 -2
  83. package/dist/html/surfaces.d.ts.map +1 -1
  84. package/dist/html/surfaces.js +16 -0
  85. package/dist/html/surfaces.js.map +1 -1
  86. package/dist/html/widgets.d.ts +29 -0
  87. package/dist/html/widgets.d.ts.map +1 -0
  88. package/dist/html/widgets.js +620 -0
  89. package/dist/html/widgets.js.map +1 -0
  90. package/dist/index.d.ts +1 -1
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal/window.d.ts +22 -0
  94. package/dist/internal/window.d.ts.map +1 -1
  95. package/dist/internal/window.js +35 -8
  96. package/dist/internal/window.js.map +1 -1
  97. package/dist/richtext/node.d.ts +7 -0
  98. package/dist/richtext/node.d.ts.map +1 -1
  99. package/dist/richtext/node.js.map +1 -1
  100. package/package.json +6 -4
  101. package/src/flow/node.ts +80 -64
  102. package/src/html/controls.ts +79 -58
  103. package/src/html/css/cascade.ts +610 -146
  104. package/src/html/css/color.ts +87 -0
  105. package/src/html/css/parse.ts +431 -13
  106. package/src/html/css/style.ts +367 -11
  107. package/src/html/css/ua.ts +76 -8
  108. package/src/html/css/values.ts +31 -3
  109. package/src/html/css/vars.ts +2 -2
  110. package/src/html/dom.ts +41 -3
  111. package/src/html/fonts.ts +116 -6
  112. package/src/html/form.ts +962 -0
  113. package/src/html/index.ts +49 -300
  114. package/src/html/layout/block.ts +231 -16
  115. package/src/html/layout/boxes.ts +133 -32
  116. package/src/html/layout/cache.ts +8 -3
  117. package/src/html/layout/flex.ts +4 -1
  118. package/src/html/layout/floats.ts +24 -0
  119. package/src/html/layout/inline.ts +1445 -108
  120. package/src/html/node.ts +1010 -166
  121. package/src/html/paint.ts +980 -270
  122. package/src/html/surfaces.ts +21 -1
  123. package/src/html/widgets.ts +735 -0
  124. package/src/index.ts +1 -0
  125. package/src/internal/window.ts +36 -8
  126. package/src/richtext/node.ts +7 -0
@@ -0,0 +1,962 @@
1
+ // Forms: what a submission carries, where it goes, and how it is encoded.
2
+ //
3
+ // Submitting a form is following a link the form writes. `<Html>` works the
4
+ // link out — the entry list HTML builds from the form's controls (HTML
5
+ // 4.10.21.4, "constructing the entry list"), encoded as the form's `enctype`
6
+ // says, for its `action` resolved against the document's base, by its
7
+ // `method` — and hands it to `onSubmit`, as it hands a link's `href` to
8
+ // `onLink`. It sends nothing: whether a POST goes anywhere is the host's to
9
+ // decide, as whether an image loads is.
10
+ //
11
+ // Pure, and asked of a DOM with no display: which controls belong to which
12
+ // form, which of them a submission carries and what Enter in a field does are
13
+ // where the subtle bugs are, and none of it needs a widget.
14
+ //
15
+ // What a control holds *now* is the one thing the markup does not say. A
16
+ // typed text is the widget's, and `FormState` keeps it for the submission and
17
+ // for the widget's next mount; a checkbox, a radio and a `<select>` keep
18
+ // theirs in the DOM's attributes, where `:checked` reads them, and
19
+ // `FormState` remembers what those attributes were so a reset can put them
20
+ // back.
21
+ import type { AnyNode, Element } from 'domhandler';
22
+
23
+ import { attr, childrenOf, elementsIn, isElement, tagOf } from './dom.js';
24
+ import { resolveUrl } from './url.js';
25
+
26
+ export type FormMethod = 'get' | 'post';
27
+
28
+ export type FormEnctype =
29
+ 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
30
+
31
+ /** A form, submitted: everything a host needs to make the request. */
32
+ export interface FormSubmission {
33
+ /** The `<form>`. */
34
+ form: Element;
35
+ /**
36
+ * The button that submitted it — a submit `<button>`, an `<input
37
+ * type=submit>` or an `<input type=image>` — or null for Enter in a form
38
+ * that has none.
39
+ */
40
+ submitter: Element | null;
41
+ method: FormMethod;
42
+ /**
43
+ * Where the request goes: the action, resolved against the document's
44
+ * base, and for a GET with the entries as its query — so a GET is a link
45
+ * to exactly this.
46
+ */
47
+ url: string;
48
+ enctype: FormEnctype;
49
+ /** The name–value pairs the form carries, in tree order, unencoded. A
50
+ * file field's value is the name of its file: always empty here. */
51
+ entries: [name: string, value: string][];
52
+ /** A POST's body, encoded as `enctype` says; null for a GET. */
53
+ body: string | null;
54
+ /** The `Content-Type` a POST's body goes with — `multipart/form-data`'s
55
+ * names its boundary; null for a GET. */
56
+ contentType: string | null;
57
+ /**
58
+ * The browsing context it asked for: `formtarget` on the button, `target`
59
+ * on the form, or `<base target>`. `'_blank'` is a new one; empty is the
60
+ * one the document is in.
61
+ */
62
+ target: string;
63
+ }
64
+
65
+ /** Where a submission's relative URLs resolve, and the document it leaves. */
66
+ export interface SubmitContext {
67
+ /** What the document's relative URLs resolve against: its `<base href>`,
68
+ * or the URL it came from. */
69
+ base: string | null;
70
+ /** The URL the document came from — an empty `action` is it (HTML
71
+ * 4.10.21.3, step 11), not its base. Defaults to `base`. */
72
+ documentUrl?: string | null;
73
+ /** What a text control holds now, where that is not what its markup
74
+ * says: typed text. `undefined` is "what the markup says". */
75
+ live?: (el: Element) => string | undefined;
76
+ /** Where an `<input type=image>` submitter was pressed, in CSS pixels
77
+ * from its top left. */
78
+ point?: { x: number; y: number };
79
+ /** `multipart/form-data`'s boundary. Made up when absent; a test pins it. */
80
+ boundary?: string;
81
+ }
82
+
83
+ // --- which controls, in which form ---------------------------------------------
84
+
85
+ /** The elements that can be submitted (HTML 4.10.2, "submittable"). */
86
+ const SUBMITTABLE = new Set(['button', 'input', 'select', 'textarea']);
87
+
88
+ /** Every `<input type>` HTML knows. Any other type, or none, is a text
89
+ * field. */
90
+ const INPUT_TYPES = new Set([
91
+ 'hidden',
92
+ 'text',
93
+ 'search',
94
+ 'tel',
95
+ 'url',
96
+ 'email',
97
+ 'password',
98
+ 'date',
99
+ 'month',
100
+ 'week',
101
+ 'time',
102
+ 'datetime-local',
103
+ 'number',
104
+ 'range',
105
+ 'color',
106
+ 'checkbox',
107
+ 'radio',
108
+ 'file',
109
+ 'submit',
110
+ 'image',
111
+ 'reset',
112
+ 'button',
113
+ ]);
114
+
115
+ /** The fields that make Enter in a form with no submit button do nothing
116
+ * when there is more than one of them (HTML 4.10.21.2). */
117
+ const BLOCKS_IMPLICIT = new Set([
118
+ 'text',
119
+ 'search',
120
+ 'email',
121
+ 'url',
122
+ 'tel',
123
+ 'password',
124
+ 'date',
125
+ 'month',
126
+ 'week',
127
+ 'time',
128
+ 'datetime-local',
129
+ 'number',
130
+ ]);
131
+
132
+ /** An `<input>`'s type state: its `type`, lowercased, or `text` where it
133
+ * names none HTML knows. */
134
+ export function inputType(el: Element): string {
135
+ const type = (attr(el, 'type') ?? '').trim().toLowerCase();
136
+ return INPUT_TYPES.has(type) ? type : 'text';
137
+ }
138
+
139
+ /**
140
+ * What pressing a control does: submits its form, resets it, nothing
141
+ * (`'button'`) — or null for something that is not a button at all. A
142
+ * `<button>` with no `type`, or one HTML does not know, submits.
143
+ */
144
+ export function buttonType(el: Element): 'submit' | 'reset' | 'button' | null {
145
+ const tag = tagOf(el);
146
+ if (tag === 'button') {
147
+ const type = (attr(el, 'type') ?? '').trim().toLowerCase();
148
+ return type === 'reset' || type === 'button' ? type : 'submit';
149
+ }
150
+ if (tag !== 'input') return null;
151
+ const type = inputType(el);
152
+ if (type === 'submit' || type === 'image') return 'submit';
153
+ if (type === 'reset' || type === 'button') return type;
154
+ return null;
155
+ }
156
+
157
+ /** The top of the tree an element is in: its document. */
158
+ export function rootOf(el: AnyNode): AnyNode {
159
+ let at: AnyNode = el;
160
+ while (at.parent) at = at.parent;
161
+ return at;
162
+ }
163
+
164
+ /**
165
+ * The form a control belongs to (HTML 4.10.17.3): the one its `form`
166
+ * attribute names by id, or else the nearest `<form>` around it. A `form`
167
+ * attribute that names nothing, or names something that is not a form,
168
+ * leaves it with none — it does not fall back to the form around it.
169
+ */
170
+ export function formOwner(
171
+ el: Element,
172
+ root: AnyNode = rootOf(el),
173
+ ): Element | null {
174
+ return ownerOf(el, root, null);
175
+ }
176
+
177
+ function ownerOf(
178
+ el: Element,
179
+ root: AnyNode,
180
+ byId: Map<string, Element> | null,
181
+ ): Element | null {
182
+ const id = attr(el, 'form');
183
+ if (id !== undefined) {
184
+ const named = byId ? (byId.get(id) ?? null) : elementById(root, id);
185
+ return named && tagOf(named) === 'form' ? named : null;
186
+ }
187
+ for (let at = el.parent; at && isElement(at); at = at.parent) {
188
+ if (tagOf(at) === 'form') return at;
189
+ }
190
+ return null;
191
+ }
192
+
193
+ function elementById(root: AnyNode, id: string): Element | null {
194
+ for (const el of elementsIn(root)) if (attr(el, 'id') === id) return el;
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * The submittable controls a form owns, in tree order — those inside it and
200
+ * those elsewhere that name it with `form`. Not those in a `<template>`,
201
+ * whose content is inert, or a `<datalist>`, whose are its suggestions.
202
+ */
203
+ export function controlsOf(form: Element): Element[] {
204
+ const root = rootOf(form);
205
+ let byId: Map<string, Element> | null = null;
206
+ const out: Element[] = [];
207
+ for (const el of elementsIn(root)) {
208
+ if (!SUBMITTABLE.has(tagOf(el))) continue;
209
+ // one walk for every id, and only when something asks by one
210
+ if (attr(el, 'form') !== undefined && !byId) {
211
+ byId = new Map();
212
+ for (const any of elementsIn(root)) {
213
+ const id = attr(any, 'id');
214
+ if (id !== undefined && !byId.has(id)) byId.set(id, any);
215
+ }
216
+ }
217
+ if (ownerOf(el, root, byId) !== form || inert(el)) continue;
218
+ out.push(el);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function inert(el: Element): boolean {
224
+ for (let at = el.parent; at && isElement(at); at = at.parent) {
225
+ const tag = tagOf(at);
226
+ if (tag === 'template' || tag === 'datalist') return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ /**
232
+ * Whether a control is disabled (HTML 4.10.18.5): by its own `disabled`, or
233
+ * by a disabled `<fieldset>` around it — except inside that fieldset's first
234
+ * `<legend>`, which stays live, so a checkbox there can switch the rest on.
235
+ */
236
+ export function isDisabled(el: Element): boolean {
237
+ if (attr(el, 'disabled') !== undefined) return true;
238
+ let child: Element = el;
239
+ for (let at = el.parent; at && isElement(at); at = at.parent) {
240
+ if (
241
+ tagOf(at) === 'fieldset' &&
242
+ attr(at, 'disabled') !== undefined &&
243
+ !(tagOf(child) === 'legend' && firstLegend(at) === child)
244
+ ) {
245
+ return true;
246
+ }
247
+ child = at;
248
+ }
249
+ return false;
250
+ }
251
+
252
+ function firstLegend(fieldset: Element): Element | null {
253
+ for (const child of childrenOf(fieldset)) {
254
+ if (isElement(child) && tagOf(child) === 'legend') return child;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ // --- what each control holds -------------------------------------------------------
260
+
261
+ /** A `<select>`'s `<option>`s, in tree order, through its `<optgroup>`s. */
262
+ export function optionElements(select: Element): Element[] {
263
+ const out: Element[] = [];
264
+ const walk = (node: Element): void => {
265
+ for (const child of childrenOf(node)) {
266
+ if (!isElement(child)) continue;
267
+ const tag = tagOf(child);
268
+ if (tag === 'option') out.push(child);
269
+ else if (tag === 'optgroup' && node === select) walk(child);
270
+ }
271
+ };
272
+ walk(select);
273
+ return out;
274
+ }
275
+
276
+ /** An option's text, its white space collapsed, as HTML's `text` IDL
277
+ * attribute reads it. */
278
+ export function optionLabel(option: Element): string {
279
+ let text = '';
280
+ const walk = (node: AnyNode): void => {
281
+ if (node.type === 'text') text += node.data;
282
+ else for (const child of childrenOf(node)) walk(child);
283
+ };
284
+ walk(option);
285
+ return text.replace(/[\t\n\f\r ]+/g, ' ').trim();
286
+ }
287
+
288
+ /** An option's value: its `value`, or else its text. */
289
+ export function optionValue(option: Element): string {
290
+ return attr(option, 'value') ?? optionLabel(option);
291
+ }
292
+
293
+ function optionDisabled(option: Element): boolean {
294
+ if (attr(option, 'disabled') !== undefined) return true;
295
+ const parent = option.parent;
296
+ return (
297
+ !!parent &&
298
+ isElement(parent) &&
299
+ tagOf(parent) === 'optgroup' &&
300
+ attr(parent, 'disabled') !== undefined
301
+ );
302
+ }
303
+
304
+ /** Whether a `<select>` is a list box that takes many options. */
305
+ export function isMultiple(select: Element): boolean {
306
+ return attr(select, 'multiple') !== undefined;
307
+ }
308
+
309
+ /**
310
+ * The options a `<select>` has selected. A drop-down one has exactly one
311
+ * where it has any: the last marked `selected`, as the parser leaves it, or
312
+ * else the first that is not disabled (HTML 4.10.7, "selectedness setting").
313
+ * A `multiple` one has those marked, and no default.
314
+ */
315
+ export function selectedOptions(select: Element): Element[] {
316
+ const options = optionElements(select);
317
+ const marked = options.filter((o) => attr(o, 'selected') !== undefined);
318
+ if (isMultiple(select)) return marked;
319
+ if (marked.length) return [marked[marked.length - 1]];
320
+ const first = options.find((o) => !optionDisabled(o));
321
+ return first ? [first] : [];
322
+ }
323
+
324
+ /** A `<textarea>`'s text as its markup has it — HTML drops one newline
325
+ * straight after the open tag, so a pretty-printed one does not start on
326
+ * a blank line. */
327
+ export function textareaDefault(el: Element): string {
328
+ let text = '';
329
+ for (const child of childrenOf(el)) {
330
+ if (child.type === 'text') text += child.data;
331
+ }
332
+ return text.replace(/^\r?\n/, '');
333
+ }
334
+
335
+ /** HTML's valid floating-point number (2.3.4.3): what a number field may
336
+ * hold, where `1.` and `+1` are not one. */
337
+ const FLOAT = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/;
338
+
339
+ /**
340
+ * What a text control holds: typed text where there is some, and else its
341
+ * markup's — a `<textarea>`'s content, an `<input>`'s `value` — put through
342
+ * the type's value sanitization (HTML 4.10.5.1): a single-line field holds no
343
+ * line breaks, an email or a URL no white space at its ends, and a number
344
+ * field nothing that is not a number.
345
+ */
346
+ export function controlValue(
347
+ el: Element,
348
+ live?: (el: Element) => string | undefined,
349
+ ): string {
350
+ const typed = live?.(el);
351
+ if (tagOf(el) === 'textarea') return typed ?? textareaDefault(el);
352
+ const raw = typed ?? attr(el, 'value') ?? '';
353
+ switch (inputType(el)) {
354
+ case 'text':
355
+ case 'search':
356
+ case 'tel':
357
+ case 'password':
358
+ return raw.replace(/[\r\n]/g, '');
359
+ case 'email':
360
+ case 'url':
361
+ return raw
362
+ .replace(/[\r\n]/g, '')
363
+ .replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '');
364
+ case 'number':
365
+ return FLOAT.test(raw) ? raw : '';
366
+ default:
367
+ return raw;
368
+ }
369
+ }
370
+
371
+ // --- the entry list --------------------------------------------------------------
372
+
373
+ /** One entry, and whether it is a file's — encoded differently in a
374
+ * multipart body. */
375
+ interface Entry {
376
+ name: string;
377
+ value: string;
378
+ file: boolean;
379
+ }
380
+
381
+ /**
382
+ * The entries a form submits (HTML 4.10.21.4): each enabled control it owns
383
+ * that has a name, with its value — the checked checkboxes and radios, a
384
+ * `<select>`'s selected options, the button that submitted it and no other.
385
+ */
386
+ function entriesOf(
387
+ form: Element,
388
+ submitter: Element | null,
389
+ live: SubmitContext['live'],
390
+ point: SubmitContext['point'],
391
+ ): Entry[] {
392
+ const out: Entry[] = [];
393
+ const add = (name: string, value: string, file = false): void => {
394
+ out.push({ name, value, file });
395
+ };
396
+ for (const el of controlsOf(form)) {
397
+ if (isDisabled(el)) continue;
398
+ const tag = tagOf(el);
399
+ const type = tag === 'input' ? inputType(el) : '';
400
+ if (buttonType(el) !== null && el !== submitter) continue;
401
+ if (type === 'image') {
402
+ // an image button is its point, named after it where it has a name
403
+ const name = attr(el, 'name');
404
+ const prefix = name ? `${name}.` : '';
405
+ add(`${prefix}x`, String(Math.round(point?.x ?? 0)));
406
+ add(`${prefix}y`, String(Math.round(point?.y ?? 0)));
407
+ continue;
408
+ }
409
+ const name = attr(el, 'name');
410
+ if (!name) continue;
411
+ if (tag === 'select') {
412
+ for (const option of selectedOptions(el)) {
413
+ if (!optionDisabled(option)) add(name, optionValue(option));
414
+ }
415
+ continue;
416
+ }
417
+ if (type === 'checkbox' || type === 'radio') {
418
+ if (attr(el, 'checked') !== undefined) {
419
+ add(name, attr(el, 'value') ?? 'on');
420
+ }
421
+ continue;
422
+ }
423
+ if (type === 'file') {
424
+ // no file is ever chosen here: the field goes as one with no name
425
+ add(name, '', true);
426
+ continue;
427
+ }
428
+ if (type === 'hidden' && name.toLowerCase() === '_charset_') {
429
+ add(name, 'UTF-8');
430
+ continue;
431
+ }
432
+ if (tag === 'button' || type === 'submit' || type === 'reset') {
433
+ add(name, attr(el, 'value') ?? '');
434
+ continue;
435
+ }
436
+ add(name, controlValue(el, live));
437
+ const dirname = attr(el, 'dirname');
438
+ if (
439
+ dirname &&
440
+ (tag === 'textarea' || type === 'text' || type === 'search')
441
+ ) {
442
+ add(dirname, attr(el, 'dir')?.toLowerCase() === 'rtl' ? 'rtl' : 'ltr');
443
+ }
444
+ }
445
+ return out;
446
+ }
447
+
448
+ // --- encoding ---------------------------------------------------------------------
449
+
450
+ /** Line breaks as a form sends them: every lone CR and lone LF becomes
451
+ * CRLF. */
452
+ function crlf(text: string): string {
453
+ return text.replace(/\r\n|\r|\n/g, '\r\n');
454
+ }
455
+
456
+ /** A string with no lone surrogates: what encoding it as UTF-8 needs, and
457
+ * what a USVString is. */
458
+ function wellFormed(text: string): string {
459
+ return text.replace(
460
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
461
+ '�',
462
+ );
463
+ }
464
+
465
+ /**
466
+ * `application/x-www-form-urlencoded`'s byte serializer over UTF-8 (URL
467
+ * 5.2): letters, digits and `*-._` as they are, a space as `+`, everything
468
+ * else percent-encoded. `encodeURIComponent` spares `!~'()` besides, so
469
+ * those are encoded after it.
470
+ */
471
+ function formEncode(text: string): string {
472
+ return encodeURIComponent(wellFormed(text))
473
+ .replace(/%20/g, '+')
474
+ .replace(
475
+ /[!'()~]/g,
476
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
477
+ );
478
+ }
479
+
480
+ /** The entries as `application/x-www-form-urlencoded`: a query string, and
481
+ * a POST body of that type. */
482
+ export function urlencoded(entries: readonly [string, string][]): string {
483
+ return entries
484
+ .map(
485
+ ([name, value]) => `${formEncode(crlf(name))}=${formEncode(crlf(value))}`,
486
+ )
487
+ .join('&');
488
+ }
489
+
490
+ /** The entries as `text/plain`: a line each, `name=value`, unescaped. */
491
+ export function plainText(entries: readonly [string, string][]): string {
492
+ return entries
493
+ .map(([name, value]) => `${crlf(name)}=${crlf(value)}\r\n`)
494
+ .join('');
495
+ }
496
+
497
+ /** A name in a `Content-Disposition` header: its quote and its line breaks
498
+ * percent-encoded, as browsers write them (HTML 4.10.21.8). */
499
+ function dispositionName(name: string): string {
500
+ return crlf(name).replace(/[\r\n"]/g, (c) =>
501
+ c === '"' ? '%22' : c === '\r' ? '%0D' : '%0A',
502
+ );
503
+ }
504
+
505
+ function multipart(entries: readonly Entry[], boundary: string): string {
506
+ let body = '';
507
+ for (const { name, value, file } of entries) {
508
+ body += `--${boundary}\r\nContent-Disposition: form-data; name="${dispositionName(name)}"`;
509
+ body += file
510
+ ? `; filename="${dispositionName(value)}"\r\nContent-Type: application/octet-stream\r\n\r\n\r\n`
511
+ : `\r\n\r\n${crlf(value)}\r\n`;
512
+ }
513
+ return `${body}--${boundary}--\r\n`;
514
+ }
515
+
516
+ function makeBoundary(): string {
517
+ let tail = '';
518
+ while (tail.length < 24) tail += Math.random().toString(36).slice(2);
519
+ return `----react-x11-form-${tail.slice(0, 24)}`;
520
+ }
521
+
522
+ /** `url` with its query replaced by `query` and its fragment kept — HTML's
523
+ * "mutate action URL". A GET always carries a query, if an empty one. */
524
+ function withQuery(url: string, query: string): string {
525
+ const hash = url.indexOf('#');
526
+ const fragment = hash < 0 ? '' : url.slice(hash);
527
+ const head = hash < 0 ? url : url.slice(0, hash);
528
+ const q = head.indexOf('?');
529
+ return `${q < 0 ? head : head.slice(0, q)}?${query}${fragment}`;
530
+ }
531
+
532
+ // --- a submission -----------------------------------------------------------------
533
+
534
+ function enctypeOf(raw: string | undefined): FormEnctype {
535
+ const type = (raw ?? '').trim().toLowerCase();
536
+ return type === 'multipart/form-data' || type === 'text/plain'
537
+ ? type
538
+ : 'application/x-www-form-urlencoded';
539
+ }
540
+
541
+ /** The first `<base target>` in the document, or empty. */
542
+ function baseTarget(root: AnyNode): string {
543
+ for (const el of elementsIn(root)) {
544
+ if (tagOf(el) === 'base' && attr(el, 'target') !== undefined) {
545
+ return attr(el, 'target') ?? '';
546
+ }
547
+ }
548
+ return '';
549
+ }
550
+
551
+ /**
552
+ * A form's submission by `submitter` (HTML 4.10.21.3): its entries, and the
553
+ * request they make. The button's `formaction`, `formmethod`, `formenctype`
554
+ * and `formtarget` win over the form's own. Null for `method="dialog"`,
555
+ * which closes a dialog rather than submitting anything, and for a
556
+ * submitter the form does not own.
557
+ */
558
+ export function formSubmission(
559
+ form: Element,
560
+ submitter: Element | null,
561
+ context: SubmitContext,
562
+ ): FormSubmission | null {
563
+ if (submitter && formOwner(submitter) !== form) return null;
564
+ const pick = (own: string, formName: string): string | undefined =>
565
+ (submitter ? attr(submitter, own) : undefined) ?? attr(form, formName);
566
+ const methodName = (pick('formmethod', 'method') ?? '').trim().toLowerCase();
567
+ if (methodName === 'dialog') return null;
568
+ const method: FormMethod = methodName === 'post' ? 'post' : 'get';
569
+
570
+ const written = pick('formaction', 'action') ?? '';
571
+ const documentUrl = context.documentUrl ?? context.base ?? '';
572
+ const action = written.trim()
573
+ ? resolveUrl(written, context.base)
574
+ : documentUrl;
575
+ const enctype = enctypeOf(pick('formenctype', 'enctype'));
576
+ const target = pick('formtarget', 'target') ?? baseTarget(rootOf(form));
577
+
578
+ const list = entriesOf(form, submitter, context.live, context.point);
579
+ const entries = list.map(({ name, value }): [string, string] => [
580
+ name,
581
+ value,
582
+ ]);
583
+ const base = { form, submitter, enctype, entries, target };
584
+ if (method === 'get') {
585
+ // a `mailto:` form writes its entries as headers, which spell a space
586
+ // `%20` (HTML 4.10.21.3, "mail with headers")
587
+ const query = /^mailto:/i.test(action)
588
+ ? urlencoded(entries).replace(/\+/g, '%20')
589
+ : urlencoded(entries);
590
+ return {
591
+ ...base,
592
+ method,
593
+ url: withQuery(action, query),
594
+ body: null,
595
+ contentType: null,
596
+ };
597
+ }
598
+ if (enctype === 'multipart/form-data') {
599
+ const boundary = context.boundary ?? makeBoundary();
600
+ return {
601
+ ...base,
602
+ method,
603
+ url: action,
604
+ body: multipart(list, boundary),
605
+ contentType: `multipart/form-data; boundary=${boundary}`,
606
+ };
607
+ }
608
+ return {
609
+ ...base,
610
+ method,
611
+ url: action,
612
+ body: enctype === 'text/plain' ? plainText(entries) : urlencoded(entries),
613
+ contentType:
614
+ enctype === 'text/plain' ? 'text/plain;charset=UTF-8' : enctype,
615
+ };
616
+ }
617
+
618
+ /**
619
+ * What Enter in a field submits (HTML 4.10.21.2, "implicit submission"): its
620
+ * form, by the form's default button — the first submit button it owns —
621
+ * or, where it has none, by no button at all, so long as the form has at
622
+ * most one field of the kinds that would make Enter ambiguous. Null where
623
+ * Enter submits nothing: no form, a disabled default button, or a form of
624
+ * several fields and no button.
625
+ */
626
+ export function implicitSubmission(
627
+ field: Element,
628
+ ): { form: Element; submitter: Element | null } | null {
629
+ const form = formOwner(field);
630
+ if (!form) return null;
631
+ let fields = 0;
632
+ for (const el of controlsOf(form)) {
633
+ if (buttonType(el) === 'submit') {
634
+ return isDisabled(el) ? null : { form, submitter: el };
635
+ }
636
+ if (tagOf(el) === 'input' && BLOCKS_IMPLICIT.has(inputType(el))) {
637
+ fields += 1;
638
+ }
639
+ }
640
+ return fields > 1 ? null : { form, submitter: null };
641
+ }
642
+
643
+ // --- labels ------------------------------------------------------------------------
644
+
645
+ /** Whether an element can be what a `<label>` labels (HTML 4.10.2). */
646
+ function labelable(el: Element): boolean {
647
+ const tag = tagOf(el);
648
+ if (tag === 'input') return inputType(el) !== 'hidden';
649
+ return (
650
+ tag === 'button' ||
651
+ tag === 'select' ||
652
+ tag === 'textarea' ||
653
+ tag === 'meter' ||
654
+ tag === 'output' ||
655
+ tag === 'progress'
656
+ );
657
+ }
658
+
659
+ /**
660
+ * The control a `<label>` is for (HTML 4.10.4): the one its `for` names by
661
+ * id, where that is one a label can label, or else the first such control
662
+ * inside it. A press on the label is a press on that control.
663
+ */
664
+ export function labeledControl(label: Element): Element | null {
665
+ const id = attr(label, 'for');
666
+ if (id !== undefined) {
667
+ const named = elementById(rootOf(label), id);
668
+ return named && labelable(named) ? named : null;
669
+ }
670
+ for (const el of elementsIn(label)) if (labelable(el)) return el;
671
+ return null;
672
+ }
673
+
674
+ // --- constraint validation ----------------------------------------------------------
675
+
676
+ /** HTML's valid email address (4.10.5.1.5), as the spec writes it. */
677
+ const EMAIL =
678
+ /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
679
+
680
+ /** The kinds of field `pattern`, `minlength` and `maxlength` apply to. */
681
+ const TEXTUAL = new Set(['text', 'search', 'url', 'tel', 'email', 'password']);
682
+
683
+ interface UrlCtor {
684
+ new (url: string): unknown;
685
+ }
686
+
687
+ function absoluteUrl(text: string): boolean {
688
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text)) return false;
689
+ const URLClass = (globalThis as { URL?: UrlCtor }).URL;
690
+ if (!URLClass) return true;
691
+ try {
692
+ new URLClass(text);
693
+ return true;
694
+ } catch {
695
+ return false;
696
+ }
697
+ }
698
+
699
+ /** A `pattern` as HTML compiles it: the whole value, in `v` mode — or `u`
700
+ * where the runtime has no `v` — and no constraint at all where it does
701
+ * not compile. */
702
+ function patternOf(source: string): RegExp | null {
703
+ for (const flags of ['v', 'u']) {
704
+ try {
705
+ return new RegExp(`^(?:${source})$`, flags);
706
+ } catch {
707
+ // try the next, then give up
708
+ }
709
+ }
710
+ return null;
711
+ }
712
+
713
+ /** Whether a control's value is checked at all (HTML 4.10.21.2, "barred
714
+ * from constraint validation"). */
715
+ function validated(el: Element): boolean {
716
+ if (isDisabled(el) || inert(el)) return false;
717
+ const tag = tagOf(el);
718
+ if (tag === 'input') {
719
+ const type = inputType(el);
720
+ if (type === 'hidden' || type === 'reset' || type === 'button') {
721
+ return false;
722
+ }
723
+ if (buttonType(el) === 'submit') return false;
724
+ return attr(el, 'readonly') === undefined;
725
+ }
726
+ if (tag === 'textarea') return attr(el, 'readonly') === undefined;
727
+ return tag === 'select';
728
+ }
729
+
730
+ /**
731
+ * What is wrong with a control's value, in the words a browser uses, or
732
+ * null where nothing is: the constraints a static document can state —
733
+ * `required`, `minlength` and `maxlength`, `pattern`, `min` and `max`, and
734
+ * an email, a URL or a number that is not one (HTML 4.10.20). A length is
735
+ * checked only once the value has been typed, as HTML checks it; a
736
+ * document's own value is the author's to get right.
737
+ */
738
+ export function validationMessage(
739
+ el: Element,
740
+ live?: (el: Element) => string | undefined,
741
+ ): string | null {
742
+ if (!validated(el)) return null;
743
+ const tag = tagOf(el);
744
+ const required = attr(el, 'required') !== undefined;
745
+ if (tag === 'select') {
746
+ if (!required) return null;
747
+ const selected = selectedOptions(el);
748
+ const placeholder =
749
+ !isMultiple(el) &&
750
+ selected.length === 1 &&
751
+ selected[0] === optionElements(el)[0] &&
752
+ selected[0].parent === el &&
753
+ optionValue(selected[0]) === '';
754
+ return !selected.length || placeholder
755
+ ? 'Please select an item in the list.'
756
+ : null;
757
+ }
758
+ const type = tag === 'input' ? inputType(el) : 'textarea';
759
+ if (type === 'checkbox') {
760
+ return required && attr(el, 'checked') === undefined
761
+ ? 'Please check this box if you want to proceed.'
762
+ : null;
763
+ }
764
+ if (type === 'radio') {
765
+ const group = [el, ...radioGroup(el)];
766
+ if (!group.some((r) => attr(r, 'required') !== undefined)) return null;
767
+ return group.some((r) => attr(r, 'checked') !== undefined)
768
+ ? null
769
+ : 'Please select one of these options.';
770
+ }
771
+ if (type === 'file') return required ? 'Please select a file.' : null;
772
+ if (type === 'range' || type === 'color') return null;
773
+
774
+ const typed = live?.(el);
775
+ const value = controlValue(el, live);
776
+ if (type === 'number' && typed !== undefined && typed.trim() && !value) {
777
+ return 'Please enter a number.';
778
+ }
779
+ if (!value) return required ? 'Please fill out this field.' : null;
780
+
781
+ if (typed !== undefined && (type === 'textarea' || TEXTUAL.has(type))) {
782
+ const length = value.length;
783
+ const min = Number(attr(el, 'minlength'));
784
+ if (min > 0 && length < min) {
785
+ return `Please lengthen this text to ${min} characters or more (you are currently using ${length} characters).`;
786
+ }
787
+ const max = attr(el, 'maxlength');
788
+ if (max !== undefined && /^\d+$/.test(max.trim()) && length > Number(max)) {
789
+ return `Please shorten this text to ${Number(max)} characters or less (you are currently using ${length} characters).`;
790
+ }
791
+ }
792
+ if (type === 'email') {
793
+ const addresses =
794
+ attr(el, 'multiple') !== undefined
795
+ ? value.split(',').map((a) => a.trim())
796
+ : [value];
797
+ if (!addresses.every((a) => EMAIL.test(a))) {
798
+ return 'Please enter an email address.';
799
+ }
800
+ }
801
+ if (type === 'url' && !absoluteUrl(value)) return 'Please enter a URL.';
802
+ const pattern = attr(el, 'pattern');
803
+ if (pattern !== undefined && TEXTUAL.has(type)) {
804
+ const re = patternOf(pattern);
805
+ const values =
806
+ type === 'email' && attr(el, 'multiple') !== undefined
807
+ ? value.split(',').map((a) => a.trim())
808
+ : [value];
809
+ if (re && !values.every((v) => re.test(v))) {
810
+ const title = attr(el, 'title');
811
+ return title
812
+ ? `Please match the requested format:\n${title}`
813
+ : 'Please match the requested format.';
814
+ }
815
+ }
816
+ if (type === 'number') {
817
+ const n = Number(value);
818
+ const min = attr(el, 'min');
819
+ if (min !== undefined && FLOAT.test(min.trim()) && n < Number(min)) {
820
+ return `Value must be greater than or equal to ${min.trim()}.`;
821
+ }
822
+ const max = attr(el, 'max');
823
+ if (max !== undefined && FLOAT.test(max.trim()) && n > Number(max)) {
824
+ return `Value must be less than or equal to ${max.trim()}.`;
825
+ }
826
+ }
827
+ return null;
828
+ }
829
+
830
+ /**
831
+ * The first control a submission would be refused over, and why — HTML's
832
+ * interactive validation (4.10.21.3), which a form's `novalidate` or its
833
+ * submitter's `formnovalidate` turns off. Null where the form may go.
834
+ */
835
+ export function firstInvalid(
836
+ form: Element,
837
+ submitter: Element | null,
838
+ live?: (el: Element) => string | undefined,
839
+ ): { element: Element; message: string } | null {
840
+ if (attr(form, 'novalidate') !== undefined) return null;
841
+ if (submitter && attr(submitter, 'formnovalidate') !== undefined) {
842
+ return null;
843
+ }
844
+ for (const el of controlsOf(form)) {
845
+ const message = validationMessage(el, live);
846
+ if (message) return { element: el, message };
847
+ }
848
+ return null;
849
+ }
850
+
851
+ // --- the live state ----------------------------------------------------------------
852
+
853
+ /** What a control's attributes were before anything was typed or chosen. */
854
+ interface Snapshot {
855
+ attribs: Record<string, string | undefined>;
856
+ /** For a `<select>`: which of its options were `selected`. */
857
+ selected?: Element[];
858
+ }
859
+
860
+ /**
861
+ * What a document's controls hold that its markup does not, and what their
862
+ * markup said before. One per `<Html>`, keyed by element, so a re-parse —
863
+ * new elements — starts clean.
864
+ *
865
+ * Typed text lives here rather than in the DOM because HTML's `value`
866
+ * attribute is the field's *default*: it is what a reset puts back, and
867
+ * what a `<textarea>` has is its content, which is not an attribute at all.
868
+ * A checkbox, a radio and a `<select>` do keep what they hold in the DOM —
869
+ * `checked` and `selected` — because `:checked` is a selector documents
870
+ * really use and it reads those; so for them this keeps the markup's
871
+ * attributes, from before the first change, for a reset.
872
+ */
873
+ export class FormState {
874
+ private _typed = new WeakMap<Element, string>();
875
+ private _markup = new WeakMap<Element, Snapshot>();
876
+
877
+ /** Typed text, where the field has any — the `live` a submission reads. */
878
+ typed(el: Element): string | undefined {
879
+ return this._typed.get(el);
880
+ }
881
+
882
+ /** What a text control holds now: typed, or its markup's. */
883
+ value(el: Element): string {
884
+ return controlValue(el, (e) => this._typed.get(e));
885
+ }
886
+
887
+ setTyped(el: Element, text: string): void {
888
+ this.remember(el);
889
+ this._typed.set(el, text);
890
+ }
891
+
892
+ /** Keep what `el`'s markup says, before a change to its attributes. */
893
+ remember(el: Element): void {
894
+ if (this._markup.has(el)) return;
895
+ const tag = tagOf(el);
896
+ const snapshot: Snapshot = {
897
+ attribs: {
898
+ checked: attr(el, 'checked'),
899
+ value: attr(el, 'value'),
900
+ },
901
+ };
902
+ if (tag === 'select') {
903
+ snapshot.selected = optionElements(el).filter(
904
+ (o) => attr(o, 'selected') !== undefined,
905
+ );
906
+ }
907
+ this._markup.set(el, snapshot);
908
+ }
909
+
910
+ /**
911
+ * Put every control `form` owns back as its markup had it (HTML 4.10.21.5,
912
+ * "reset"). True when anything changed — the widgets then mount again, to
913
+ * show it.
914
+ */
915
+ reset(form: Element): boolean {
916
+ let changed = false;
917
+ for (const el of controlsOf(form)) {
918
+ changed = this._typed.delete(el) || changed;
919
+ const snapshot = this._markup.get(el);
920
+ if (!snapshot) continue;
921
+ this._markup.delete(el);
922
+ changed = true;
923
+ for (const [name, value] of Object.entries(snapshot.attribs)) {
924
+ if (value === undefined) delete el.attribs[name];
925
+ else el.attribs[name] = value;
926
+ }
927
+ if (snapshot.selected) {
928
+ for (const option of optionElements(el)) {
929
+ if (snapshot.selected.includes(option)) option.attribs.selected = '';
930
+ else delete option.attribs.selected;
931
+ }
932
+ }
933
+ }
934
+ return changed;
935
+ }
936
+ }
937
+
938
+ /**
939
+ * The other radios in `radio`'s group (HTML 4.10.5.1.18): the inputs of type
940
+ * radio with the same name, in the same form — or, for one in no form, in
941
+ * no form either. Checking one unchecks these.
942
+ */
943
+ export function radioGroup(radio: Element): Element[] {
944
+ const name = attr(radio, 'name');
945
+ if (!name) return [];
946
+ const root = rootOf(radio);
947
+ const form = formOwner(radio, root);
948
+ const out: Element[] = [];
949
+ const candidates = form ? controlsOf(form) : elementsIn(root);
950
+ for (const el of candidates) {
951
+ if (
952
+ el !== radio &&
953
+ tagOf(el) === 'input' &&
954
+ inputType(el) === 'radio' &&
955
+ attr(el, 'name') === name &&
956
+ (form || formOwner(el, root) === null)
957
+ ) {
958
+ out.push(el);
959
+ }
960
+ }
961
+ return out;
962
+ }