@reticlehq/browser 3.8.1 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,14 +11,21 @@ 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
  }>;
28
+ export declare function scrollIntoViewNow(el: Element, options?: ScrollIntoViewOptions): void;
20
29
  export declare function isOffViewport(el: Element, rect: DOMRect): boolean;
21
30
  export declare function clickGeometry(el: ActionTarget): ClickGeometry;
22
31
  export declare function firePointer(el: Element, type: string, relatedTarget?: EventTarget | null): void;
@@ -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,40 +47,55 @@ 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) {
61
89
  return rect.width > 0 || rect.height > 0;
62
90
  }
91
+ export function scrollIntoViewNow(el, options = {}) {
92
+ el.scrollIntoView({ ...options, behavior: 'instant' });
93
+ }
63
94
  export function isOffViewport(el, rect) {
64
95
  const win = el.ownerDocument.defaultView;
65
- if (null === win)
96
+ if (null === win) {
66
97
  return false;
98
+ }
67
99
  const cx = rect.left + rect.width / 2;
68
100
  const cy = rect.top + rect.height / 2;
69
101
  return cx < 0 || cy < 0 || cx > win.innerWidth || cy > win.innerHeight;
@@ -76,26 +108,30 @@ function hitTest(el, rect) {
76
108
  }
77
109
  function nameOf(el) {
78
110
  const testid = readTestId(el);
79
- if (testid !== null && testid.length > 0)
111
+ if (testid !== null && testid.length > 0) {
80
112
  return `[data-testid="${testid}"]`;
113
+ }
81
114
  const name = getAccessibleName(el).trim();
82
115
  return name.length > 0 ? `${getRole(el)} "${name.slice(0, 40)}"` : el.tagName.toLowerCase();
83
116
  }
84
117
  function hasNoSize(el) {
85
- if (typeof el.getClientRects !== 'function')
118
+ if (typeof el.getClientRects !== 'function') {
86
119
  return false;
120
+ }
87
121
  const rects = Array.from(el.getClientRects());
88
122
  return rects.length > 0 && rects.every((r) => 0 === r.width || 0 === r.height);
89
123
  }
90
124
  export function clickGeometry(el) {
91
- if (typeof el.getBoundingClientRect !== 'function')
125
+ if (typeof el.getBoundingClientRect !== 'function') {
92
126
  return NO_GEOMETRY;
127
+ }
93
128
  let rect = el.getBoundingClientRect();
94
- if (!isMeasurable(rect))
129
+ if (!isMeasurable(rect)) {
95
130
  return hasNoSize(el) ? { ...NO_GEOMETRY, noSize: true } : NO_GEOMETRY;
131
+ }
96
132
  let scrolledIntoView = false;
97
133
  if (isOffViewport(el, rect) && 'function' === typeof el.scrollIntoView) {
98
- el.scrollIntoView({ block: 'center', inline: 'center' });
134
+ scrollIntoViewNow(el, { block: 'center', inline: 'center' });
99
135
  scrolledIntoView = true;
100
136
  rect = el.getBoundingClientRect();
101
137
  }
@@ -109,15 +145,17 @@ export function firePointerNonBubbling(el, type, relatedTarget = null) {
109
145
  asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget })));
110
146
  }
