@react-x11/components 0.12.0 → 0.14.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 (150) hide show
  1. package/dist/html/controls.d.ts +41 -5
  2. package/dist/html/controls.d.ts.map +1 -1
  3. package/dist/html/controls.js +85 -57
  4. package/dist/html/controls.js.map +1 -1
  5. package/dist/html/css/cascade.d.ts +134 -20
  6. package/dist/html/css/cascade.d.ts.map +1 -1
  7. package/dist/html/css/cascade.js +545 -126
  8. package/dist/html/css/cascade.js.map +1 -1
  9. package/dist/html/css/color.d.ts +18 -0
  10. package/dist/html/css/color.d.ts.map +1 -1
  11. package/dist/html/css/color.js +27 -0
  12. package/dist/html/css/color.js.map +1 -1
  13. package/dist/html/css/parse.d.ts +47 -6
  14. package/dist/html/css/parse.d.ts.map +1 -1
  15. package/dist/html/css/parse.js +336 -30
  16. package/dist/html/css/parse.js.map +1 -1
  17. package/dist/html/css/style.d.ts +142 -15
  18. package/dist/html/css/style.d.ts.map +1 -1
  19. package/dist/html/css/style.js +563 -147
  20. package/dist/html/css/style.js.map +1 -1
  21. package/dist/html/css/transform.d.ts +85 -0
  22. package/dist/html/css/transform.d.ts.map +1 -0
  23. package/dist/html/css/transform.js +425 -0
  24. package/dist/html/css/transform.js.map +1 -0
  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 -9
  28. package/dist/html/css/ua.js.map +1 -1
  29. package/dist/html/css/values.d.ts +4 -0
  30. package/dist/html/css/values.d.ts.map +1 -1
  31. package/dist/html/css/values.js.map +1 -1
  32. package/dist/html/dom.d.ts.map +1 -1
  33. package/dist/html/dom.js +3 -1
  34. package/dist/html/dom.js.map +1 -1
  35. package/dist/html/fonts.d.ts +78 -4
  36. package/dist/html/fonts.d.ts.map +1 -1
  37. package/dist/html/fonts.js +373 -19
  38. package/dist/html/fonts.js.map +1 -1
  39. package/dist/html/form.d.ts +200 -0
  40. package/dist/html/form.d.ts.map +1 -0
  41. package/dist/html/form.js +801 -0
  42. package/dist/html/form.js.map +1 -0
  43. package/dist/html/index.d.ts +12 -0
  44. package/dist/html/index.d.ts.map +1 -1
  45. package/dist/html/index.js +30 -301
  46. package/dist/html/index.js.map +1 -1
  47. package/dist/html/layout/axes.d.ts +14 -0
  48. package/dist/html/layout/axes.d.ts.map +1 -0
  49. package/dist/html/layout/axes.js +28 -0
  50. package/dist/html/layout/axes.js.map +1 -0
  51. package/dist/html/layout/block.d.ts +93 -12
  52. package/dist/html/layout/block.d.ts.map +1 -1
  53. package/dist/html/layout/block.js +393 -39
  54. package/dist/html/layout/block.js.map +1 -1
  55. package/dist/html/layout/boxes.d.ts +90 -2
  56. package/dist/html/layout/boxes.d.ts.map +1 -1
  57. package/dist/html/layout/boxes.js +74 -10
  58. package/dist/html/layout/boxes.js.map +1 -1
  59. package/dist/html/layout/cache.d.ts +1 -1
  60. package/dist/html/layout/cache.d.ts.map +1 -1
  61. package/dist/html/layout/cache.js +6 -0
  62. package/dist/html/layout/cache.js.map +1 -1
  63. package/dist/html/layout/css-grid.d.ts.map +1 -1
  64. package/dist/html/layout/css-grid.js +90 -18
  65. package/dist/html/layout/css-grid.js.map +1 -1
  66. package/dist/html/layout/flex.d.ts.map +1 -1
  67. package/dist/html/layout/flex.js +574 -84
  68. package/dist/html/layout/flex.js.map +1 -1
  69. package/dist/html/layout/inline.d.ts +3 -0
  70. package/dist/html/layout/inline.d.ts.map +1 -1
  71. package/dist/html/layout/inline.js +154 -54
  72. package/dist/html/layout/inline.js.map +1 -1
  73. package/dist/html/layout/multicol.d.ts +42 -0
  74. package/dist/html/layout/multicol.d.ts.map +1 -0
  75. package/dist/html/layout/multicol.js +897 -0
  76. package/dist/html/layout/multicol.js.map +1 -0
  77. package/dist/html/layout/shaping.d.ts +7 -0
  78. package/dist/html/layout/shaping.d.ts.map +1 -1
  79. package/dist/html/layout/shaping.js +25 -20
  80. package/dist/html/layout/shaping.js.map +1 -1
  81. package/dist/html/layout/table.d.ts.map +1 -1
  82. package/dist/html/layout/table.js +62 -18
  83. package/dist/html/layout/table.js.map +1 -1
  84. package/dist/html/node.d.ts +90 -15
  85. package/dist/html/node.d.ts.map +1 -1
  86. package/dist/html/node.js +771 -186
  87. package/dist/html/node.js.map +1 -1
  88. package/dist/html/paint.d.ts +101 -5
  89. package/dist/html/paint.d.ts.map +1 -1
  90. package/dist/html/paint.js +1502 -160
  91. package/dist/html/paint.js.map +1 -1
  92. package/dist/html/svg.d.ts +15 -2
  93. package/dist/html/svg.d.ts.map +1 -1
  94. package/dist/html/svg.js +249 -17
  95. package/dist/html/svg.js.map +1 -1
  96. package/dist/html/widgets.d.ts +29 -0
  97. package/dist/html/widgets.d.ts.map +1 -0
  98. package/dist/html/widgets.js +700 -0
  99. package/dist/html/widgets.js.map +1 -0
  100. package/dist/html/woff2.d.ts +14 -0
  101. package/dist/html/woff2.d.ts.map +1 -0
  102. package/dist/html/woff2.js +608 -0
  103. package/dist/html/woff2.js.map +1 -0
  104. package/dist/index.d.ts +1 -1
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/maps/controller.d.ts +17 -1
  108. package/dist/maps/controller.d.ts.map +1 -1
  109. package/dist/maps/controller.js +27 -7
  110. package/dist/maps/controller.js.map +1 -1
  111. package/dist/richtext/node.d.ts +8 -1
  112. package/dist/richtext/node.d.ts.map +1 -1
  113. package/dist/richtext/node.js +5 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/richtext/runs.d.ts +25 -6
  116. package/dist/richtext/runs.d.ts.map +1 -1
  117. package/dist/richtext/runs.js +240 -24
  118. package/dist/richtext/runs.js.map +1 -1
  119. package/package.json +2 -2
  120. package/src/html/controls.ts +114 -51
  121. package/src/html/css/cascade.ts +614 -140
  122. package/src/html/css/color.ts +29 -0
  123. package/src/html/css/parse.ts +388 -33
  124. package/src/html/css/style.ts +688 -150
  125. package/src/html/css/transform.ts +459 -0
  126. package/src/html/css/ua.ts +77 -10
  127. package/src/html/css/values.ts +4 -0
  128. package/src/html/dom.ts +3 -1
  129. package/src/html/fonts.ts +417 -24
  130. package/src/html/form.ts +962 -0
  131. package/src/html/index.ts +49 -338
  132. package/src/html/layout/axes.ts +62 -0
  133. package/src/html/layout/block.ts +447 -37
  134. package/src/html/layout/boxes.ts +143 -14
  135. package/src/html/layout/cache.ts +6 -0
  136. package/src/html/layout/css-grid.ts +95 -22
  137. package/src/html/layout/flex.ts +675 -78
  138. package/src/html/layout/inline.ts +159 -55
  139. package/src/html/layout/multicol.ts +977 -0
  140. package/src/html/layout/shaping.ts +27 -18
  141. package/src/html/layout/table.ts +62 -19
  142. package/src/html/node.ts +798 -172
  143. package/src/html/paint.ts +1742 -241
  144. package/src/html/svg.ts +277 -19
  145. package/src/html/widgets.ts +821 -0
  146. package/src/html/woff2.ts +612 -0
  147. package/src/index.ts +1 -0
  148. package/src/maps/controller.ts +28 -7
  149. package/src/richtext/node.ts +13 -2
  150. package/src/richtext/runs.ts +313 -35
