@uniflowed/react-testing 0.0.0-alpha.10
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/index.js +32 -0
- package/internal/dom.js +312 -0
- package/internal/events.js +503 -0
- package/internal/queries.js +635 -0
- package/internal/render.js +298 -0
- package/internal/screen.js +294 -0
- package/package.json +29 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Making things happen to the page.
|
|
4
|
+
//
|
|
5
|
+
// Two layers, because tests want two different things.
|
|
6
|
+
//
|
|
7
|
+
// `fireEvent` dispatches one event. It is the right tool when the test is
|
|
8
|
+
// about the handler: "clicking calls onSelect once".
|
|
9
|
+
//
|
|
10
|
+
// `userEvent` performs what a person did, which is almost never one event.
|
|
11
|
+
// Clicking a button is pointerdown, mousedown, focus, pointerup, mouseup and
|
|
12
|
+
// click; typing is a keydown, an input and a keyup per character, with the
|
|
13
|
+
// value updated in between. A component that listens for `mousedown` — a menu
|
|
14
|
+
// that closes on outside press, say — behaves correctly under a real click and
|
|
15
|
+
// not at all under a bare `click` event, and a test that only fires `click`
|
|
16
|
+
// would pass while the feature was broken.
|
|
17
|
+
|
|
18
|
+
import { bodyOf, documentOf } from "./dom.js";
|
|
19
|
+
import { displayValue } from "./queries.js";
|
|
20
|
+
import { actively } from "./render.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a caller wants the event to carry.
|
|
24
|
+
*
|
|
25
|
+
* An indexer, because which properties are meaningful is decided by the event's
|
|
26
|
+
* *interface* and the interface is decided by the name — `{ key: "Escape" }`
|
|
27
|
+
* for a `keydown`, `{ clientX: 40 }` for a `pointermove` — and the name is a
|
|
28
|
+
* string a caller computes. There is no type that says "the initialisers of
|
|
29
|
+
* whichever interface `name` maps to", so this says what is true: names in,
|
|
30
|
+
* and what each one means is the DOM's business.
|
|
31
|
+
*/
|
|
32
|
+
export type EventInit = { readonly [string]: mixed };
|
|
33
|
+
|
|
34
|
+
/** Event constructors by DOM event name, with the right interface for each. */
|
|
35
|
+
const EVENT_TYPES: { readonly [string]: string } = {
|
|
36
|
+
click: "MouseEvent",
|
|
37
|
+
dblclick: "MouseEvent",
|
|
38
|
+
mousedown: "MouseEvent",
|
|
39
|
+
mouseup: "MouseEvent",
|
|
40
|
+
mouseover: "MouseEvent",
|
|
41
|
+
mouseout: "MouseEvent",
|
|
42
|
+
mouseenter: "MouseEvent",
|
|
43
|
+
mouseleave: "MouseEvent",
|
|
44
|
+
mousemove: "MouseEvent",
|
|
45
|
+
contextmenu: "MouseEvent",
|
|
46
|
+
keydown: "KeyboardEvent",
|
|
47
|
+
keyup: "KeyboardEvent",
|
|
48
|
+
keypress: "KeyboardEvent",
|
|
49
|
+
focus: "FocusEvent",
|
|
50
|
+
blur: "FocusEvent",
|
|
51
|
+
focusin: "FocusEvent",
|
|
52
|
+
focusout: "FocusEvent",
|
|
53
|
+
input: "InputEvent",
|
|
54
|
+
pointerdown: "PointerEvent",
|
|
55
|
+
pointerup: "PointerEvent",
|
|
56
|
+
pointermove: "PointerEvent",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Events that do not bubble, whatever else is said about them. */
|
|
60
|
+
const NON_BUBBLING = new Set(["focus", "blur", "mouseenter", "mouseleave"]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The bubbling event React actually listens for, for each one that does not
|
|
64
|
+
* bubble.
|
|
65
|
+
*
|
|
66
|
+
* React attaches every listener to the root container rather than to the
|
|
67
|
+
* element, so it can only hear events that reach the root. `focus` and `blur`
|
|
68
|
+
* never do. React's answer is to listen for `focusin` and `focusout` — which
|
|
69
|
+
* are the same moments and do bubble — and surface them to a component as
|
|
70
|
+
* `onFocus` and `onBlur`.
|
|
71
|
+
*
|
|
72
|
+
* So dispatching a bare `focus` calls nothing: the element's own listener, if
|
|
73
|
+
* a test added one directly, and no React handler at all. The test then
|
|
74
|
+
* asserts on a component that never re-rendered and reads as a component bug.
|
|
75
|
+
* Firing the pair is what a browser does anyway — a real focus is a `focus`
|
|
76
|
+
* and a `focusin` — so this is less a workaround than the missing half.
|
|
77
|
+
*/
|
|
78
|
+
const ALSO_BUBBLES: { readonly [string]: string } = {
|
|
79
|
+
focus: "focusin",
|
|
80
|
+
blur: "focusout",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The event a name asks for, built by the document's own class for it.
|
|
85
|
+
*
|
|
86
|
+
* # Why `Constructor` is `any`
|
|
87
|
+
*
|
|
88
|
+
* Neither half of this can be typed.
|
|
89
|
+
*
|
|
90
|
+
* Reading it: `globalThis` is a namespace to the checker rather than an
|
|
91
|
+
* object, and the value under a computed name is `mixed`, which cannot be
|
|
92
|
+
* `new`ed — refining a `mixed` with `typeof x === "function"` gives a function
|
|
93
|
+
* whose signature Flow says it does not know.
|
|
94
|
+
*
|
|
95
|
+
* Calling it: this is the half that is not uf's to fix. Flow's library
|
|
96
|
+
* definitions declare every event initialiser dictionary with *writable*
|
|
97
|
+
* properties — `MouseEvent$MouseEventInit` has `clientX?: number`, not
|
|
98
|
+
* `readonly clientX?: number` — so those properties are invariant, and no
|
|
99
|
+
* value whose type was computed rather than written inline can be passed to
|
|
100
|
+
* one. `{ readonly [string]: mixed }` fails on variance before it fails on
|
|
101
|
+
* anything else:
|
|
102
|
+
*
|
|
103
|
+
* error[incompatible-variance]: property `bubbles` is read-only in
|
|
104
|
+
* `EventInit` but writable in `Event$Init`
|
|
105
|
+
* error[incompatible-type]: in property `clientX`: `unknown` is not
|
|
106
|
+
* exactly the same as `number`
|
|
107
|
+
*
|
|
108
|
+
* — twelve of those for `MouseEvent` alone, and Flow's own advice in the
|
|
109
|
+
* message is to make the library definition's property readonly. So an
|
|
110
|
+
* `EventInit` cannot reach an event constructor under any spelling, and this
|
|
111
|
+
* stays one cast at one line rather than a dozen errors at every call.
|
|
112
|
+
*/
|
|
113
|
+
function construct(name: string, init: EventInit): Event {
|
|
114
|
+
// One cast, covering the lookup and both constructions; see above. Suppressed
|
|
115
|
+
// rather than left to fail `check:lib`, because the half that would have to
|
|
116
|
+
// change is Flow's library definition for the event initialiser dictionaries.
|
|
117
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
118
|
+
const classes: any = globalThis;
|
|
119
|
+
const Constructor = classes[EVENT_TYPES[name] ?? "Event"] ?? classes.Event;
|
|
120
|
+
const options = optionsFor(name, init);
|
|
121
|
+
try {
|
|
122
|
+
return new Constructor(name, options);
|
|
123
|
+
} catch {
|
|
124
|
+
// A host whose constructor is stricter than the init we were handed.
|
|
125
|
+
return new classes.Event(name, options);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The defaults an event is built with, under whatever the caller asked for.
|
|
131
|
+
*
|
|
132
|
+
* Written as a loop rather than `{ bubbles, cancelable, ...init }` because
|
|
133
|
+
* spreading an indexer is something Flow declines to compute a type for —
|
|
134
|
+
* "the indexer `string` may overwrite properties with explicit keys in a way
|
|
135
|
+
* that Flow cannot track", which is precisely what this is for. Its
|
|
136
|
+
* suggestion, spreading `init` first, would reverse the precedence and stop a
|
|
137
|
+
* caller from passing `bubbles: false`; the loop keeps the caller on top,
|
|
138
|
+
* which is what the spread said.
|
|
139
|
+
*/
|
|
140
|
+
function optionsFor(name: string, init: EventInit): EventInit {
|
|
141
|
+
const options: { [string]: mixed } = {
|
|
142
|
+
bubbles: !NON_BUBBLING.has(name),
|
|
143
|
+
cancelable: true,
|
|
144
|
+
};
|
|
145
|
+
for (const key of Object.keys(init)) {
|
|
146
|
+
options[key] = init[key];
|
|
147
|
+
}
|
|
148
|
+
return options;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Dispatch one event, inside `act`.
|
|
153
|
+
*
|
|
154
|
+
* Returns whether the event ran to completion — `false` when a handler called
|
|
155
|
+
* `preventDefault`, which is what `dispatchEvent` reports and what a test
|
|
156
|
+
* asserting "the form did not submit" needs.
|
|
157
|
+
*/
|
|
158
|
+
export function dispatch(target: EventTarget, name: string, init?: EventInit): boolean {
|
|
159
|
+
const event = construct(name, init ?? {});
|
|
160
|
+
const paired = ALSO_BUBBLES[name];
|
|
161
|
+
let ran = true;
|
|
162
|
+
actively(() => {
|
|
163
|
+
ran = target.dispatchEvent(event);
|
|
164
|
+
if (paired != null) {
|
|
165
|
+
// Both, in the order a browser sends them, and inside the same `act` so
|
|
166
|
+
// the component re-renders once rather than twice.
|
|
167
|
+
target.dispatchEvent(construct(paired, init ?? {}));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
return ran;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `fireEvent.click(element)`, and one entry per event name.
|
|
175
|
+
*
|
|
176
|
+
* A proxy rather than a written-out table: the set of DOM events is long,
|
|
177
|
+
* grows, and every entry would be the same line. `fireEvent(target, name)`
|
|
178
|
+
* also works, for an event whose name is computed.
|
|
179
|
+
*
|
|
180
|
+
* # Why the type is still `any`, and what was tried
|
|
181
|
+
*
|
|
182
|
+
* The type this wants is a function that also answers to every event name.
|
|
183
|
+
* Written with an indexer:
|
|
184
|
+
*
|
|
185
|
+
* type FireEvent = {
|
|
186
|
+
* (target: EventTarget, name: string, init?: EventInit): boolean,
|
|
187
|
+
* readonly [string]: (target: EventTarget, init?: EventInit) => boolean,
|
|
188
|
+
* };
|
|
189
|
+
*
|
|
190
|
+
* Flow declines the indexer, and is right to. As an `interface`, so the
|
|
191
|
+
* assignment gets far enough to say why, it reads "an unknown property that
|
|
192
|
+
* may exist on the inexact function is incompatible with `Firer`" — the value
|
|
193
|
+
* is a function, a function has `name`, `length`, `call`, `apply` and `bind`,
|
|
194
|
+
* and the trap below hands those back as themselves because `property in base`
|
|
195
|
+
* is true for them. None of the five is a DOM event, so the lie is unreachable
|
|
196
|
+
* from any real call, but a type is not something to be right about on
|
|
197
|
+
* average.
|
|
198
|
+
*
|
|
199
|
+
* The written-out table the runtime deliberately is not — a call signature
|
|
200
|
+
* plus a named property per event — fails earlier and for a reason no list of
|
|
201
|
+
* names would fix:
|
|
202
|
+
*
|
|
203
|
+
* error[incompatible-type]: Cannot assign `new Proxy(...)` to `fireEvent`
|
|
204
|
+
* because `(target: EventTarget, name: string, init?: EventInit) =>
|
|
205
|
+
* boolean` is incompatible with `FireEvent`.
|
|
206
|
+
* Functions without statics are not compatible with objects.
|
|
207
|
+
*
|
|
208
|
+
* A `Proxy` over a function *is* a function, and Flow will not treat a
|
|
209
|
+
* function with no statics as an object with properties whatever those
|
|
210
|
+
* properties are. So no type at all can be assigned to this value: typing
|
|
211
|
+
* `fireEvent` means changing what it is — a function carrying real static
|
|
212
|
+
* properties, one per event name, which is a table of a hundred-odd entries
|
|
213
|
+
* that stops answering to the hundred-and-first. That is a design decision
|
|
214
|
+
* about a published API and not a cast to remove in passing.
|
|
215
|
+
*
|
|
216
|
+
* Suppressed by name rather than renamed to `$FlowFixMe`. The rename would
|
|
217
|
+
* move it out of `flow/unclear-type`'s sight and say nothing; the directive
|
|
218
|
+
* below names the rule it is escaping, is checked by
|
|
219
|
+
* `uniflowed/unknown-lint-suppression`, and can be counted — this is one of
|
|
220
|
+
* the seven in the packages, and ubugeeei-prod/uf#401 is the design decision
|
|
221
|
+
* that would remove it.
|
|
222
|
+
*/
|
|
223
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
224
|
+
export const fireEvent: any = new Proxy(
|
|
225
|
+
(target: EventTarget, name: string, init?: EventInit) => dispatch(target, name, init),
|
|
226
|
+
{
|
|
227
|
+
get(base, property) {
|
|
228
|
+
if (typeof property !== "string") {
|
|
229
|
+
return Reflect.get(base, property);
|
|
230
|
+
}
|
|
231
|
+
if (property in base) {
|
|
232
|
+
return Reflect.get(base, property);
|
|
233
|
+
}
|
|
234
|
+
return (target: EventTarget, init?: EventInit) =>
|
|
235
|
+
dispatch(target, property.toLowerCase(), init);
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
/** Set a control's value the way a browser does, so React sees the change. */
|
|
241
|
+
function setValue(element: HTMLElement, value: string): void {
|
|
242
|
+
// React tracks the last value it wrote on the node and skips an `input`
|
|
243
|
+
// event whose value it believes it already knows. Writing through the
|
|
244
|
+
// prototype's setter is what a browser does and what clears that.
|
|
245
|
+
const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
|
|
246
|
+
const set = descriptor?.set;
|
|
247
|
+
if (set != null) {
|
|
248
|
+
set.call(element, value);
|
|
249
|
+
} else if (
|
|
250
|
+
element instanceof HTMLInputElement ||
|
|
251
|
+
element instanceof HTMLTextAreaElement ||
|
|
252
|
+
element instanceof HTMLSelectElement
|
|
253
|
+
) {
|
|
254
|
+
// The fallback, for a host whose control classes keep `value` as an own
|
|
255
|
+
// property rather than an accessor on the prototype. The three classes are
|
|
256
|
+
// the ones `displayValue` reads, so what a test writes is what a
|
|
257
|
+
// `ByDisplayValue` query can find.
|
|
258
|
+
element.value = value;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A key's `key`, `code` and printable text. */
|
|
263
|
+
function describeKey(key: string): {| key: string, code: string, text: string | null |} {
|
|
264
|
+
const named: { readonly [string]: {| code: string, text: string | null |} } = {
|
|
265
|
+
Enter: { code: "Enter", text: "\n" },
|
|
266
|
+
Tab: { code: "Tab", text: null },
|
|
267
|
+
Escape: { code: "Escape", text: null },
|
|
268
|
+
Backspace: { code: "Backspace", text: null },
|
|
269
|
+
Delete: { code: "Delete", text: null },
|
|
270
|
+
ArrowUp: { code: "ArrowUp", text: null },
|
|
271
|
+
ArrowDown: { code: "ArrowDown", text: null },
|
|
272
|
+
ArrowLeft: { code: "ArrowLeft", text: null },
|
|
273
|
+
ArrowRight: { code: "ArrowRight", text: null },
|
|
274
|
+
Home: { code: "Home", text: null },
|
|
275
|
+
End: { code: "End", text: null },
|
|
276
|
+
" ": { code: "Space", text: " " },
|
|
277
|
+
};
|
|
278
|
+
const entry = named[key];
|
|
279
|
+
if (entry != null) {
|
|
280
|
+
return { key, code: entry.code, text: entry.text };
|
|
281
|
+
}
|
|
282
|
+
return { key, code: `Key${key.toUpperCase()}`, text: key };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Elements the tab order includes, in document order.
|
|
287
|
+
*
|
|
288
|
+
* `instanceof HTMLElement` rather than a cast, and it is not only the
|
|
289
|
+
* checker's question: the next thing done to one of these is `focus`, and
|
|
290
|
+
* `focus` is a method of `HTMLElement`. The declared return type has always
|
|
291
|
+
* said `HTMLElement` while the selector could match an `Element` — Flow said
|
|
292
|
+
* so, "in array element: `Element` is incompatible with `HTMLElement`" — and
|
|
293
|
+
* anything that reached here without being one would have been handed to a
|
|
294
|
+
* `(element as any).focus?.()` that silently did nothing, swallowing the Tab.
|
|
295
|
+
*/
|
|
296
|
+
function tabbable(): Array<HTMLElement> {
|
|
297
|
+
const selector =
|
|
298
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
299
|
+
const order = [];
|
|
300
|
+
for (const element of documentOf().querySelectorAll(selector)) {
|
|
301
|
+
if (element instanceof HTMLElement && element.getAttribute("aria-hidden") !== "true") {
|
|
302
|
+
order.push(element);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return order;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Whether a control is disabled, for the elements that can be.
|
|
310
|
+
*
|
|
311
|
+
* The classes rather than `(element as any).disabled === true`, which was the
|
|
312
|
+
* same question asked in a way that answered `any`. `disabled` belongs to the
|
|
313
|
+
* form controls and to `fieldset`; on a `div` it is an attribute React put in
|
|
314
|
+
* the markup and not a property, which is why the old test was `=== true`
|
|
315
|
+
* rather than truthiness, and why the answer for one is still `false`.
|
|
316
|
+
*/
|
|
317
|
+
function isDisabled(element: HTMLElement): boolean {
|
|
318
|
+
return (
|
|
319
|
+
(element instanceof HTMLButtonElement ||
|
|
320
|
+
element instanceof HTMLInputElement ||
|
|
321
|
+
element instanceof HTMLSelectElement ||
|
|
322
|
+
element instanceof HTMLTextAreaElement ||
|
|
323
|
+
element instanceof HTMLFieldSetElement) &&
|
|
324
|
+
element.disabled
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* What a person did, rather than what the DOM emitted.
|
|
330
|
+
*
|
|
331
|
+
* Every method is async because that is what makes a test written with it
|
|
332
|
+
* correct as it grows: the moment an interaction leads to something awaited —
|
|
333
|
+
* a fetch, a transition, a lazily loaded panel — a synchronous helper would
|
|
334
|
+
* return before the result existed, and the test would need a sleep. Awaiting
|
|
335
|
+
* from the start means adding that behaviour later changes nothing.
|
|
336
|
+
*/
|
|
337
|
+
export const userEvent = {
|
|
338
|
+
/** Press and release, with the events a real click produces, in order. */
|
|
339
|
+
async click(element: HTMLElement, init?: EventInit): Promise<void> {
|
|
340
|
+
if (isDisabled(element)) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
dispatch(element, "pointerdown", init);
|
|
344
|
+
dispatch(element, "mousedown", init);
|
|
345
|
+
focus(element);
|
|
346
|
+
dispatch(element, "pointerup", init);
|
|
347
|
+
dispatch(element, "mouseup", init);
|
|
348
|
+
dispatch(element, "click", init);
|
|
349
|
+
await settle();
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
/** Two clicks and a dblclick. */
|
|
353
|
+
async dblClick(element: HTMLElement): Promise<void> {
|
|
354
|
+
await userEvent.click(element);
|
|
355
|
+
await userEvent.click(element);
|
|
356
|
+
dispatch(element, "dblclick");
|
|
357
|
+
await settle();
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Type into a control, one character at a time.
|
|
362
|
+
*
|
|
363
|
+
* Per character rather than setting the value once, because a component
|
|
364
|
+
* that reacts to each keystroke — a search box that filters, a field that
|
|
365
|
+
* rejects a character — behaves differently, and the difference is the thing
|
|
366
|
+
* usually being tested.
|
|
367
|
+
*/
|
|
368
|
+
async type(element: HTMLElement, text: string): Promise<void> {
|
|
369
|
+
focus(element);
|
|
370
|
+
for (const character of text) {
|
|
371
|
+
const { key, code, text: printable } = describeKey(character);
|
|
372
|
+
dispatch(element, "keydown", { key, code });
|
|
373
|
+
// Nothing is typed into an element that shows no value; see `keyboard`
|
|
374
|
+
// for what that used to do instead.
|
|
375
|
+
const current = displayValue(element);
|
|
376
|
+
if (printable != null && printable !== "\n" && current != null) {
|
|
377
|
+
setValue(element, `${current}${printable}`);
|
|
378
|
+
dispatch(element, "input", { data: printable });
|
|
379
|
+
}
|
|
380
|
+
dispatch(element, "keyup", { key, code });
|
|
381
|
+
}
|
|
382
|
+
await settle();
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
/** Empty a control, the way selecting everything and deleting would. */
|
|
386
|
+
async clear(element: HTMLElement): Promise<void> {
|
|
387
|
+
focus(element);
|
|
388
|
+
setValue(element, "");
|
|
389
|
+
dispatch(element, "input", {});
|
|
390
|
+
await settle();
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
/** Press keys at whatever has focus. Named keys go in braces: `{Enter}`. */
|
|
394
|
+
async keyboard(sequence: string): Promise<void> {
|
|
395
|
+
const target = documentOf().activeElement ?? bodyOf();
|
|
396
|
+
for (const token of parseKeys(sequence)) {
|
|
397
|
+
const { key, code, text } = describeKey(token);
|
|
398
|
+
dispatch(target, "keydown", { key, code });
|
|
399
|
+
// `displayValue` rather than `target.value !== undefined`.
|
|
400
|
+
//
|
|
401
|
+
// The two agree about every control a person can type into, and differ
|
|
402
|
+
// about `<button>`, `<option>`, `<progress>` and the rest of the
|
|
403
|
+
// elements that have a `value` property without showing one: pressing
|
|
404
|
+
// Space at a focused button used to write `button.value = " "` and
|
|
405
|
+
// dispatch an `input` event at it, which no browser does — Space on a
|
|
406
|
+
// button is a click. Nothing in this repository's suite depended on it,
|
|
407
|
+
// and `ui.test.js` presses Space at a switch on the way past.
|
|
408
|
+
const current = displayValue(target);
|
|
409
|
+
if (text != null && text !== "\n" && current != null) {
|
|
410
|
+
setValue(target, `${current}${text}`);
|
|
411
|
+
dispatch(target, "input", { data: text });
|
|
412
|
+
}
|
|
413
|
+
dispatch(target, "keyup", { key, code });
|
|
414
|
+
}
|
|
415
|
+
await settle();
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
/** Move focus the way the Tab key does. */
|
|
419
|
+
async tab(options?: {| readonly shift?: boolean |}): Promise<void> {
|
|
420
|
+
const order = tabbable();
|
|
421
|
+
if (order.length === 0) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const active = documentOf().activeElement;
|
|
425
|
+
// `indexOf` needs an element; a document with nothing focused is the same
|
|
426
|
+
// "not in the order" that `indexOf` answers `-1` to, said in front.
|
|
427
|
+
const at = active == null ? -1 : order.indexOf(active);
|
|
428
|
+
const shift = options?.shift ?? false;
|
|
429
|
+
const next =
|
|
430
|
+
at < 0
|
|
431
|
+
? shift
|
|
432
|
+
? order[order.length - 1]
|
|
433
|
+
: order[0]
|
|
434
|
+
: order[(at + (shift ? -1 : 1) + order.length) % order.length];
|
|
435
|
+
focus(next);
|
|
436
|
+
await settle();
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
/** Choose options in a select. */
|
|
440
|
+
async selectOptions(
|
|
441
|
+
element: HTMLElement,
|
|
442
|
+
values: string | $ReadOnlyArray<string>,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const wanted = typeof values === "string" ? [values] : values;
|
|
445
|
+
// Only a `select` has options; anything else has none, which is what
|
|
446
|
+
// `select.options ?? []` used to say. The events are dispatched either
|
|
447
|
+
// way, because a component listening for `change` on something that is not
|
|
448
|
+
// a select is a component under test and not this function's business.
|
|
449
|
+
if (element instanceof HTMLSelectElement) {
|
|
450
|
+
for (const option of Array.from(element.options)) {
|
|
451
|
+
option.selected = wanted.includes(option.value);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
dispatch(element, "input");
|
|
455
|
+
dispatch(element, "change");
|
|
456
|
+
await settle();
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
/** Move focus away, which is what makes a blur-validated field validate. */
|
|
460
|
+
async tabAway(element: HTMLElement): Promise<void> {
|
|
461
|
+
dispatch(element, "blur");
|
|
462
|
+
element.blur();
|
|
463
|
+
await settle();
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
function focus(element: HTMLElement): void {
|
|
468
|
+
if (documentOf().activeElement === element) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
actively(() => {
|
|
472
|
+
element.focus();
|
|
473
|
+
});
|
|
474
|
+
if (documentOf().activeElement !== element) {
|
|
475
|
+
// A host whose `focus` does not move `activeElement`; the events are what
|
|
476
|
+
// components listen for, so dispatch them regardless.
|
|
477
|
+
dispatch(element, "focus");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** `{Enter}` and `{Escape}` as single tokens; everything else per character. */
|
|
482
|
+
function parseKeys(sequence: string): Array<string> {
|
|
483
|
+
const keys = [];
|
|
484
|
+
let index = 0;
|
|
485
|
+
while (index < sequence.length) {
|
|
486
|
+
if (sequence[index] === "{") {
|
|
487
|
+
const close = sequence.indexOf("}", index);
|
|
488
|
+
if (close > index) {
|
|
489
|
+
keys.push(sequence.slice(index + 1, close));
|
|
490
|
+
index = close + 1;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
keys.push(sequence[index]);
|
|
495
|
+
index += 1;
|
|
496
|
+
}
|
|
497
|
+
return keys;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Let React finish anything the interaction started. */
|
|
501
|
+
async function settle(): Promise<void> {
|
|
502
|
+
await actively(() => Promise.resolve());
|
|
503
|
+
}
|