@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,735 @@
1
+ // The form controls' React half: the widgets mounted at the rectangles
2
+ // layout reserved for them, what a press on a `<button>`, a `<label>` or an
3
+ // image button does, and a form's submission — checked, encoded, and handed
4
+ // to `onSubmit`.
5
+ //
6
+ // Every widget here is a **core** one rather than something drawn: a
7
+ // `<select>` in a document drops the same menu as a `<Select>` in the window
8
+ // around it, a `<textinput>` gets the same caret, the same IME and the same
9
+ // edit menu, and all of them join the window's focus order. What the
10
+ // document draws itself is a `<button>`, whose content is the page's, and an
11
+ // image button, which is a picture; a press on either reaches the element,
12
+ // which is why this also watches the document's own presses.
13
+ //
14
+ // What the markup cannot say — what was typed, what a reset puts back — is
15
+ // `FormState`'s (form.ts), one per `<Html>`, and what a submission carries
16
+ // and where it goes is form.ts's too. This is the half that has a React
17
+ // tree.
18
+ import React from 'react';
19
+ import type { ReactNode } from 'react';
20
+ import { Button, Checkbox, Radio, RadioGroup, Select } from 'react-x11';
21
+ import type { DrawnNode, MouseEvent as X11MouseEvent } from 'react-x11';
22
+ import type { Style } from 'react-x11/style';
23
+
24
+ import type {} from 'react-x11/jsx-runtime';
25
+
26
+ import { cancelLater, later } from '../internal/timers.js';
27
+ import { hx } from './hx.js';
28
+ import { attr, tagOf } from './dom.js';
29
+ import type { Document, Element } from './dom.js';
30
+ import type { HtmlViewNode } from './node.js';
31
+ import type { RootLook } from './css/style.js';
32
+ import { buttonLabel, optionsOf, selectedOption } from './controls.js';
33
+ import type { BareField, ControlRect } from './controls.js';
34
+ import {
35
+ FormState,
36
+ buttonType,
37
+ firstInvalid,
38
+ formOwner,
39
+ formSubmission,
40
+ implicitSubmission,
41
+ inputType,
42
+ isDisabled,
43
+ labeledControl,
44
+ optionElements,
45
+ optionValue,
46
+ radioGroup,
47
+ } from './form.js';
48
+ import type { FormSubmission } from './form.js';
49
+
50
+ const h = React.createElement;
51
+
52
+ /** A point in CSS pixels, from an element's top left. */
53
+ type Point = { x: number; y: number };
54
+
55
+ export interface FormsOptions {
56
+ /** The element, for the document's base, its geometry and its window. */
57
+ view: React.RefObject<HtmlViewNode | null>;
58
+ /** The URL the document came from: an empty `action` is it. */
59
+ baseUrl: string | null | undefined;
60
+ onControlChange?: (element: Element, value: string | boolean) => void;
61
+ onSubmit?: (submission: FormSubmission) => void;
62
+ /** Restyle and lay out again: a change a selector can see. */
63
+ touch: () => void;
64
+ }
65
+
66
+ export interface Forms {
67
+ /** The document's own presses: its `<button>`s, `<label>`s and image
68
+ * buttons. */
69
+ onMouseDown: (ev: X11MouseEvent<DrawnNode>) => void;
70
+ onMouseUp: (ev: X11MouseEvent<DrawnNode>) => void;
71
+ /** The widgets for the rectangles layout reported, and the message of a
72
+ * form that would not submit. */
73
+ render(rects: readonly ControlRect[], look: RootLook): ReactNode[];
74
+ }
75
+
76
+ /** How far a press may travel and still be a click — `useLinkClicks`'s
77
+ * rule, so a link and a button in one document agree about it. */
78
+ const CLICK_SLOP = 4;
79
+
80
+ /** How long a validation message stays, as a browser's bubble does. */
81
+ const MESSAGE_MS = 5000;
82
+
83
+ export function useForms(options: FormsOptions): Forms {
84
+ const { view, baseUrl, onControlChange, onSubmit, touch } = options;
85
+
86
+ // What the controls hold that the markup does not, for the widgets'
87
+ // next mount, a submission and a reset. A reset bumps `resets`, which is
88
+ // in every widget's key: an uncontrolled field shows its markup's value
89
+ // again only by mounting again.
90
+ const forms = React.useMemo(() => new FormState(), []);
91
+ const live = (el: Element) => forms.typed(el);
92
+ const [resets, setResets] = React.useState(0);
93
+ const [invalid, setInvalid] = React.useState<{
94
+ element: Element;
95
+ message: string;
96
+ } | null>(null);
97
+
98
+ // Each element's widget is keyed by the element, not by where it is: a
99
+ // field that layout moves — a stylesheet arriving, an image above it
100
+ // loading, a resize — is the same widget, and keeps its focus, its caret
101
+ // and its undo. Keyed by position, each move mounted a new one, and a
102
+ // page whose stylesheet landed while someone was typing lost the field
103
+ // from under them.
104
+ const widgets = React.useMemo(() => new WidgetBoxes(), []);
105
+
106
+ React.useEffect(() => {
107
+ if (!invalid) return;
108
+ const timer = later(() => setInvalid(null), MESSAGE_MS);
109
+ return () => cancelLater(timer);
110
+ }, [invalid]);
111
+
112
+ const submit = (form: Element, submitter: Element | null, point?: Point) => {
113
+ if (!onSubmit) return;
114
+ // a form that would send what its own constraints refuse says why, at
115
+ // the first control that is wrong, and sends nothing
116
+ const wrong = firstInvalid(form, submitter, live);
117
+ if (wrong) {
118
+ setInvalid(wrong);
119
+ widgets.focus(wrong.element);
120
+ return;
121
+ }
122
+ setInvalid(null);
123
+ const node = view.current;
124
+ const submission = formSubmission(form, submitter, {
125
+ base: node?.documentBase ?? baseUrl ?? null,
126
+ documentUrl: baseUrl ?? null,
127
+ live,
128
+ point,
129
+ });
130
+ if (submission) onSubmit(submission);
131
+ };
132
+
133
+ /** A button's activation behaviour (HTML 4.10.6): a submit button
134
+ * submits its form, a reset button resets it, and any other does
135
+ * nothing. Reported first, as a pressed widget is. */
136
+ const press = (button: Element, point?: Point) => {
137
+ onControlChange?.(button, attr(button, 'value') ?? '');
138
+ const kind = buttonType(button);
139
+ const form = kind && kind !== 'button' ? formOwner(button) : null;
140
+ if (!form) return;
141
+ if (kind === 'submit') submit(form, button, point);
142
+ else if (forms.reset(form)) {
143
+ setInvalid(null);
144
+ setResets((n) => n + 1);
145
+ touch();
146
+ }
147
+ };
148
+
149
+ /** A control's value changed: tell the application, and let a message
150
+ * about it go. */
151
+ const changed = (el: Element, value: string | boolean, restyle: boolean) => {
152
+ onControlChange?.(el, value);
153
+ // a field's change lets its own message go, and a radio's its group's
154
+ if (
155
+ invalid &&
156
+ (invalid.element === el ||
157
+ (tagOf(el) === 'input' &&
158
+ inputType(el) === 'radio' &&
159
+ radioGroup(el).includes(invalid.element)))
160
+ ) {
161
+ setInvalid(null);
162
+ }
163
+ if (restyle) touch();
164
+ };
165
+
166
+ const setChecked = (el: Element, checked: boolean) => {
167
+ forms.remember(el);
168
+ if (checked) el.attribs.checked = '';
169
+ else delete el.attribs.checked;
170
+ changed(el, checked, true);
171
+ };
172
+
173
+ // Core's radio is a group member and HTML's is a free-standing input that
174
+ // happens to share a `name`. Each one is therefore its own one-member
175
+ // `RadioGroup`, and the exclusivity that makes it a group is done where
176
+ // HTML keeps it: in the DOM, across the radios of its name in its form.
177
+ const checkRadio = (el: Element) => {
178
+ for (const other of radioGroup(el)) {
179
+ if (attr(other, 'checked') === undefined) continue;
180
+ forms.remember(other);
181
+ delete other.attribs.checked;
182
+ }
183
+ forms.remember(el);
184
+ el.attribs.checked = '';
185
+ changed(el, attr(el, 'value') ?? 'on', true);
186
+ };
187
+
188
+ /** A press on a `<label>` is one on its control (HTML 4.10.4): a box is
189
+ * toggled, a radio checked, a button pressed, and a field focused. */
190
+ const activateLabel = (control: Element) => {
191
+ if (isDisabled(control)) return;
192
+ const tag = tagOf(control);
193
+ const type = tag === 'input' ? inputType(control) : '';
194
+ if (type === 'checkbox') {
195
+ setChecked(control, attr(control, 'checked') === undefined);
196
+ } else if (type === 'radio') {
197
+ if (attr(control, 'checked') === undefined) checkRadio(control);
198
+ } else if (tag === 'button' || buttonType(control) !== null) {
199
+ press(control, type === 'image' ? { x: 0, y: 0 } : undefined);
200
+ } else {
201
+ widgets.focus(control);
202
+ }
203
+ };
204
+
205
+ // The document's own presses. A press and a release on the same thing,
206
+ // close together: a click, not a drag.
207
+ const pressed = React.useRef<{ at: Pressable; x: number; y: number } | null>(
208
+ null,
209
+ );
210
+ const onMouseDown = (ev: X11MouseEvent<DrawnNode>) => {
211
+ pressed.current = null;
212
+ if (ev.button !== 1) return;
213
+ const at = pressableAt(ev);
214
+ if (at) pressed.current = { at, x: ev.x, y: ev.y };
215
+ };
216
+ const onMouseUp = (ev: X11MouseEvent<DrawnNode>) => {
217
+ const start = pressed.current;
218
+ pressed.current = null;
219
+ if (!start || ev.button !== 1) return;
220
+ if (
221
+ Math.abs(ev.x - start.x) > CLICK_SLOP ||
222
+ Math.abs(ev.y - start.y) > CLICK_SLOP
223
+ ) {
224
+ return;
225
+ }
226
+ const at = pressableAt(ev);
227
+ if (!at || at.element !== start.at.element) return;
228
+ if (at.kind === 'label') {
229
+ // a label is text, and a drag that selected some of it was reading
230
+ // it; a button is pressed however many times it is clicked
231
+ if (!(ev.currentTarget?.textSelection?.isCollapsed ?? true)) return;
232
+ activateLabel(at.control);
233
+ } else if (at.kind === 'image')
234
+ press(at.element, imagePoint(at.element, ev));
235
+ else press(at.element);
236
+ };
237
+
238
+ /** Where in an image button a press landed, in its own CSS pixels. */
239
+ const imagePoint = (el: Element, ev: X11MouseEvent<DrawnNode>): Point => {
240
+ const node = view.current;
241
+ const rect = node?.elementRect(el);
242
+ const origin = node?.getClientRects()[0];
243
+ if (!rect || !origin) return { x: 0, y: 0 };
244
+ return {
245
+ x: Math.max(0, ev.x - origin.x - rect.x),
246
+ y: Math.max(0, ev.y - origin.y - rect.y),
247
+ };
248
+ };
249
+
250
+ // `autofocus`: the first control that asks for the focus gets it once a
251
+ // document is up — where nothing else in the window holds it. A page
252
+ // never takes the keyboard from the application around it.
253
+ const autofocused = React.useRef<Document | null>(null);
254
+ const autofocus = (rects: readonly ControlRect[]) => {
255
+ const document = view.current?.document ?? null;
256
+ if (!document || autofocused.current === document) return;
257
+ const wants = rects.find((r) => attr(r.element, 'autofocus') !== undefined);
258
+ if (!wants) return;
259
+ autofocused.current = document;
260
+ let top: DrawnNode | null = view.current as unknown as DrawnNode;
261
+ while (top?.parent) top = top.parent as DrawnNode;
262
+ if (top?.focusWithin) return;
263
+ widgets.focus(wants.element);
264
+ };
265
+
266
+ const ctx: ControlContext = {
267
+ forms,
268
+ generation: resets,
269
+ widgets,
270
+ changed,
271
+ setChecked,
272
+ checkRadio,
273
+ press,
274
+ submitFrom: (field) => {
275
+ const plan = implicitSubmission(field);
276
+ if (plan) submit(plan.form, plan.submitter);
277
+ },
278
+ };
279
+
280
+ const rendered = React.useRef<readonly ControlRect[]>([]);
281
+ React.useEffect(() => autofocus(rendered.current));
282
+
283
+ return {
284
+ onMouseDown,
285
+ onMouseUp,
286
+ render: (rects, look) => {
287
+ rendered.current = rects;
288
+ const out = rects.map((rect) => renderControl(rect, look, ctx));
289
+ if (invalid) {
290
+ const bubble = renderMessage(invalid, rects, view.current, look);
291
+ if (bubble) out.push(bubble);
292
+ }
293
+ return out;
294
+ },
295
+ };
296
+ }
297
+
298
+ /** What a press in the document can be a press of. */
299
+ type Pressable =
300
+ | { kind: 'button'; element: Element }
301
+ | { kind: 'image'; element: Element }
302
+ | { kind: 'label'; element: Element; control: Element };
303
+
304
+ /**
305
+ * The thing a press lands on: the nearest `<button>`, image button or
306
+ * `<label>` around the element under it. A link inside one is the link's,
307
+ * and a disabled button is pressed by nobody; a label with nothing to label
308
+ * is only text.
309
+ */
310
+ function pressableAt(ev: X11MouseEvent<DrawnNode>): Pressable | null {
311
+ const target = ev.target as {
312
+ elementAtPoint?: (x: number, y: number) => Element | null;
313
+ } | null;
314
+ let node =
315
+ typeof target?.elementAtPoint === 'function'
316
+ ? target.elementAtPoint(ev.x, ev.y)
317
+ : null;
318
+ for (
319
+ ;
320
+ node;
321
+ node = node.parent?.type === 'tag' ? (node.parent as Element) : null
322
+ ) {
323
+ const tag = tagOf(node);
324
+ if (tag === 'a' && attr(node, 'href') !== undefined) return null;
325
+ if (tag === 'button') {
326
+ return isDisabled(node) ? null : { kind: 'button', element: node };
327
+ }
328
+ if (tag === 'input' && inputType(node) === 'image') {
329
+ return isDisabled(node) ? null : { kind: 'image', element: node };
330
+ }
331
+ if (tag === 'label') {
332
+ const control = labeledControl(node);
333
+ return control ? { kind: 'label', element: node, control } : null;
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * The box each element's widget is mounted in, by element: the key the
341
+ * widget is mounted under, and where to find it to give it the focus.
342
+ */
343
+ class WidgetBoxes {
344
+ private _ids = new WeakMap<Element, number>();
345
+ private _next = 0;
346
+ private _boxes = new Map<Element, DrawnNode>();
347
+ private _refs = new WeakMap<Element, (node: DrawnNode | null) => void>();
348
+
349
+ idOf(el: Element): number {
350
+ let id = this._ids.get(el);
351
+ if (id === undefined) this._ids.set(el, (id = ++this._next));
352
+ return id;
353
+ }
354
+
355
+ /** A ref for the box of `el`'s widget, the same one every render. */
356
+ refOf(el: Element): (node: DrawnNode | null) => void {
357
+ let ref = this._refs.get(el);
358
+ if (!ref) {
359
+ ref = (node) => {
360
+ if (node) this._boxes.set(el, node);
361
+ else if (this._boxes.get(el)) this._boxes.delete(el);
362
+ };
363
+ this._refs.set(el, ref);
364
+ }
365
+ return ref;
366
+ }
367
+
368
+ /** Focus `el`'s widget: the first node in its box that takes the focus. */
369
+ focus(el: Element): boolean {
370
+ const box = this._boxes.get(el);
371
+ if (!box) return false;
372
+ const stack: DrawnNode[] = [box];
373
+ while (stack.length) {
374
+ const node = stack.shift()!;
375
+ if (takesFocus(node)) {
376
+ node.focus();
377
+ return true;
378
+ }
379
+ stack.unshift(...(node.children as DrawnNode[]));
380
+ }
381
+ return false;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Whether a node takes the focus: core's rule, which it keeps to itself
387
+ * (`isFocusable` in its a11y.js) — `focusable` or a `tabIndex` where the
388
+ * props say, and else the element's own default (a `<textinput>`'s) or a
389
+ * selectable surface, and never when disabled. `focus()` itself focuses
390
+ * any node it is asked to, the box a widget sits in included.
391
+ */
392
+ function takesFocus(node: DrawnNode): boolean {
393
+ const { props, focusableByDefault } = node as unknown as {
394
+ props: Record<string, unknown>;
395
+ focusableByDefault?: boolean;
396
+ };
397
+ if (props.disabled) return false;
398
+ if (typeof props.focusable === 'boolean') return props.focusable;
399
+ if (props.tabIndex != null) return true;
400
+ return (focusableByDefault ?? false) || props.selectable === true;
401
+ }
402
+
403
+ // --- the widgets ------------------------------------------------------------
404
+
405
+ /** What every mounted control shares: the live state, and what to do when
406
+ * one changes or is pressed. */
407
+ interface ControlContext {
408
+ forms: FormState;
409
+ /** How many resets there have been — in every widget's key, so a reset
410
+ * mounts each again with the value its markup has. */
411
+ generation: number;
412
+ widgets: WidgetBoxes;
413
+ changed: (el: Element, value: string | boolean, restyle: boolean) => void;
414
+ setChecked: (el: Element, checked: boolean) => void;
415
+ checkRadio: (el: Element) => void;
416
+ /** A button was pressed: what it does to its form. */
417
+ press: (button: Element) => void;
418
+ /** Enter in a text field: its form's implicit submission. */
419
+ submitFrom: (field: Element) => void;
420
+ }
421
+
422
+ /** One form control, as a real widget at the rectangle layout reserved for
423
+ * it. */
424
+ function renderControl(
425
+ rect: ControlRect,
426
+ look: RootLook,
427
+ ctx: ControlContext,
428
+ ): ReactNode {
429
+ const { forms } = ctx;
430
+ const el = rect.element;
431
+ const key = `${rect.kind}:${ctx.widgets.idOf(el)}#${ctx.generation}`;
432
+ const disabled = isDisabled(el);
433
+ const readOnly = attr(el, 'readonly') !== undefined;
434
+ // a field whose box the document draws takes its content box
435
+ const at = rect.bare ?? rect;
436
+ const frame: Style = {
437
+ position: 'absolute',
438
+ left: Math.round(at.x),
439
+ top: Math.round(at.y),
440
+ width: Math.round(at.width),
441
+ height: Math.round(at.height),
442
+ // The face and the size the box was measured in, the element's: the
443
+ // palette's from the UA sheet, or the page's where it set its own. A
444
+ // field inherits them, and so does a `<Button>`'s or a `<Select>`'s
445
+ // caption — named here because the text cascade takes the palette's
446
+ // from the window, and a provider inside it that names a face or a size
447
+ // reaches `useTheme` and not the cascade.
448
+ fontFamily: rect.fontFamily,
449
+ fontSize: rect.fontSize,
450
+ // core's `opacity` is CSS's: the widget faded as a group, and at 0 not
451
+ // drawn and still hit
452
+ ...(rect.opacity !== undefined && { opacity: rect.opacity }),
453
+ };
454
+ const field = rect.bare ? bareField(rect.bare) : fieldChrome(look);
455
+ // A button or a select whose font the page set is the drawn control: a
456
+ // native bezel sets its title at AppKit's size, whatever it is handed,
457
+ // in a bezel only as tall as that title, where the box was measured for
458
+ // the page's.
459
+ const ownFont = !paletteFont(rect, look);
460
+ // A text edit does NOT restyle: the value lives in the widget and in
461
+ // `forms`, and neither changes any box — while a restyle would re-run
462
+ // the cascade and relayout the whole document *per keystroke*. This also
463
+ // matches HTML's own semantics: typing updates the value, not the
464
+ // attribute selectors match against. The checkables do restyle, because
465
+ // `:checked` is a selector documents really use.
466
+ const typed = (value: string) => {
467
+ forms.setTyped(el, value);
468
+ ctx.changed(el, value, false);
469
+ };
470
+
471
+ let widget: ReactNode;
472
+ switch (rect.kind) {
473
+ case 'checkbox':
474
+ widget = h(Checkbox, {
475
+ checked: attr(el, 'checked') !== undefined,
476
+ disabled,
477
+ // the element's whole box takes the press, as it does in a browser:
478
+ // a page that sizes one over its label, invisible, means the label
479
+ style: { width: '100%', height: '100%' },
480
+ onChange: (ev) => ctx.setChecked(el, ev.value),
481
+ });
482
+ break;
483
+ case 'radio': {
484
+ const value = attr(el, 'value') ?? 'on';
485
+ widget = h(
486
+ RadioGroup,
487
+ {
488
+ value: attr(el, 'checked') !== undefined ? value : undefined,
489
+ onChange: () => ctx.checkRadio(el),
490
+ },
491
+ h(Radio, { key: 'r', value, disabled }),
492
+ );
493
+ break;
494
+ }
495
+ case 'button':
496
+ widget = h(Button, {
497
+ label: buttonLabel(el),
498
+ disabled,
499
+ ...(ownFont && { native: false }),
500
+ style: { width: '100%', height: '100%' },
501
+ onPress: () => ctx.press(el),
502
+ });
503
+ break;
504
+ case 'select': {
505
+ const options = optionsOf(el);
506
+ widget = h(Select, {
507
+ options: options.map((o) => ({ value: o.value, label: o.label })),
508
+ value: selectedOption(el) ?? undefined,
509
+ disabled,
510
+ style: rect.bare
511
+ ? [BARE_TRIGGER, { width: '100%', height: '100%' }]
512
+ : { width: '100%', height: '100%' },
513
+ // the slots choose the drawn trigger on every backend, and put the
514
+ // caption and the arrow in the page's ink
515
+ ...(rect.bare && {
516
+ labelStyle: {
517
+ color: rect.bare.color,
518
+ fontFamily: rect.fontFamily,
519
+ fontSize: rect.fontSize,
520
+ },
521
+ chevronStyle: rect.bare.chevron
522
+ ? { color: rect.bare.color }
523
+ : { display: 'none' },
524
+ }),
525
+ // and so does a caption in the page's font, whose size the chevron
526
+ // is read back from: it is as tall as the capitals beside it
527
+ ...(!rect.bare &&
528
+ ownFont && {
529
+ labelStyle: {
530
+ fontFamily: rect.fontFamily,
531
+ fontSize: rect.fontSize,
532
+ },
533
+ }),
534
+ onChange: (ev) => {
535
+ const next = String(ev.value ?? '');
536
+ forms.remember(el);
537
+ setSelectedOption(el, next);
538
+ ctx.changed(el, next, true);
539
+ },
540
+ });
541
+ break;
542
+ }
543
+ case 'textarea':
544
+ // Uncontrolled on purpose: the widget owns the live text the way a
545
+ // browser's does, and one mounted again — its element hidden and
546
+ // shown, a reset — starts from what `forms` kept of it.
547
+ widget = hx('textarea', {
548
+ defaultValue: forms.value(el),
549
+ maxLength: maxLength(el),
550
+ style: [field, { width: '100%', height: '100%' }],
551
+ onChange: readOnly ? undefined : (ev) => typed(ev.value),
552
+ });
553
+ break;
554
+ case 'input': {
555
+ const type = inputType(el);
556
+ widget = hx('textinput', {
557
+ defaultValue: forms.value(el),
558
+ placeholder: attr(el, 'placeholder'),
559
+ maxLength: maxLength(el),
560
+ // Core's word for a password field: nothing in it reaches a
561
+ // selection, PRIMARY included.
562
+ sensitive: type === 'password',
563
+ style: [field, { width: '100%', height: '100%' }],
564
+ onChange: readOnly
565
+ ? undefined
566
+ : (ev) => {
567
+ typed(ev.value);
568
+ // echoed where it always was, for a handler that reads it
569
+ // back off the element — after `typed`, which keeps what the
570
+ // attribute said before for a reset
571
+ el.attribs.value = ev.value;
572
+ },
573
+ // Enter submits the field's form, as it does in a browser
574
+ onSubmit: () => ctx.submitFrom(el),
575
+ });
576
+ break;
577
+ }
578
+ default:
579
+ return null;
580
+ }
581
+ return hx(
582
+ 'box',
583
+ {
584
+ key,
585
+ ref: ctx.widgets.refOf(el),
586
+ style: frame,
587
+ selectable: false,
588
+ },
589
+ widget,
590
+ );
591
+ }
592
+
593
+ /** A field's `maxlength`, which the widget enforces as it is typed into. */
594
+ function maxLength(el: Element): number | undefined {
595
+ const raw = attr(el, 'maxlength')?.trim();
596
+ return raw && /^\d+$/.test(raw) ? Number(raw) : undefined;
597
+ }
598
+
599
+ /**
600
+ * Why a form did not submit, under the control that stopped it — a
601
+ * browser's validation bubble, in the palette's surface and border. Where
602
+ * the control has no widget (a checkbox the page hid and drew its own), it
603
+ * goes under the element's box; where it has neither, it is not shown, and
604
+ * the form still does not submit, as in a browser.
605
+ */
606
+ function renderMessage(
607
+ invalid: { element: Element; message: string },
608
+ rects: readonly ControlRect[],
609
+ view: HtmlViewNode | null,
610
+ look: RootLook,
611
+ ): ReactNode {
612
+ const rect =
613
+ rects.find((r) => r.element === invalid.element) ??
614
+ view?.elementRect(invalid.element) ??
615
+ null;
616
+ if (!rect) return null;
617
+ return hx(
618
+ 'box',
619
+ {
620
+ key: 'form-message',
621
+ selectable: false,
622
+ style: {
623
+ position: 'absolute',
624
+ left: Math.round(rect.x),
625
+ top: Math.round(rect.y + rect.height + 4),
626
+ maxWidth: 320,
627
+ paddingLeft: 10,
628
+ paddingRight: 10,
629
+ paddingTop: 6,
630
+ paddingBottom: 6,
631
+ backgroundColor: look.surface,
632
+ borderWidth: look.controlBorder,
633
+ borderColor: look.borderColor,
634
+ borderRadius: look.controlRadius,
635
+ zIndex: 1,
636
+ },
637
+ },
638
+ hx(
639
+ 'text',
640
+ {
641
+ style: {
642
+ color: look.color,
643
+ fontFamily: look.fontFamily,
644
+ fontSize: Math.round(look.fontSize * 0.9),
645
+ },
646
+ },
647
+ invalid.message,
648
+ ),
649
+ );
650
+ }
651
+
652
+ /**
653
+ * The chrome a text field needs.
654
+ *
655
+ * `<textinput>` and `<textarea>` are core *elements* rather than components,
656
+ * so they draw no frame of their own — an application supplies one, which is
657
+ * why core's own `<Button>` and `<Select>` are components and these are not.
658
+ * The values are the palette's, so a field in a document and a `<Select>`
659
+ * beside it are the same height with the same corner and the same edge.
660
+ * Its text is in the face and at the size of the frame around it, which
661
+ * are the element's (`renderControl`).
662
+ */
663
+ function fieldChrome(look: RootLook): Style {
664
+ return {
665
+ backgroundColor: look.surface,
666
+ borderWidth: look.controlBorder,
667
+ borderColor: look.borderColor,
668
+ borderRadius: look.controlRadius,
669
+ paddingLeft: 6,
670
+ paddingRight: 6,
671
+ color: look.color,
672
+ };
673
+ }
674
+
675
+ /**
676
+ * A text field whose box the author styled: the document draws the border
677
+ * and the background, so the widget draws neither, and its text is the
678
+ * element's colour, which the author chose to go on that background, rather
679
+ * than the theme's. Its face and size are the frame's, as every field's are.
680
+ */
681
+ function bareField(bare: BareField): Style {
682
+ return {
683
+ backgroundColor: 'transparent',
684
+ borderWidth: 0,
685
+ borderRadius: 0,
686
+ paddingLeft: 0,
687
+ paddingRight: 0,
688
+ color: bare.color,
689
+ };
690
+ }
691
+
692
+ /**
693
+ * Whether a control is set in the face and at the size the UA sheet gives
694
+ * a button or a select, the palette's: the page left its font alone. The
695
+ * size is compared loosely, since the rect's is a device size divided back
696
+ * by the scale.
697
+ */
698
+ function paletteFont(rect: ControlRect, look: RootLook): boolean {
699
+ return (
700
+ rect.fontFamily === (look.controlFontFamily ?? look.fontFamily) &&
701
+ Math.abs(rect.fontSize - (look.controlFontSize ?? look.fontSize)) < 0.01
702
+ );
703
+ }
704
+
705
+ /**
706
+ * The trigger of a `<select>` whose box the page styled: no frame, no fill
707
+ * and none of its own insets, and no wash under the pointer, since the box
708
+ * it would tint is the document's. Core's focus ring still marks it for the
709
+ * keyboard.
710
+ */
711
+ const BARE_TRIGGER: Style = {
712
+ paddingTop: 0,
713
+ paddingBottom: 0,
714
+ paddingLeft: 0,
715
+ paddingRight: 0,
716
+ borderWidth: 0,
717
+ borderRadius: 0,
718
+ backgroundColor: 'transparent',
719
+ ':hover': { backgroundColor: 'transparent' },
720
+ ':active': { backgroundColor: 'transparent' },
721
+ };
722
+
723
+ /** Select the option of a drop-down `<select>` whose value is `value`, and
724
+ * no other. */
725
+ function setSelectedOption(el: Element, value: string): void {
726
+ let found = false;
727
+ for (const option of optionElements(el)) {
728
+ if (!found && optionValue(option) === value) {
729
+ option.attribs.selected = '';
730
+ found = true;
731
+ } else {
732
+ delete option.attribs.selected;
733
+ }
734
+ }
735
+ }