@@ -0,0 +1,821 @@
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, isElement, 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 { between, 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 left = Math.round(at.x);
437
+ const top = Math.round(at.y);
438
+ const width = Math.round(at.width);
439
+ const height = Math.round(at.height);
440
+ // What the widget shows through: its own rectangle, or where the
441
+ // document cuts the element, what the cut leaves of it — out to where a
442
+ // focus ring reaches, which a clip around the element cuts as it cuts
443
+ // the element, and one that leaves the box whole does not.
444
+ const port = rect.clip
445
+ ? between(rect.clip, {
446
+ x: left - RING_REACH,
447
+ y: top - RING_REACH,
448
+ width: width + 2 * RING_REACH,
449
+ height: height + 2 * RING_REACH,
450
+ })
451
+ : { x: left, y: top, width, height };
452
+ const frame: Style = {
453
+ position: 'absolute',
454
+ left: left - port.x,
455
+ top: top - port.y,
456
+ width,
457
+ height,
458
+ // The face and the size the box was measured in, the element's: the
459
+ // palette's from the UA sheet, or the page's where it set its own. A
460
+ // field inherits them, and so does a `<Button>`'s or a `<Select>`'s
461
+ // caption — named here because the text cascade takes the palette's
462
+ // from the window, and a provider inside it that names a face or a size
463
+ // reaches `useTheme` and not the cascade.
464
+ fontFamily: rect.fontFamily,
465
+ fontSize: rect.fontSize,
466
+ // core's `opacity` is CSS's: the widget faded as a group, and at 0 not
467
+ // drawn and still hit
468
+ ...(rect.opacity !== undefined && { opacity: rect.opacity }),
469
+ };
470
+ const field = rect.bare ? bareField(rect.bare) : fieldChrome(look);
471
+ const order = tabOrder(el);
472
+ // A button or a select whose font the page set is the drawn control: a
473
+ // native bezel sets its title at AppKit's size, whatever it is handed,
474
+ // in a bezel only as tall as that title, where the box was measured for
475
+ // the page's.
476
+ const ownFont = !paletteFont(rect, look);
477
+ // A text edit does NOT restyle: the value lives in the widget and in
478
+ // `forms`, and neither changes any box — while a restyle would re-run
479
+ // the cascade and relayout the whole document *per keystroke*. This also
480
+ // matches HTML's own semantics: typing updates the value, not the
481
+ // attribute selectors match against. The checkables do restyle, because
482
+ // `:checked` is a selector documents really use.
483
+ const typed = (value: string) => {
484
+ forms.setTyped(el, value);
485
+ ctx.changed(el, value, false);
486
+ };
487
+
488
+ let widget: ReactNode;
489
+ switch (rect.kind) {
490
+ case 'checkbox':
491
+ widget = h(Checkbox, {
492
+ checked: attr(el, 'checked') !== undefined,
493
+ disabled,
494
+ ...order,
495
+ // the element's whole box takes the press, as it does in a browser:
496
+ // a page that sizes one over its label, invisible, means the label
497
+ style: { width: '100%', height: '100%' },
498
+ onChange: (ev) => ctx.setChecked(el, ev.value),
499
+ });
500
+ break;
501
+ case 'radio': {
502
+ const value = attr(el, 'value') ?? 'on';
503
+ // No `tabindex` reaches a radio: core's `<Radio>` takes no props for
504
+ // the node it draws, as the other widgets do, and the group's box is
505
+ // not the one that takes the focus.
506
+ widget = h(
507
+ RadioGroup,
508
+ {
509
+ value: attr(el, 'checked') !== undefined ? value : undefined,
510
+ onChange: () => ctx.checkRadio(el),
511
+ },
512
+ h(Radio, { key: 'r', value, disabled }),
513
+ );
514
+ break;
515
+ }
516
+ case 'button':
517
+ widget = h(Button, {
518
+ label: buttonLabel(el),
519
+ disabled,
520
+ ...order,
521
+ ...(ownFont && { native: false }),
522
+ style: { width: '100%', height: '100%' },
523
+ onPress: () => ctx.press(el),
524
+ });
525
+ break;
526
+ case 'select': {
527
+ const options = optionsOf(el);
528
+ widget = h(Select, {
529
+ options: options.map((o) => ({ value: o.value, label: o.label })),
530
+ value: selectedOption(el) ?? undefined,
531
+ // a `<select>` with nothing selected has no options, and shows
532
+ // none: core's "Select…" is an application's prompt, not a page's
533
+ placeholder: '',
534
+ disabled,
535
+ ...order,
536
+ style: rect.bare
537
+ ? [BARE_TRIGGER, { width: '100%', height: '100%' }]
538
+ : { width: '100%', height: '100%' },
539
+ // the slots choose the drawn trigger on every backend, and put the
540
+ // caption and the arrow in the page's ink
541
+ ...(rect.bare && {
542
+ labelStyle: {
543
+ color: rect.bare.color,
544
+ fontFamily: rect.fontFamily,
545
+ fontSize: rect.fontSize,
546
+ },
547
+ chevronStyle: rect.bare.chevron
548
+ ? { color: rect.bare.color }
549
+ : { display: 'none' },
550
+ }),
551
+ // and so does a caption in the page's font, whose size the chevron
552
+ // is read back from: it is as tall as the capitals beside it
553
+ ...(!rect.bare &&
554
+ ownFont && {
555
+ labelStyle: {
556
+ fontFamily: rect.fontFamily,
557
+ fontSize: rect.fontSize,
558
+ },
559
+ }),
560
+ onChange: (ev) => {
561
+ const next = String(ev.value ?? '');
562
+ forms.remember(el);
563
+ setSelectedOption(el, next);
564
+ ctx.changed(el, next, true);
565
+ },
566
+ });
567
+ break;
568
+ }
569
+ case 'textarea':
570
+ // Uncontrolled on purpose: the widget owns the live text the way a
571
+ // browser's does, and one mounted again — its element hidden and
572
+ // shown, a reset — starts from what `forms` kept of it.
573
+ widget = hx('textarea', {
574
+ defaultValue: forms.value(el),
575
+ maxLength: maxLength(el),
576
+ ...order,
577
+ style: [field, { width: '100%', height: '100%' }],
578
+ onChange: readOnly ? undefined : (ev) => typed(ev.value),
579
+ });
580
+ break;
581
+ case 'input': {
582
+ const type = inputType(el);
583
+ widget = hx('textinput', {
584
+ defaultValue: forms.value(el),
585
+ placeholder: attr(el, 'placeholder'),
586
+ maxLength: maxLength(el),
587
+ ...order,
588
+ // Core's word for a password field: nothing in it reaches a
589
+ // selection, PRIMARY included.
590
+ sensitive: type === 'password',
591
+ style: [field, { width: '100%', height: '100%' }],
592
+ onChange: readOnly
593
+ ? undefined
594
+ : (ev) => {
595
+ typed(ev.value);
596
+ // echoed where it always was, for a handler that reads it
597
+ // back off the element — after `typed`, which keeps what the
598
+ // attribute said before for a reset
599
+ el.attribs.value = ev.value;
600
+ },
601
+ // Enter submits the field's form, as it does in a browser
602
+ onSubmit: () => ctx.submitFrom(el),
603
+ });
604
+ break;
605
+ }
606
+ default:
607
+ return null;
608
+ }
609
+ // Two boxes, always: the one the widget shows through, and in it the
610
+ // element's. A widget the document comes to cut, or stops cutting, is
611
+ // the same widget in the same place in the tree, and keeps its focus
612
+ // and its caret.
613
+ return hx(
614
+ 'box',
615
+ {
616
+ key,
617
+ selectable: false,
618
+ style: {
619
+ position: 'absolute',
620
+ left: port.x,
621
+ top: port.y,
622
+ width: port.width,
623
+ height: port.height,
624
+ // cut to it, and nothing itself: a press beside the widget is the
625
+ // document's
626
+ ...(rect.clip && { overflow: 'hidden', pointerEvents: 'box-none' }),
627
+ },
628
+ },
629
+ hx(
630
+ 'box',
631
+ {
632
+ ref: ctx.widgets.refOf(el),
633
+ style: frame,
634
+ selectable: false,
635
+ // a control the page keeps from assistive technology is kept from
636
+ // it here: core leaves the node, and the widget in it, out of the
637
+ // accessibility tree
638
+ ...(ariaHidden(el) && { 'aria-hidden': true }),
639
+ },
640
+ widget,
641
+ ),
642
+ );
643
+ }
644
+
645
+ /**
646
+ * What an element's `tabindex` says of its widget's place in the Tab order
647
+ * (HTML 6.6.3): a negative one is focusable — by a press, by its label, by
648
+ * `autofocus` — and not reached by Tab, which is core's `tabIndex={-1}`
649
+ * too. Radix lays a native `<select tabindex="-1" aria-hidden="true">`
650
+ * beside the picker it draws, cut to nothing: it was a stop the eye could
651
+ * not find, and Space on it opened an empty menu.
652
+ *
653
+ * Nothing else is handed over. Zero is where a control already is, and a
654
+ * positive one would put a page's control ahead of the application's own,
655
+ * whose window it is: the order between a document and what is around it
656
+ * is not the document's to set.
657
+ */
658
+ function tabOrder(el: Element): { tabIndex: -1 } | undefined {
659
+ // the rules for parsing integers (HTML 2.3.4.1): white space, a sign,
660
+ // digits, and whatever follows them ignored
661
+ const m = /^[ \t\n\f\r]*([+-]?\d+)/.exec(attr(el, 'tabindex') ?? '');
662
+ return m && Number(m[1]) < 0 ? { tabIndex: -1 } : undefined;
663
+ }
664
+
665
+ /** Whether an element is hidden from assistive technology: `aria-hidden`
666
+ * is true on it or on an element around it (WAI-ARIA 1.2, 6.6). */
667
+ function ariaHidden(el: Element): boolean {
668
+ for (let at: Element | null = el; at;) {
669
+ if (attr(at, 'aria-hidden')?.trim().toLowerCase() === 'true') return true;
670
+ const parent: Element['parent'] = at.parent;
671
+ at = isElement(parent) ? parent : null;
672
+ }
673
+ return false;
674
+ }
675
+
676
+ /** How far past its box a widget's focus ring is drawn, and then some. */
677
+ const RING_REACH = 8;
678
+
679
+ /** A field's `maxlength`, which the widget enforces as it is typed into. */
680
+ function maxLength(el: Element): number | undefined {
681
+ const raw = attr(el, 'maxlength')?.trim();
682
+ return raw && /^\d+$/.test(raw) ? Number(raw) : undefined;
683
+ }
684
+
685
+ /**
686
+ * Why a form did not submit, under the control that stopped it — a
687
+ * browser's validation bubble, in the palette's surface and border. Where
688
+ * the control has no widget (a checkbox the page hid and drew its own), it
689
+ * goes under the element's box; where it has neither, it is not shown, and
690
+ * the form still does not submit, as in a browser.
691
+ */
692
+ function renderMessage(
693
+ invalid: { element: Element; message: string },
694
+ rects: readonly ControlRect[],
695
+ view: HtmlViewNode | null,
696
+ look: RootLook,
697
+ ): ReactNode {
698
+ const rect =
699
+ rects.find((r) => r.element === invalid.element) ??
700
+ view?.elementRect(invalid.element) ??
701
+ null;
702
+ if (!rect) return null;
703
+ return hx(
704
+ 'box',
705
+ {
706
+ key: 'form-message',
707
+ selectable: false,
708
+ style: {
709
+ position: 'absolute',
710
+ left: Math.round(rect.x),
711
+ top: Math.round(rect.y + rect.height + 4),
712
+ maxWidth: 320,
713
+ paddingLeft: 10,
714
+ paddingRight: 10,
715
+ paddingTop: 6,
716
+ paddingBottom: 6,
717
+ backgroundColor: look.surface,
718
+ borderWidth: look.controlBorder,
719
+ borderColor: look.borderColor,
720
+ borderRadius: look.controlRadius,
721
+ zIndex: 1,
722
+ },
723
+ },
724
+ hx(
725
+ 'text',
726
+ {
727
+ style: {
728
+ color: look.color,
729
+ fontFamily: look.fontFamily,
730
+ fontSize: Math.round(look.fontSize * 0.9),
731
+ },
732
+ },
733
+ invalid.message,
734
+ ),
735
+ );
736
+ }
737
+
738
+ /**
739
+ * The chrome a text field needs.
740
+ *
741
+ * `<textinput>` and `<textarea>` are core *elements* rather than components,
742
+ * so they draw no frame of their own — an application supplies one, which is
743
+ * why core's own `<Button>` and `<Select>` are components and these are not.
744
+ * The values are the palette's, so a field in a document and a `<Select>`
745
+ * beside it are the same height with the same corner and the same edge.
746
+ * Its text is in the face and at the size of the frame around it, which
747
+ * are the element's (`renderControl`).
748
+ */
749
+ function fieldChrome(look: RootLook): Style {
750
+ return {
751
+ backgroundColor: look.surface,
752
+ borderWidth: look.controlBorder,
753
+ borderColor: look.borderColor,
754
+ borderRadius: look.controlRadius,
755
+ paddingLeft: 6,
756
+ paddingRight: 6,
757
+ color: look.color,
758
+ };
759
+ }
760
+
761
+ /**
762
+ * A text field whose box the author styled: the document draws the border
763
+ * and the background, so the widget draws neither, and its text is the
764
+ * element's colour, which the author chose to go on that background, rather
765
+ * than the theme's. Its face and size are the frame's, as every field's are.
766
+ */
767
+ function bareField(bare: BareField): Style {
768
+ return {
769
+ backgroundColor: 'transparent',
770
+ borderWidth: 0,
771
+ borderRadius: 0,
772
+ paddingLeft: 0,
773
+ paddingRight: 0,
774
+ color: bare.color,
775
+ };
776
+ }
777
+
778
+ /**
779
+ * Whether a control is set in the face and at the size the UA sheet gives
780
+ * a button or a select, the palette's: the page left its font alone. The
781
+ * size is compared loosely, since the rect's is a device size divided back
782
+ * by the scale.
783
+ */
784
+ function paletteFont(rect: ControlRect, look: RootLook): boolean {
785
+ return (
786
+ rect.fontFamily === (look.controlFontFamily ?? look.fontFamily) &&
787
+ Math.abs(rect.fontSize - (look.controlFontSize ?? look.fontSize)) < 0.01
788
+ );
789
+ }
790
+
791
+ /**
792
+ * The trigger of a `<select>` whose box the page styled: no frame, no fill
793
+ * and none of its own insets, and no wash under the pointer, since the box
794
+ * it would tint is the document's. Core's focus ring still marks it for the
795
+ * keyboard.
796
+ */
797
+ const BARE_TRIGGER: Style = {
798
+ paddingTop: 0,
799
+ paddingBottom: 0,
800
+ paddingLeft: 0,
801
+ paddingRight: 0,
802
+ borderWidth: 0,
803
+ borderRadius: 0,
804
+ backgroundColor: 'transparent',
805
+ ':hover': { backgroundColor: 'transparent' },
806
+ ':active': { backgroundColor: 'transparent' },
807
+ };
808
+
809
+ /** Select the option of a drop-down `<select>` whose value is `value`, and
810
+ * no other. */
811
+ function setSelectedOption(el: Element, value: string): void {
812
+ let found = false;
813
+ for (const option of optionElements(el)) {
814
+ if (!found && optionValue(option) === value) {
815
+ option.attribs.selected = '';
816
+ found = true;
817
+ } else {
818
+ delete option.attribs.selected;
819
+ }
820
+ }
821
+ }