111
147
  function makeDataTransfer(data) {
112
- if (typeof DataTransfer !== 'function')
148
+ if (typeof DataTransfer !== 'function') {
113
149
  return null;
150
+ }
114
151
  const dt = new DataTransfer();
115
152
  const entries = Array.isArray(data) ? data : data !== undefined ? [data] : [];
116
153
  for (const entry of entries) {
117
154
  if ('object' === typeof entry && entry !== null) {
118
155
  const e = entry;
119
- if ('string' === typeof e.mime && 'string' === typeof e.value)
156
+ if ('string' === typeof e.mime && 'string' === typeof e.value) {
120
157
  dt.setData(e.mime, e.value);
158
+ }
121
159
  }
122
160
  }
123
161
  return dt;
@@ -174,8 +212,9 @@ export async function dragElement(source, target, data) {
174
212
  if ('function' === typeof DragEvent) {
175
213
  const dataTransfer = makeDataTransfer(data);
176
214
  const init = { bubbles: true, cancelable: true };
177
- if (dataTransfer !== null)
215
+ if (dataTransfer !== null) {
178
216
  init.dataTransfer = dataTransfer;
217
+ }
179
218
  source.dispatchEvent(new DragEvent('dragstart', init));
180
219
  await nativeFrame();
181
220
  dest.dispatchEvent(new DragEvent('dragenter', init));
@@ -190,8 +229,9 @@ export async function fireTapSequence(el, hold) {
190
229
  const touches = touchListFor(el);
191
230
  firePointerTouch(el, 'pointerdown');
192
231
  asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchstart', touches)));
193
- if (el.tabIndex >= 0 && 'function' === typeof el.focus)
232
+ if (el.tabIndex >= 0 && 'function' === typeof el.focus) {
194
233
  el.focus();
234
+ }
195
235
  let heldMs = 0;
196
236
  if (hold !== undefined && hold.ms > 0) {
197
237
  const started = hold.now();
@@ -214,8 +254,9 @@ function firePointerTouch(el, type) {
214
254
  })));
215
255
  }
216
256
  function touchListFor(el) {
217
- if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect)
257
+ if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect) {
218
258
  return [];
259
+ }
219
260
  const rect = el.getBoundingClientRect();
