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