@reticlehq/browser 3.8.1 → 3.8.2
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.
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ActionTarget } from '../dom/realm.js';
|
|
2
|
+
import { ClickButton } from '@reticlehq/core';
|
|
3
|
+
import { type ModifierFlags } from './actions-press.js';
|
|
2
4
|
interface ClickGeometry {
|
|
3
5
|
occluded: boolean;
|
|
4
6
|
occludedBy: string | null;
|
|
@@ -9,11 +11,17 @@ interface ClickGeometry {
|
|
|
9
11
|
}
|
|
10
12
|
export declare const NO_GEOMETRY: ClickGeometry;
|
|
11
13
|
export declare function mouseEventFor(el: Element, type: string, init?: MouseEventInit): MouseEvent;
|
|
14
|
+
export interface ClickInput {
|
|
15
|
+
modifiers: ModifierFlags;
|
|
16
|
+
button: ClickButton;
|
|
17
|
+
}
|
|
18
|
+
export declare const PLAIN_CLICK: ClickInput;
|
|
19
|
+
export declare function clickInputOf(args: Record<string, unknown>): ClickInput;
|
|
12
20
|
export declare function fireClickSequence(el: ActionTarget, hold?: {
|
|
13
21
|
ms: number;
|
|
14
22
|
sleep: (ms: number) => Promise<void>;
|
|
15
23
|
now: () => number;
|
|
16
|
-
}, detail?: number): Promise<{
|
|
24
|
+
}, detail?: number, input?: ClickInput): Promise<{
|
|
17
25
|
prevented: boolean;
|
|
18
26
|
heldMs: number;
|
|
19
27
|
}>;
|
|
@@ -4,6 +4,8 @@ import { getAccessibleName, getRole } from '../dom/a11y.js';
|
|
|
4
4
|
import { hitTestOccluder } from '../dom/occlusion.js';
|
|
5
5
|
import { nativeFrame } from '../timers/native/native-timers.js';
|
|
6
6
|
import { asSyntheticInput } from './synthetic/synthetic-input.js';
|
|
7
|
+
import { ClickButton, clickButtonFromArgs } from '@reticlehq/core';
|
|
8
|
+
import { pressModifiers } from './actions-press.js';
|
|
7
9
|
export const NO_GEOMETRY = {
|
|
8
10
|
occluded: false,
|
|
9
11
|
occludedBy: null,
|
|
@@ -20,7 +22,22 @@ function pointerEventFor(el, type, init) {
|
|
|
20
22
|
}
|
|
21
23
|
const BUTTON_HELD = 1;
|
|
22
24
|
const BUTTON_RELEASED = 0;
|
|
23
|
-
|
|
25
|
+
export const PLAIN_CLICK = {
|
|
26
|
+
modifiers: { metaKey: false, ctrlKey: false, shiftKey: false, altKey: false },
|
|
27
|
+
button: ClickButton.LEFT,
|
|
28
|
+
};
|
|
29
|
+
const BUTTON_CODES = {
|
|
30
|
+
[ClickButton.LEFT]: { button: 0, bit: 1 },
|
|
31
|
+
[ClickButton.RIGHT]: { button: 2, bit: 2 },
|
|
32
|
+
[ClickButton.MIDDLE]: { button: 1, bit: 4 },
|
|
33
|
+
};
|
|
34
|
+
export function clickInputOf(args) {
|
|
35
|
+
return {
|
|
36
|
+
modifiers: pressModifiers(args),
|
|
37
|
+
button: clickButtonFromArgs(args) ?? ClickButton.LEFT,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function fireAt(el, type, at, buttons, related, detail = 0, input = PLAIN_CLICK) {
|
|
24
41
|
const init = {
|
|
25
42
|
bubbles: !(type.endsWith('enter') || type.endsWith('leave')),
|
|
26
43
|
cancelable: true,
|
|
@@ -30,31 +47,42 @@ function fireAt(el, type, at, buttons, related, detail = 0) {
|
|
|
30
47
|
screenX: at.x,
|
|
31
48
|
screenY: at.y,
|
|
32
49
|
buttons,
|
|
33
|
-
button:
|
|
50
|
+
button: BUTTON_CODES[input.button].button,
|
|
51
|
+
...input.modifiers,
|
|
34
52
|
...(related !== undefined ? { relatedTarget: related } : {}),
|
|
35
53
|
};
|
|
36
54
|
return el.dispatchEvent(type.startsWith('pointer')
|
|
37
55
|
? pointerEventFor(el, type, { ...init, pointerId: 1, isPrimary: true })
|
|
38
56
|
: mouseEventFor(el, type, init));
|
|
39
57
|
}
|
|
40
|
-
export async function fireClickSequence(el, hold, detail) {
|
|
58
|
+
export async function fireClickSequence(el, hold, detail, input = PLAIN_CLICK) {
|
|
41
59
|
const doc = el.ownerDocument;
|
|
42
60
|
const from = doc.activeElement ?? doc.body;
|
|
43
61
|
const at = centreOf(el);
|
|
44
62
|
const d = detail ?? 0;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
const held = BUTTON_CODES[input.button].bit;
|
|
64
|
+
const isLeft = ClickButton.LEFT === input.button;
|
|
65
|
+
let notPrevented = true;
|
|
66
|
+
asSyntheticInput(() => fireAt(el, 'pointerdown', at, held, from, d, input));
|
|
67
|
+
asSyntheticInput(() => fireAt(el, 'mousedown', at, held, undefined, d, input));
|
|
68
|
+
if (ClickButton.RIGHT === input.button) {
|
|
69
|
+
notPrevented = asSyntheticInput(() => fireAt(el, 'contextmenu', at, held, undefined, d, input));
|
|
70
|
+
}
|
|
71
|
+
if (el.tabIndex >= 0 && 'function' === typeof el.focus) {
|
|
48
72
|
el.focus();
|
|
73
|
+
}
|
|
49
74
|
let heldMs = 0;
|
|
50
75
|
if (hold !== undefined && hold.ms > 0) {
|
|
51
76
|
const startedAt = hold.now();
|
|
52
77
|
await hold.sleep(hold.ms);
|
|
53
78
|
heldMs = hold.now() - startedAt;
|
|
54
79
|
}
|
|
55
|
-
asSyntheticInput(() => fireAt(el, 'pointerup', at, BUTTON_RELEASED, from, d));
|
|
56
|
-
asSyntheticInput(() => fireAt(el, 'mouseup', at, BUTTON_RELEASED, undefined, d));
|
|
57
|
-
const
|
|
80
|
+
asSyntheticInput(() => fireAt(el, 'pointerup', at, BUTTON_RELEASED, from, d, input));
|
|
81
|
+
asSyntheticInput(() => fireAt(el, 'mouseup', at, BUTTON_RELEASED, undefined, d, input));
|
|
82
|
+
const clicked = asSyntheticInput(() => fireAt(el, isLeft ? 'click' : 'auxclick', at, BUTTON_RELEASED, undefined, d, input));
|
|
83
|
+
if (ClickButton.RIGHT !== input.button) {
|
|
84
|
+
notPrevented = clicked;
|
|
85
|
+
}
|
|
58
86
|
return { prevented: !notPrevented, heldMs };
|
|
59
87
|
}
|
|
60
88
|
function isMeasurable(rect) {
|
|
@@ -62,8 +90,9 @@ function isMeasurable(rect) {
|
|
|
62
90
|
}
|
|
63
91
|
export function isOffViewport(el, rect) {
|
|
64
92
|
const win = el.ownerDocument.defaultView;
|
|
65
|
-
if (null === win)
|
|
93
|
+
if (null === win) {
|
|
66
94
|
return false;
|
|
95
|
+
}
|
|
67
96
|
const cx = rect.left + rect.width / 2;
|
|
68
97
|
const cy = rect.top + rect.height / 2;
|
|
69
98
|
return cx < 0 || cy < 0 || cx > win.innerWidth || cy > win.innerHeight;
|
|
@@ -76,23 +105,27 @@ function hitTest(el, rect) {
|
|
|
76
105
|
}
|
|
77
106
|
function nameOf(el) {
|
|
78
107
|
const testid = readTestId(el);
|
|
79
|
-
if (testid !== null && testid.length > 0)
|
|
108
|
+
if (testid !== null && testid.length > 0) {
|
|
80
109
|
return `[data-testid="${testid}"]`;
|
|
110
|
+
}
|
|
81
111
|
const name = getAccessibleName(el).trim();
|
|
82
112
|
return name.length > 0 ? `${getRole(el)} "${name.slice(0, 40)}"` : el.tagName.toLowerCase();
|
|
83
113
|
}
|
|
84
114
|
function hasNoSize(el) {
|
|
85
|
-
if (typeof el.getClientRects !== 'function')
|
|
115
|
+
if (typeof el.getClientRects !== 'function') {
|
|
86
116
|
return false;
|
|
117
|
+
}
|
|
87
118
|
const rects = Array.from(el.getClientRects());
|
|
88
119
|
return rects.length > 0 && rects.every((r) => 0 === r.width || 0 === r.height);
|
|
89
120
|
}
|
|
90
121
|
export function clickGeometry(el) {
|
|
91
|
-
if (typeof el.getBoundingClientRect !== 'function')
|
|
122
|
+
if (typeof el.getBoundingClientRect !== 'function') {
|
|
92
123
|
return NO_GEOMETRY;
|
|
124
|
+
}
|
|
93
125
|
let rect = el.getBoundingClientRect();
|
|
94
|
-
if (!isMeasurable(rect))
|
|
126
|
+
if (!isMeasurable(rect)) {
|
|
95
127
|
return hasNoSize(el) ? { ...NO_GEOMETRY, noSize: true } : NO_GEOMETRY;
|
|
128
|
+
}
|
|
96
129
|
let scrolledIntoView = false;
|
|
97
130
|
if (isOffViewport(el, rect) && 'function' === typeof el.scrollIntoView) {
|
|
98
131
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
@@ -109,15 +142,17 @@ export function firePointerNonBubbling(el, type, relatedTarget = null) {
|
|
|
109
142
|
asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget })));
|
|
110
143
|
}
|
|
111
144
|
function makeDataTransfer(data) {
|
|
112
|
-
if (typeof DataTransfer !== 'function')
|
|
145
|
+
if (typeof DataTransfer !== 'function') {
|
|
113
146
|
return null;
|
|
147
|
+
}
|
|
114
148
|
const dt = new DataTransfer();
|
|
115
149
|
const entries = Array.isArray(data) ? data : data !== undefined ? [data] : [];
|
|
116
150
|
for (const entry of entries) {
|
|
117
151
|
if ('object' === typeof entry && entry !== null) {
|
|
118
152
|
const e = entry;
|
|
119
|
-
if ('string' === typeof e.mime && 'string' === typeof e.value)
|
|
153
|
+
if ('string' === typeof e.mime && 'string' === typeof e.value) {
|
|
120
154
|
dt.setData(e.mime, e.value);
|
|
155
|
+
}
|
|
121
156
|
}
|
|
122
157
|
}
|
|
123
158
|
return dt;
|
|
@@ -174,8 +209,9 @@ export async function dragElement(source, target, data) {
|
|
|
174
209
|
if ('function' === typeof DragEvent) {
|
|
175
210
|
const dataTransfer = makeDataTransfer(data);
|
|
176
211
|
const init = { bubbles: true, cancelable: true };
|
|
177
|
-
if (dataTransfer !== null)
|
|
212
|
+
if (dataTransfer !== null) {
|
|
178
213
|
init.dataTransfer = dataTransfer;
|
|
214
|
+
}
|
|
179
215
|
source.dispatchEvent(new DragEvent('dragstart', init));
|
|
180
216
|
await nativeFrame();
|
|
181
217
|
dest.dispatchEvent(new DragEvent('dragenter', init));
|
|
@@ -190,8 +226,9 @@ export async function fireTapSequence(el, hold) {
|
|
|
190
226
|
const touches = touchListFor(el);
|
|
191
227
|
firePointerTouch(el, 'pointerdown');
|
|
192
228
|
asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchstart', touches)));
|
|
193
|
-
if (el.tabIndex >= 0 && 'function' === typeof el.focus)
|
|
229
|
+
if (el.tabIndex >= 0 && 'function' === typeof el.focus) {
|
|
194
230
|
el.focus();
|
|
231
|
+
}
|
|
195
232
|
let heldMs = 0;
|
|
196
233
|
if (hold !== undefined && hold.ms > 0) {
|
|
197
234
|
const started = hold.now();
|
|
@@ -214,8 +251,9 @@ function firePointerTouch(el, type) {
|
|
|
214
251
|
})));
|
|
215
252
|
}
|
|
216
253
|
function touchListFor(el) {
|
|
217
|
-
if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect)
|
|
254
|
+
if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect) {
|
|
218
255
|
return [];
|
|
256
|
+
}
|
|
219
257
|
const rect = el.getBoundingClientRect();
|
|
220
258
|
try {
|
|
221
259
|
return [
|
|
@@ -251,10 +289,12 @@ const POPUP_ROLES = new Set(['menu', 'menubar', 'listbox', 'dialog', 'alertdialo
|
|
|
251
289
|
const CLOSED_STATE = 'closed';
|
|
252
290
|
export function assertNotLeaving(el) {
|
|
253
291
|
for (let up = el.parentElement; up !== null; up = up.parentElement) {
|
|
254
|
-
if (CLOSED_STATE !== up.getAttribute('data-state'))
|
|
292
|
+
if (CLOSED_STATE !== up.getAttribute('data-state')) {
|
|
255
293
|
continue;
|
|
256
|
-
|
|
294
|
+
}
|
|
295
|
+
if (!POPUP_ROLES.has(up.getAttribute('role') ?? '')) {
|
|
257
296
|
continue;
|
|
297
|
+
}
|
|
258
298
|
throw new Error(`'${up.getAttribute('role') ?? 'popup'}' holding this element is closing (data-state="closed"), ` +
|
|
259
299
|
'so a person could not click it: look again once it has gone and act on what is open');
|
|
260
300
|
}
|
package/dist/actions/actions.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { ActionType, ActionWarning, clampHoldMs, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
|
|
1
|
+
import { ActionType, ActionWarning, clampHoldMs, clickInputProblem, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
|
|
2
2
|
import { isHiddenNativeOption } from '../dom/query-finders.js';
|
|
3
3
|
import { isReticleUi } from '../dom/dom-ignore.js';
|
|
4
4
|
import { asSyntheticInput } from './synthetic/synthetic-input.js';
|
|
5
5
|
import { echoRef, refs } from '../dom/addressing/refs.js';
|
|
6
6
|
import { requiresDangerousConfirmation, assertOutwardAllowed, assertPaymentEntryAllowed, submitControlFor, } from './danger-context.js';
|
|
7
7
|
import { assertEditable, assertNotRichText, isAutofillSafeTarget, setNativeValue, } from './value-input.js';
|
|
8
|
-
import { getStates } from '../dom/a11y.js';
|
|
8
|
+
import { getRole, getStates } from '../dom/a11y.js';
|
|
9
9
|
import { isVisible } from '../dom/visibility.js';
|
|
10
|
+
import { selectInCombobox } from './select-combobox.js';
|
|
10
11
|
import { elementHasHoverHandlers } from '../registry/stores/adapters.js';
|
|
11
12
|
import { isActionTarget, isForm, isInput, isSelect, isTextArea, } from '../dom/realm.js';
|
|
12
13
|
import { asSettleBudget, nativeSetTimeout, settle } from '../timers/native/native-timers.js';
|
|
@@ -99,7 +100,7 @@ function optionValueFor(options, named) {
|
|
|
99
100
|
const byLabel = options.filter((option) => option.label.trim() === named.trim());
|
|
100
101
|
return 1 === byLabel.length ? (byLabel[0]?.value ?? named) : named;
|
|
101
102
|
}
|
|
102
|
-
import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, assertNotLeaving, } from './actions-dom.js';
|
|
103
|
+
import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, assertNotLeaving, clickInputOf, } from './actions-dom.js';
|
|
103
104
|
import { scrollFrom } from './scroll.js';
|
|
104
105
|
import { clickHitsNoControl } from './click-target.js';
|
|
105
106
|
function enabledOf(el) {
|
|
@@ -121,7 +122,7 @@ function activeRef(el) {
|
|
|
121
122
|
async function dispatchFor(el, action, args) {
|
|
122
123
|
if (ActionType.CLICK === action) {
|
|
123
124
|
const hold = clampHoldMs(args['holdMs']);
|
|
124
|
-
return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
|
|
125
|
+
return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() }, undefined, clickInputOf(args));
|
|
125
126
|
}
|
|
126
127
|
if (ActionType.TAP === action) {
|
|
127
128
|
const hold = clampHoldMs(args['holdMs']);
|
|
@@ -132,15 +133,27 @@ async function dispatchFor(el, action, args) {
|
|
|
132
133
|
export const ZOOM_NEEDS_REAL_BROWSER_MSG = 'cannot zoom from inside the page — CSS zoom changes how it LOOKS without changing the layout ' +
|
|
133
134
|
'viewport, visualViewport or media queries, so a layout that breaks at 200% would be reported as ' +
|
|
134
135
|
'checked and passing. Drive a real browser (reticle drive <url>, or reticle_lease) and zoom there.';
|
|
136
|
+
function isAriaCombobox(el) {
|
|
137
|
+
return 'combobox' === getRole(el) || 'listbox' === el.getAttribute('aria-haspopup');
|
|
138
|
+
}
|
|
139
|
+
const SELECT_ON_COMBOBOX_MSG = (tag) => `cannot select on a <${tag}> — it is an ARIA combobox, not a native <select>. ` +
|
|
140
|
+
'Click the combobox to open it, then click the option with { role: "option", name: "<label>" }.';
|
|
135
141
|
function asFiniteNumber(raw) {
|
|
136
142
|
return 'number' === typeof raw && Number.isFinite(raw) ? raw : undefined;
|
|
137
143
|
}
|
|
138
144
|
async function dispatchOther(el, action, args) {
|
|
139
145
|
switch (action) {
|
|
140
|
-
case ActionType.DBLCLICK:
|
|
141
|
-
|
|
142
|
-
await fireClickSequence(el, undefined,
|
|
143
|
-
|
|
146
|
+
case ActionType.DBLCLICK: {
|
|
147
|
+
const input = clickInputOf(args);
|
|
148
|
+
await fireClickSequence(el, undefined, 1, input);
|
|
149
|
+
await fireClickSequence(el, undefined, 2, input);
|
|
150
|
+
return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', {
|
|
151
|
+
bubbles: true,
|
|
152
|
+
cancelable: true,
|
|
153
|
+
detail: 2,
|
|
154
|
+
...input.modifiers,
|
|
155
|
+
})));
|
|
156
|
+
}
|
|
144
157
|
case ActionType.HOVER: {
|
|
145
158
|
const doc = el.ownerDocument;
|
|
146
159
|
const from = doc.activeElement ?? doc.body;
|
|
@@ -215,6 +228,12 @@ async function dispatchOther(el, action, args) {
|
|
|
215
228
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
216
229
|
return false;
|
|
217
230
|
}
|
|
231
|
+
if (isAriaCombobox(el)) {
|
|
232
|
+
if (typeof args['value'] !== 'string') {
|
|
233
|
+
throw new Error("select requires a string `value` — pass it nested, as args: { value: '…' }.");
|
|
234
|
+
}
|
|
235
|
+
return await selectInCombobox(el, args['value'], (target) => fireClickSequence(target), SELECT_ON_COMBOBOX_MSG(el.tagName.toLowerCase()));
|
|
236
|
+
}
|
|
218
237
|
throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
|
|
219
238
|
case ActionType.CHECK:
|
|
220
239
|
case ActionType.UNCHECK: {
|
|
@@ -325,6 +344,12 @@ async function dispatchOther(el, action, args) {
|
|
|
325
344
|
}
|
|
326
345
|
export async function executeAction(ref, action, args = {}) {
|
|
327
346
|
const named = 0 < ref.length;
|
|
347
|
+
if (ActionType.CLICK === action || ActionType.DBLCLICK === action) {
|
|
348
|
+
const problem = clickInputProblem(action, args);
|
|
349
|
+
if (problem !== undefined) {
|
|
350
|
+
throw new Error(`cannot ${action}: ${problem}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
328
353
|
const el = isReflessDocumentPress(ref, action, args) ? focusedOrDocument() : requireElement(ref);
|
|
329
354
|
if (named) {
|
|
330
355
|
assertActionAllowed(el, action, args);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ActionTarget } from '../dom/realm.js';
|
|
2
|
+
type ClickFn = (el: ActionTarget) => Promise<{
|
|
3
|
+
prevented: boolean;
|
|
4
|
+
}>;
|
|
5
|
+
export declare function selectInCombobox(trigger: ActionTarget, wanted: string, click: ClickFn, noPopupMessage: string): Promise<boolean>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { echoRef } from '../dom/addressing/refs.js';
|
|
2
|
+
import { getAccessibleName } from '../dom/a11y.js';
|
|
3
|
+
import { isReticleUi } from '../dom/dom-ignore.js';
|
|
4
|
+
import { isVisible } from '../dom/visibility.js';
|
|
5
|
+
import { nativeFrame } from '../timers/native/native-timers.js';
|
|
6
|
+
const OPEN_WAIT_FRAMES = 30;
|
|
7
|
+
const ROLE_OPTION = 'option';
|
|
8
|
+
function optionName(option) {
|
|
9
|
+
return getAccessibleName(option).trim();
|
|
10
|
+
}
|
|
11
|
+
function visibleOptions(trigger) {
|
|
12
|
+
const doc = trigger.ownerDocument;
|
|
13
|
+
const ids = (trigger.getAttribute('aria-controls') ?? trigger.getAttribute('aria-owns') ?? '')
|
|
14
|
+
.split(/\s+/)
|
|
15
|
+
.filter((id) => 0 < id.length);
|
|
16
|
+
const popups = ids
|
|
17
|
+
.map((id) => doc.getElementById(id))
|
|
18
|
+
.filter((popup) => null !== popup);
|
|
19
|
+
const roots = 0 < popups.length ? popups : [doc];
|
|
20
|
+
return roots
|
|
21
|
+
.flatMap((root) => Array.from(root.querySelectorAll(`[role="${ROLE_OPTION}"]`)))
|
|
22
|
+
.filter((option) => !isReticleUi(option) && isVisible(option));
|
|
23
|
+
}
|
|
24
|
+
async function waitForOptions(trigger) {
|
|
25
|
+
for (let frame = 0; frame < OPEN_WAIT_FRAMES; frame += 1) {
|
|
26
|
+
const options = visibleOptions(trigger);
|
|
27
|
+
if (0 < options.length) {
|
|
28
|
+
return options;
|
|
29
|
+
}
|
|
30
|
+
await nativeFrame();
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
function matches(option, wanted) {
|
|
35
|
+
const asked = wanted.trim();
|
|
36
|
+
return (optionName(option) === asked ||
|
|
37
|
+
option.getAttribute('data-value') === wanted ||
|
|
38
|
+
option.getAttribute('value') === wanted);
|
|
39
|
+
}
|
|
40
|
+
export async function selectInCombobox(trigger, wanted, click, noPopupMessage) {
|
|
41
|
+
const openedHere = 'true' !== trigger.getAttribute('aria-expanded');
|
|
42
|
+
if (openedHere) {
|
|
43
|
+
await click(trigger);
|
|
44
|
+
}
|
|
45
|
+
const options = await waitForOptions(trigger);
|
|
46
|
+
const found = options.filter((option) => matches(option, wanted));
|
|
47
|
+
const [match] = found;
|
|
48
|
+
const problem = 0 === options.length ? noPopupMessage : problemWith(found, options, wanted);
|
|
49
|
+
if (match === undefined || problem !== undefined) {
|
|
50
|
+
if (openedHere) {
|
|
51
|
+
await click(trigger);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(problem ?? 'no option');
|
|
54
|
+
}
|
|
55
|
+
return (await click(match)).prevented;
|
|
56
|
+
}
|
|
57
|
+
function problemWith(found, options, wanted) {
|
|
58
|
+
const [match] = found;
|
|
59
|
+
if (match === undefined) {
|
|
60
|
+
return `no option '${echoRef(wanted)}' — available: ${options.map(optionName).join(', ')}`;
|
|
61
|
+
}
|
|
62
|
+
if (1 < found.length) {
|
|
63
|
+
return `${found.length} options match '${echoRef(wanted)}' — name one by its exact label`;
|
|
64
|
+
}
|
|
65
|
+
if ('true' === match.getAttribute('aria-disabled')) {
|
|
66
|
+
return `cannot select a disabled option '${echoRef(wanted)}' — a real user could not, so neither will Reticle`;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|