220
261
  try {
221
262
  return [
@@ -251,10 +292,12 @@ const POPUP_ROLES = new Set(['menu', 'menubar', 'listbox', 'dialog', 'alertdialo
251
292
  const CLOSED_STATE = 'closed';
252
293
  export function assertNotLeaving(el) {
253
294
  for (let up = el.parentElement; up !== null; up = up.parentElement) {
254
- if (CLOSED_STATE !== up.getAttribute('data-state'))
295
+ if (CLOSED_STATE !== up.getAttribute('data-state')) {
255
296
  continue;
256
- if (!POPUP_ROLES.has(up.getAttribute('role') ?? ''))
297
+ }
298
+ if (!POPUP_ROLES.has(up.getAttribute('role') ?? '')) {
257
299
  continue;
300
+ }
258
301
  throw new Error(`'${up.getAttribute('role') ?? 'popup'}' holding this element is closing (data-state="closed"), ` +
259
302
  'so a person could not click it: look again once it has gone and act on what is open');
260
303
  }
@@ -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, scrollIntoViewNow, 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: {
@@ -271,7 +290,7 @@ async function dispatchOther(el, action, args) {
271
290
  return !down;
272
291
  }
273
292
  case ActionType.SCROLL_INTO_VIEW:
274
- el.scrollIntoView();
293
+ scrollIntoViewNow(el);
275
294
  return false;
276
295
  case ActionType.ZOOM:
277
296
  throw new Error(ZOOM_NEEDS_REAL_BROWSER_MSG);
@@ -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);
@@ -333,9 +358,19 @@ export async function executeAction(ref, action, args = {}) {
333
358
  assertNotLeaving(el);
334
359
  }
335
360
  const anchor = named ? anchorOf(el) : {};
361
+ const prevFocus = activeRef(el);
362
+ if ((ActionType.CLICK === action || ActionType.DBLCLICK === action) && !enabledOf(el)) {
363
+ const active = el.ownerDocument.activeElement;
364
+ if (active !== null &&
365
+ active !== el &&
366
+ active !== el.ownerDocument.body &&
367
+ isActionTarget(active)) {
368
+ active.blur();
369
+ await Promise.resolve();
370
+ }
371
+ }
336
372
  const visible = isVisible(el);
337
373
  const enabled = enabledOf(el);
338
- const prevFocus = activeRef(el);
339
374
  const valueBefore = valueOf(el);
340
375
  const actedField = watchActedField(el);
341
376
  const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { ComponentStateReason, ElementQuerySchema, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, ReticleCommand, SnapshotMode, TRANSPORT_LIMITS, selectPath, capDepth, } from '@reticlehq/core';
1
+ import { ComponentStateReason, ElementQuerySchema, INSPECT_IN_VIEWPORT_FIELD, INSPECT_REVEAL_ARG, MatchArg, ReticleCommand, SnapshotMode, TRANSPORT_LIMITS, selectPath, capDepth, } from '@reticlehq/core';
2
2
  import { buildSnapshot } from '../dom/snapshot.js';
3
3
  import { paintContextOf } from '../dom/paint-context.js';
4
4
  import { matchQuery, runQuery } from '../dom/query.js';
@@ -19,7 +19,7 @@ import { sanitizeWithReport } from '../security/serialization.js';
19
19
  import { getCapabilities } from '../registry/capabilities.js';
20
20
  import { freezeClock, advanceClock, resetClock, isClockFrozen } from '../timers/clock.js';
21
21
  import { scrollContainer } from '../actions/scroll.js';
22
- import { isOffViewport } from '../actions/actions-dom.js';
22
+ import { isOffViewport, scrollIntoViewNow } from '../actions/actions-dom.js';
23
23
  export const RELOAD_CACHE_BUST_PARAM = '_reticle_reload';
24
24
  function str(value) {
25
25
  return 'string' === typeof value ? value : undefined;
@@ -69,7 +69,7 @@ function inspect(ref, reveal = false) {
69
69
  'function' === typeof el.scrollIntoView &&
70
70
  isOffViewport(el, el.getBoundingClientRect());
71
71
  if (scrolledIntoView) {
72
- el.scrollIntoView({ block: 'center', inline: 'center' });
72
+ scrollIntoViewNow(el, { block: 'center', inline: 'center' });
73
73
  }
74
74
  const rect = el.getBoundingClientRect();
75
75
  const component = identifyComponent(el);
@@ -216,7 +216,7 @@ export function createCommandRegistry() {
216
216
  const limit = args['limit'];
217
217
  return runQuery(queryFromArgs(args), 'number' === typeof limit ? limit : undefined);
218
218
  });
219
- reg.set(ReticleCommand.MATCH, (args) => matchQuery(ElementQuerySchema.parse(record(args['query'])), str(args['state']), undefined, undefined, true === args['diagnose']));
219
+ reg.set(ReticleCommand.MATCH, (args) => matchQuery(ElementQuerySchema.parse(record(args['query'])), str(args['state']), undefined, undefined, true === args['diagnose'], true === args[MatchArg.FULL_TEXT]));
220
220
  reg.set(ReticleCommand.ACT, async (args) => {
221
221
  const action = str(args['action']) ?? '';
222
222
  const result = await executeAction(str(args['ref']) ?? '', action, record(args['args']));
@@ -4,4 +4,4 @@ export declare function getAccessibleName(el: Element): string;
4
4
  export declare function getStates(el: Element, visible?: boolean): ElementState[];
5
5
  export declare function isSensitiveField(el: Element): boolean;
6
6
  export declare function getValue(el: Element): string | undefined;
7
- export declare function describe(el: Element, memo?: Map<Element, boolean>): ElementDescriptor;
7
+ export declare function describe(el: Element, memo?: Map<Element, boolean>, fullText?: boolean): ElementDescriptor;
package/dist/dom/a11y.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readTestId } from './addressing/testid-attr.js';
2
- import { DEFAULT_TESTID_ATTR, ElementState, REDACTED_VALUE, } from '@reticlehq/core';
2
+ import { DEFAULT_TESTID_ATTR, ElementState, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
3
  import { isButton, isImage, isInput, isMeter, isOutput, isProgress, isSelect, isTextArea, } from './realm.js';
4
4
  import { refs } from './addressing/refs.js';
5
5
  import { inspectChart } from './chart.js';
@@ -360,14 +360,14 @@ export function getValue(el) {
360
360
  const valueNow = el.getAttribute('aria-valuenow');
361
361
  return valueNow ?? undefined;
362
362
  }
363
- const MAX_TEXT = 80;
364
- function getVisibleText(el) {
363
+ function getVisibleText(el, fullText) {
365
364
  const text = collapse(el.textContent ?? '');
366
- return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}…` : text;
365
+ const max = fullText ? TRANSPORT_LIMITS.MAX_FULL_TEXT : TRANSPORT_LIMITS.MAX_DESCRIBED_TEXT;
366
+ return text.length > max ? `${text.slice(0, max)}…` : text;
367
367
  }
368
- export function describe(el, memo) {
368
+ export function describe(el, memo, fullText = false) {
369
369
  const value = getValue(el);
370
- const text = getVisibleText(el);
370
+ const text = getVisibleText(el, fullText);
371
371
  const name = getAccessibleName(el);
372
372
  const visible = isVisible(el, memo);
373
373
  const base = {
@@ -380,7 +380,7 @@ export function describe(el, memo) {
380
380
  if (value !== undefined && value.length > 0) {
381
381
  base.value = value;
382
382
  }
383
- if (text.length > 0 && text !== name) {
383
+ if (text.length > 0 && (fullText || text !== name)) {
384
384
  base.text = text;
385
385
  }
386
386
  const source = formatSource(sourceFromDom(el));
@@ -1,5 +1,8 @@
1
- import { ElementState, type ElementQuery, type MatchResult, type QueryResult } from '@reticlehq/core';
1
+ import { ElementState, type ElementQuery, type MatchResult, type NearestTarget, type QueryResult } from '@reticlehq/core';
2
2
  export declare const ATTR_VALUE_MAX = 512;
3
- export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean, diagnose?: boolean): MatchResult;
3
+ export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean, diagnose?: boolean, fullText?: boolean): MatchResult;
4
+ export declare const MAX_NEAREST_NAME = 60;
5
+ export declare function nameSimilarity(a: string, b: string): number;
6
+ export declare function nearestTargets(container: HTMLElement, query: ElementQuery): NearestTarget[];
4
7
  export declare function elementsMatching(query: ElementQuery): HTMLElement[];
5
8
  export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
package/dist/dom/query.js CHANGED
@@ -12,6 +12,7 @@ import { stableSelectorFor } from './stable-selector.js';
12
12
  import { fuzzyVisibleText, normaliseVisibleText, comparableText, foldTypography, textMatches, unreadableMatch, } from './text-match.js';
13
13
  import { pageRoute } from '../security/page-location.js';
14
14
  import { elementsUnder, findIn, isNonRendered, queryByRoleAndName } from './query-finders.js';
15
+ import { INTERACTIVE } from './snapshot.js';
15
16
  const MAX_PRESENT_TESTIDS = 12;
16
17
  function visibleTextOf(el, memo) {
17
18
  if (isNonRendered(el) || isWordsIgnored(el)) {
@@ -149,7 +150,7 @@ function inState(el, state, memo) {
149
150
  return getStates(el, isVisible(el, memo)).includes(state);
150
151
  }
151
152
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
152
- export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
153
+ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true, fullText = false) {
153
154
  const found = findCandidates(query);
154
155
  const scopeMissing = found.scopeMissing;
155
156
  const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
@@ -159,10 +160,13 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
159
160
  const visMemo = new Map();
160
161
  const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
161
162
  const attrs = query.attrs;
162
- const described = filtered.slice(0, Math.max(0, Math.min(limit, MAX_DESCRIBED)));
163
+ const cap = fullText
164
+ ? Math.min(MAX_DESCRIBED, TRANSPORT_LIMITS.MAX_FULL_TEXT_ELEMENTS)
165
+ : MAX_DESCRIBED;
166
+ const described = filtered.slice(0, Math.max(0, Math.min(limit, cap)));
163
167
  const stampViewport = described.length > 1 || ElementState.IN_VIEWPORT === state;
164
168
  const descriptors = described.map((el) => {
165
- let base = describe(el, visMemo);
169
+ let base = describe(el, visMemo, fullText);
166
170
  if (stampViewport &&
167
171
  isInViewport(el, visMemo) &&
168
172
  !base.states.includes(ElementState.IN_VIEWPORT)) {
@@ -180,6 +184,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
180
184
  matched: filtered.length > 0,
181
185
  count: filtered.length,
182
186
  elements: descriptors,
187
+ ...(fullText ? { fullText: true } : {}),
183
188
  ...(scopeMissing ? { scopeMissing: true } : {}),
184
189
  ...(covered === undefined ? {} : { coveredBy: covered }),
185
190
  ...(unreadable === undefined ? {} : { unreadable }),
@@ -346,6 +351,81 @@ function nameNearMisses(container, query) {
346
351
  }
347
352
  return out;
348
353
  }
354
+ const MAX_NEAREST_TARGETS = 3;
355
+ export const MAX_NEAREST_NAME = 60;
356
+ function bigrams(value) {
357
+ const padded = ` ${value} `;
358
+ const out = new Set();
359
+ for (let i = 0; i < padded.length - 1; i += 1) {
360
+ out.add(padded.slice(i, i + 2));
361
+ }
362
+ return out;
363
+ }
364
+ export function nameSimilarity(a, b) {
365
+ const left = bigrams(foldTypography(a).toLowerCase());
366
+ const right = bigrams(foldTypography(b).toLowerCase());
367
+ let shared = 0;
368
+ for (const gram of left) {
369
+ if (right.has(gram)) {
370
+ shared += 1;
371
+ }
372
+ }
373
+ return (2 * shared) / (left.size + right.size);
374
+ }
375
+ function askedRole(query) {
376
+ return QueryBy.ROLE === query.by ? query.value : query.role;
377
+ }
378
+ function askedName(query) {
379
+ const value = QueryBy.ROLE === query.by ? undefined : query.value;
380
+ const asked = query.name ??
381
+ value ??
382
+ query.text ??
383
+ query.label ??
384
+ query.placeholder ??
385
+ query.testid ??
386
+ query.alt;
387
+ return asked === undefined ? '' : normaliseVisibleText(asked);
388
+ }
389
+ export function nearestTargets(container, query) {
390
+ const role = askedRole(query);
391
+ const name = askedName(query);
392
+ if (role === undefined && 0 === name.length) {
393
+ return [];
394
+ }
395
+ const memo = new Map();
396
+ const scored = [];
397
+ for (const el of elementsUnder(container)) {
398
+ const elRole = getRole(el);
399
+ const roleMatch = role !== undefined && elRole === role;
400
+ if (!roleMatch && !INTERACTIVE.has(elRole)) {
401
+ continue;
402
+ }
403
+ if (isIgnored(el) || !isVisible(el, memo)) {
404
+ continue;
405
+ }
406
+ const elName = normaliseVisibleText(getAccessibleName(el));
407
+ if (0 === elName.length) {
408
+ continue;
409
+ }
410
+ const score = 0 === name.length ? 0 : nameSimilarity(name, elName);
411
+ if (!roleMatch && 0 === score) {
412
+ continue;
413
+ }
414
+ scored.push({ el, role: elRole, name: elName, roleMatch, score });
415
+ }
416
+ scored.sort((a, b) => Number(b.roleMatch) - Number(a.roleMatch) || b.score - a.score);
417
+ return scored.slice(0, MAX_NEAREST_TARGETS).map((c) => {
418
+ const target = {
419
+ ref: refs.refFor(c.el),
420
+ role: c.role,
421
+ name: c.name.slice(0, MAX_NEAREST_NAME),
422
+ };
423
+ if (c.name.length > MAX_NEAREST_NAME) {
424
+ target.nameTruncated = true;
425
+ }
426
+ return target;
427
+ });
428
+ }
349
429
  function testidFoundUnder(container, query) {
350
430
  if (QueryBy.TESTID !== query.by || undefined === query.value) {
351
431
  return undefined;
@@ -452,6 +532,10 @@ function buildEmptyHint(query, state, diagnose = true) {
452
532
  if (elsewhere !== undefined) {
453
533
  hint.testidFoundUnder = elsewhere;
454
534
  }
535
+ const nearest = diagnose ? nearestTargets(container, query) : [];
536
+ if (nearest.length > 0) {
537
+ hint.nearest = nearest;
538
+ }
455
539
  return hint;
456
540
  }
457
541
  export function elementsMatching(query) {
@@ -464,7 +548,7 @@ export function runQuery(query, limit) {
464
548
  return {
465
549
  elements: result.elements,
466
550
  count: result.count,
467
- hint: buildEmptyHint(query),
551
+ hint: result.hint ?? buildEmptyHint(query),
468
552
  ...scopeFields,
469
553
  };
470
554
  }