@reticlehq/browser 3.8.0 → 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.
@@ -10,13 +10,15 @@ export function visibleCanvases(doc) {
10
10
  const height = doc.defaultView?.innerHeight ?? 0;
11
11
  let count = 0;
12
12
  for (const canvas of doc.querySelectorAll(CANVAS_SELECTOR)) {
13
- if (isReticleUi(canvas))
13
+ if (isReticleUi(canvas)) {
14
14
  continue;
15
+ }
15
16
  const box = canvas.getBoundingClientRect();
16
17
  const sized = box.width > 0 && box.height > 0;
17
18
  const onScreen = box.bottom > 0 && box.right > 0 && box.top < height && box.left < width;
18
- if (sized && onScreen)
19
+ if (sized && onScreen) {
19
20
  count += 1;
21
+ }
20
22
  }
21
23
  return count;
22
24
  }
@@ -27,22 +29,28 @@ export function anchorOf(el) {
27
29
  if (testid !== undefined) {
28
30
  out.testid = testid;
29
31
  const shared = elementsMatching({ by: QueryBy.TESTID, value: testid }).length;
30
- if (1 < shared)
32
+ if (1 < shared) {
31
33
  out.testidOf = shared;
34
+ }
32
35
  }
33
36
  const component = info?.componentStack[0];
34
- if (component !== undefined)
37
+ if (component !== undefined) {
35
38
  out.component = component;
36
- if (info?.source !== undefined)
39
+ }
40
+ if (info?.source !== undefined) {
37
41
  out.source = info.source;
42
+ }
38
43
  const role = getRole(el);
39
44
  const name = getAccessibleName(el);
40
- if (role.length > 0)
45
+ if (role.length > 0) {
41
46
  out.role = role;
42
- if (name.length > 0)
47
+ }
48
+ if (name.length > 0) {
43
49
  out.name = name;
44
- if (isSensitiveField(el))
50
+ }
51
+ if (isSensitiveField(el)) {
45
52
  out.secret = true;
53
+ }
46
54
  if (role.length > 0 && name.length > 0) {
47
55
  const same = elementsMatching({ by: QueryBy.ROLE, value: role, name });
48
56
  const at = same.indexOf(el);
@@ -63,25 +71,35 @@ export const result = (ref, action, effect, settled, settleReason, anchor, warni
63
71
  settleReason,
64
72
  effect,
65
73
  };
66
- if (anchor.testid !== undefined)
74
+ if (anchor.testid !== undefined) {
67
75
  base.testid = anchor.testid;
68
- if (anchor.testidOf !== undefined)
76
+ }
77
+ if (anchor.testidOf !== undefined) {
69
78
  base.testidOf = anchor.testidOf;
70
- if (anchor.component !== undefined)
79
+ }
80
+ if (anchor.component !== undefined) {
71
81
  base.component = anchor.component;
72
- if (anchor.source !== undefined)
82
+ }
83
+ if (anchor.source !== undefined) {
73
84
  base.source = anchor.source;
74
- if (anchor.role !== undefined)
85
+ }
86
+ if (anchor.role !== undefined) {
75
87
  base.role = anchor.role;
76
- if (anchor.name !== undefined)
88
+ }
89
+ if (anchor.name !== undefined) {
77
90
  base.name = anchor.name;
78
- if (anchor.nth !== undefined)
91
+ }
92
+ if (anchor.nth !== undefined) {
79
93
  base.nth = anchor.nth;
80
- if (anchor.of !== undefined)
94
+ }
95
+ if (anchor.of !== undefined) {
81
96
  base.of = anchor.of;
82
- if (anchor.secret !== undefined)
97
+ }
98
+ if (anchor.secret !== undefined) {
83
99
  base.secret = anchor.secret;
84
- if (warning !== undefined)
100
+ }
101
+ if (warning !== undefined) {
85
102
  base.warning = warning;
103
+ }
86
104
  return base;
87
105
  };
@@ -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
- function fireAt(el, type, at, buttons, related, detail = 0) {
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: 0,
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
- asSyntheticInput(() => fireAt(el, 'pointerdown', at, BUTTON_HELD, from, d));
46
- asSyntheticInput(() => fireAt(el, 'mousedown', at, BUTTON_HELD, undefined, d));
47
- if (el.tabIndex >= 0 && 'function' === typeof el.focus)
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 notPrevented = asSyntheticInput(() => fireAt(el, 'click', at, BUTTON_RELEASED, undefined, d));
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
- if (!POPUP_ROLES.has(up.getAttribute('role') ?? ''))
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
  }
@@ -1,11 +1,13 @@
1
- import { ActionType, ActionWarning, clampHoldMs, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
2
- import { isHiddenNativeOption } from '../dom/query.js';
1
+ import { ActionType, ActionWarning, clampHoldMs, clickInputProblem, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
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 { isVisible, getStates } from '../dom/a11y.js';
8
+ import { getRole, getStates } from '../dom/a11y.js';
9
+ import { isVisible } from '../dom/visibility.js';
10
+ import { selectInCombobox } from './select-combobox.js';
9
11
  import { elementHasHoverHandlers } from '../registry/stores/adapters.js';
10
12
  import { isActionTarget, isForm, isInput, isSelect, isTextArea, } from '../dom/realm.js';
11
13
  import { asSettleBudget, nativeSetTimeout, settle } from '../timers/native/native-timers.js';
@@ -19,10 +21,12 @@ function asString(value, fallback = '') {
19
21
  }
20
22
  function requireElement(ref) {
21
23
  const el = refs.resolve(ref);
22
- if (null === el)
24
+ if (null === el) {
23
25
  throw new Error(`ref '${echoRef(ref)}' no longer resolves to an element`);
24
- if (!isActionTarget(el))
26
+ }
27
+ if (!isActionTarget(el)) {
25
28
  throw new Error(`ref '${echoRef(ref)}' is not an HTML or SVG element`);
29
+ }
26
30
  return el;
27
31
  }
28
32
  const FILL_LIKE = new Set([
@@ -51,8 +55,9 @@ const GENERIC_ACTION_ARG_KEYS = new Set([
51
55
  function assertUploadArgs(args) {
52
56
  const keys = Object.keys(args);
53
57
  const dropped = keys.filter((k) => !UPLOAD_ARG_KEYS.has(k) && !GENERIC_ACTION_ARG_KEYS.has(k));
54
- if (0 === dropped.length && keys.some((k) => UPLOAD_ARG_KEYS.has(k)))
58
+ if (0 === dropped.length && keys.some((k) => UPLOAD_ARG_KEYS.has(k))) {
55
59
  return;
60
+ }
56
61
  const detail = 0 === dropped.length
57
62
  ? 'no file was described'
58
63
  : `upload does not read ${dropped.join(', ')}, so it would be dropped`;
@@ -70,10 +75,12 @@ function assertActionAllowed(el, action, args) {
70
75
  (action === ActionType.PRESS && 'Enter' === pressKey(args));
71
76
  const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
72
77
  const submitter = action === ActionType.PRESS && 'Enter' === pressKey(args) ? submitControlFor(el) : null;
73
- if (canTrigger)
78
+ if (canTrigger) {
74
79
  assertOutwardAllowed([el, submitter, isActionTarget(dragTarget) ? dragTarget : null]);
75
- if (action === ActionType.FILL || action === ActionType.TYPE)
80
+ }
81
+ if (action === ActionType.FILL || action === ActionType.TYPE) {
76
82
  assertPaymentEntryAllowed(el);
83
+ }
77
84
  const sourceDangerous = requiresDangerousConfirmation(el) ||
78
85
  (submitter !== null && requiresDangerousConfirmation(submitter));
79
86
  const targetDangerous = isActionTarget(dragTarget) && requiresDangerousConfirmation(dragTarget);
@@ -87,12 +94,13 @@ function assertActionAllowed(el, action, args) {
87
94
  }
88
95
  }
89
96
  function optionValueFor(options, named) {
90
- if (options.some((option) => option.value === named))
97
+ if (options.some((option) => option.value === named)) {
91
98
  return named;
99
+ }
92
100
  const byLabel = options.filter((option) => option.label.trim() === named.trim());
93
101
  return 1 === byLabel.length ? (byLabel[0]?.value ?? named) : named;
94
102
  }
95
- 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';
96
104
  import { scrollFrom } from './scroll.js';
97
105
  import { clickHitsNoControl } from './click-target.js';
98
106
  function enabledOf(el) {
@@ -106,14 +114,15 @@ function valueOf(el) {
106
114
  }
107
115
  function activeRef(el) {
108
116
  const active = el.ownerDocument.activeElement;
109
- if (null === active || active === el.ownerDocument.body)
117
+ if (null === active || active === el.ownerDocument.body) {
110
118
  return null;
119
+ }
111
120
  return refs.refFor(active);
112
121
  }
113
122
  async function dispatchFor(el, action, args) {
114
123
  if (ActionType.CLICK === action) {
115
124
  const hold = clampHoldMs(args['holdMs']);
116
- 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));
117
126
  }
118
127
  if (ActionType.TAP === action) {
119
128
  const hold = clampHoldMs(args['holdMs']);
@@ -124,15 +133,27 @@ async function dispatchFor(el, action, args) {
124
133
  export const ZOOM_NEEDS_REAL_BROWSER_MSG = 'cannot zoom from inside the page — CSS zoom changes how it LOOKS without changing the layout ' +
125
134
  'viewport, visualViewport or media queries, so a layout that breaks at 200% would be reported as ' +
126
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>" }.';
127
141
  function asFiniteNumber(raw) {
128
142
  return 'number' === typeof raw && Number.isFinite(raw) ? raw : undefined;
129
143
  }
130
144
  async function dispatchOther(el, action, args) {
131
145
  switch (action) {
132
- case ActionType.DBLCLICK:
133
- await fireClickSequence(el, undefined, 1);
134
- await fireClickSequence(el, undefined, 2);
135
- return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true, detail: 2 })));
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
+ }
136
157
  case ActionType.HOVER: {
137
158
  const doc = el.ownerDocument;
138
159
  const from = doc.activeElement ?? doc.body;
@@ -143,8 +164,9 @@ async function dispatchOther(el, action, args) {
143
164
  firePointer(el, 'pointermove', from);
144
165
  const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true }));
145
166
  const holdMs = clampHoldMs(args['holdMs']);
146
- if (holdMs > 0)
167
+ if (holdMs > 0) {
147
168
  await sleep(holdMs);
169
+ }
148
170
  return !moved;
149
171
  }
150
172
  case ActionType.FOCUS:
@@ -168,8 +190,9 @@ async function dispatchOther(el, action, args) {
168
190
  'To empty a field on purpose use the `clear` action, or fill with an explicit "".');
169
191
  }
170
192
  assertEditable(el, 'fill');
171
- if (!isAutofillSafeTarget(el))
193
+ if (!isAutofillSafeTarget(el)) {
172
194
  el.focus();
195
+ }
173
196
  return setNativeValue(el, args['value']);
174
197
  }
175
198
  assertNotRichText(el, 'fill');
@@ -205,6 +228,12 @@ async function dispatchOther(el, action, args) {
205
228
  el.dispatchEvent(new Event('change', { bubbles: true }));
206
229
  return false;
207
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
+ }
208
237
  throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
209
238
  case ActionType.CHECK:
210
239
  case ActionType.UNCHECK: {
@@ -221,8 +250,9 @@ async function dispatchOther(el, action, args) {
221
250
  if ('radio' === el.type && action === ActionType.UNCHECK) {
222
251
  throw new Error('cannot uncheck a radio button — a real user could not; select another radio in the group');
223
252
  }
224
- if (alreadyAtCheckedState(el, action))
253
+ if (alreadyAtCheckedState(el, action)) {
225
254
  return false;
255
+ }
226
256
  const event = mouseEventFor(el, 'click', {
227
257
  bubbles: true,
228
258
  cancelable: true,
@@ -233,25 +263,29 @@ async function dispatchOther(el, action, args) {
233
263
  }
234
264
  case ActionType.SUBMIT: {
235
265
  const form = isForm(el) ? el : el.closest('form');
236
- if (null === form)
266
+ if (null === form) {
237
267
  throw new Error('no form to submit');
268
+ }
238
269
  form.requestSubmit();
239
270
  return false;
240
271
  }
241
272
  case ActionType.PRESS: {
242
273
  const mods = pressModifiers(args);
243
274
  const combo = pressKeys(args);
244
- if (combo.length > 0)
275
+ if (combo.length > 0) {
245
276
  return await pressCombo(el, combo, mods, clampHoldMs(args['holdMs']));
277
+ }
246
278
  const key = pressKey(args);
247
279
  const code = pressCode(args, key);
248
280
  const down = asSyntheticInput(() => el.dispatchEvent(keyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
249
281
  closeModalOnEscape(el, key, down);
250
- if (down)
282
+ if (down) {
251
283
  dispatchKeypress(el, key, code, mods);
284
+ }
252
285
  const hold = clampHoldMs(args['holdMs']);
253
- if (hold > 0)
286
+ if (hold > 0) {
254
287
  await holdKey(el, key, code, mods, hold);
288
+ }
255
289
  asSyntheticInput(() => el.dispatchEvent(keyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
256
290
  return !down;
257
291
  }
@@ -277,8 +311,9 @@ async function dispatchOther(el, action, args) {
277
311
  if (true === args['__base64']) {
278
312
  const bin = atob(rawContent);
279
313
  const arr = new Uint8Array(bin.length);
280
- for (let i = 0; i < bin.length; i++)
314
+ for (let i = 0; i < bin.length; i++) {
281
315
  arr[i] = bin.charCodeAt(i);
316
+ }
282
317
  fileBody = arr.buffer.slice(0);
283
318
  }
284
319
  const file = new File([fileBody], asString(args['name'], 'file.txt'), {
@@ -293,8 +328,9 @@ async function dispatchOther(el, action, args) {
293
328
  }
294
329
  case ActionType.DRAG: {
295
330
  const toRef = dragTargetRef(args);
296
- if ('' === toRef)
331
+ if ('' === toRef) {
297
332
  return await dragElement(el, null, args['data']);
333
+ }
298
334
  const resolved = refs.resolve(toRef);
299
335
  if (!isActionTarget(resolved)) {
300
336
  throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
@@ -308,11 +344,19 @@ async function dispatchOther(el, action, args) {
308
344
  }
309
345
  export async function executeAction(ref, action, args = {}) {
310
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
+ }
311
353
  const el = isReflessDocumentPress(ref, action, args) ? focusedOrDocument() : requireElement(ref);
312
- if (named)
354
+ if (named) {
313
355
  assertActionAllowed(el, action, args);
314
- if (named)
356
+ }
357
+ if (named) {
315
358
  assertNotLeaving(el);
359
+ }
316
360
  const anchor = named ? anchorOf(el) : {};
317
361
  const visible = isVisible(el);
318
362
  const enabled = enabledOf(el);
@@ -376,15 +420,19 @@ export async function executeAction(ref, action, args = {}) {
376
420
  ...(alreadyAtValue ? { alreadyAtValue: true } : {}),
377
421
  ...canvasesOnScreen(),
378
422
  };
379
- const warning = !named
380
- ? ActionWarning.GLOBAL_PRESS
381
- : geometry.occluded
382
- ? ActionWarning.CLICK_OCCLUDED
383
- : action === ActionType.HOVER && elementHasHoverHandlers(el)
384
- ? ActionWarning.HOVER_NATIVE_ENTER_LEAVE
385
- : hitsNoControl
386
- ? ActionWarning.CLICK_NOT_A_CONTROL
387
- : undefined;
423
+ let warning;
424
+ if (!named) {
425
+ warning = ActionWarning.GLOBAL_PRESS;
426
+ }
427
+ else if (geometry.occluded) {
428
+ warning = ActionWarning.CLICK_OCCLUDED;
429
+ }
430
+ else if (action === ActionType.HOVER && elementHasHoverHandlers(el)) {
431
+ warning = ActionWarning.HOVER_NATIVE_ENTER_LEAVE;
432
+ }
433
+ else if (hitsNoControl) {
434
+ warning = ActionWarning.CLICK_NOT_A_CONTROL;
435
+ }
388
436
  const done = result(ref, action, effect, settled, settleReason, anchor, warning);
389
437
  return isReticleUi(el) ? { ...done, reticleUi: true } : done;
390
438
  }
@@ -406,20 +454,27 @@ export async function executeSequence(steps) {
406
454
  settled: res.settled,
407
455
  settleReason: res.settleReason,
408
456
  };
409
- if (res.testid !== undefined)
457
+ if (res.testid !== undefined) {
410
458
  stepBase.testid = res.testid;
411
- if (res.component !== undefined)
459
+ }
460
+ if (res.component !== undefined) {
412
461
  stepBase.component = res.component;
413
- if (res.role !== undefined)
462
+ }
463
+ if (res.role !== undefined) {
414
464
  stepBase.role = res.role;
415
- if (res.name !== undefined)
465
+ }
466
+ if (res.name !== undefined) {
416
467
  stepBase.name = res.name;
417
- if (res.source !== undefined)
468
+ }
469
+ if (res.source !== undefined) {
418
470
  stepBase.source = res.source;
419
- if (res.secret !== undefined)
471
+ }
472
+ if (res.secret !== undefined) {
420
473
  stepBase.secret = res.secret;
421
- if (res.warning !== undefined)
474
+ }
475
+ if (res.warning !== undefined) {
422
476
  stepBase.warning = res.warning;
477
+ }
423
478
  stepResults.push(stepBase);
424
479
  }
425
480
  return { ok: true, count: steps.length, effects, steps: stepResults };