@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.
- package/dist/actions/actions-dom.d.ts +10 -1
- package/dist/actions/actions-dom.js +65 -22
- package/dist/actions/actions.js +45 -10
- package/dist/actions/select-combobox.d.ts +6 -0
- package/dist/actions/select-combobox.js +69 -0
- package/dist/commands/commands.js +4 -4
- package/dist/dom/a11y.d.ts +1 -1
- package/dist/dom/a11y.js +7 -7
- package/dist/dom/query.d.ts +5 -2
- package/dist/dom/query.js +88 -4
- package/dist/dom/text-match.js +2 -1
- package/dist/dom/visibility.d.ts +1 -0
- package/dist/dom/visibility.js +86 -3
- package/dist/observers/network.js +3 -1
- package/dist/reticle-inject.js +489 -489
- package/dist/reticle.js +2 -2
- package/dist/review/annotator.js +4 -0
- package/dist/session-continuity.d.ts +1 -0
- package/dist/session-continuity.js +18 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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:
|
|
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) {
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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, 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
|
-
|
|
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: {
|
|
@@ -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
|
|
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
|
|
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']));
|
package/dist/dom/a11y.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
364
|
-
function getVisibleText(el) {
|
|
363
|
+
function getVisibleText(el, fullText) {
|
|
365
364
|
const text = collapse(el.textContent ?? '');
|
|
366
|
-
|
|
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));
|
package/dist/dom/query.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|