@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
- 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,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
- await fireClickSequence(el, undefined, 1);
142
- await fireClickSequence(el, undefined, 2);
143
- 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
+ }
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
+ }