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