@reticlehq/browser 3.7.0 → 3.8.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/action-report.d.ts +10 -0
- package/dist/actions/action-report.js +29 -2
- package/dist/actions/actions-dom.d.ts +4 -0
- package/dist/actions/actions-dom.js +20 -4
- package/dist/actions/actions.d.ts +1 -0
- package/dist/actions/actions.js +35 -17
- package/dist/actions/appeared-text.js +2 -2
- package/dist/actions/click-target.d.ts +1 -0
- package/dist/actions/click-target.js +14 -0
- package/dist/actions/danger-context.d.ts +2 -1
- package/dist/actions/danger-context.js +20 -5
- package/dist/activate.d.ts +14 -0
- package/dist/activate.js +43 -0
- package/dist/busy.d.ts +3 -0
- package/dist/busy.js +42 -0
- package/dist/commands/commands.js +45 -17
- package/dist/connect-options.d.ts +12 -1
- package/dist/connect-options.js +18 -1
- package/dist/dom/a11y.d.ts +2 -0
- package/dist/dom/a11y.js +191 -159
- package/dist/dom/chart.d.ts +3 -2
- package/dist/dom/chart.js +3 -2
- package/dist/dom/dom-ignore.d.ts +6 -0
- package/dist/dom/dom-ignore.js +43 -1
- package/dist/dom/layout-audit.d.ts +15 -0
- package/dist/dom/layout-audit.js +208 -0
- package/dist/dom/paint-context.d.ts +15 -0
- package/dist/dom/paint-context.js +78 -0
- package/dist/dom/query.d.ts +2 -1
- package/dist/dom/query.js +97 -34
- package/dist/dom/shadow-registry.d.ts +1 -0
- package/dist/dom/shadow-registry.js +27 -0
- package/dist/dom/snapshot.d.ts +5 -0
- package/dist/dom/snapshot.js +85 -34
- package/dist/dom/stable-selector.d.ts +1 -0
- package/dist/dom/stable-selector.js +19 -0
- package/dist/dom/text-match.d.ts +12 -0
- package/dist/dom/text-match.js +115 -0
- package/dist/dom/theme.d.ts +1 -0
- package/dist/dom/theme.js +21 -9
- package/dist/dom/virtualized.js +5 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -0
- package/dist/observers/blind-spots.js +3 -2
- package/dist/observers/canvas.d.ts +18 -0
- package/dist/observers/canvas.js +76 -0
- package/dist/observers/dom.js +66 -18
- package/dist/observers/field.d.ts +11 -0
- package/dist/observers/field.js +117 -13
- package/dist/observers/install-all.js +4 -0
- package/dist/observers/media.d.ts +2 -0
- package/dist/observers/media.js +32 -0
- package/dist/observers/net-detail/network-body.d.ts +2 -0
- package/dist/observers/net-detail/network-body.js +8 -1
- package/dist/observers/network.js +26 -6
- package/dist/observers/route.d.ts +2 -0
- package/dist/observers/route.js +33 -4
- package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -5
- package/dist/presenter/chrome/presenter-report-copy.js +0 -14
- package/dist/presenter/hud-telemetry.js +1 -1
- package/dist/presenter/presenter-chat-views.d.ts +3 -4
- package/dist/presenter/presenter-chat-views.js +47 -20
- package/dist/presenter/presenter-config.d.ts +1 -0
- package/dist/presenter/presenter-config.js +1 -0
- package/dist/presenter/presenter-frame.d.ts +3 -2
- package/dist/presenter/presenter-frame.js +24 -15
- package/dist/presenter/presenter-harness-row.d.ts +4 -15
- package/dist/presenter/presenter-harness-row.js +11 -50
- package/dist/presenter/presenter-report-styles.js +0 -17
- package/dist/presenter/presenter-report.d.ts +1 -2
- package/dist/presenter/presenter-report.js +4 -54
- package/dist/presenter/presenter-run-state.js +2 -1
- package/dist/presenter/presenter-settings.d.ts +2 -3
- package/dist/presenter/presenter-settings.js +14 -17
- package/dist/presenter/presenter-shell.d.ts +6 -4
- package/dist/presenter/presenter-shell.js +37 -7
- package/dist/presenter/presenter.js +32 -11
- package/dist/registry/store-adapters.d.ts +5 -2
- package/dist/registry/store-adapters.js +40 -10
- package/dist/reticle-inject.js +602 -611
- package/dist/reticle.d.ts +4 -2
- package/dist/reticle.js +36 -6
- package/dist/security/page-location.d.ts +2 -0
- package/dist/security/page-location.js +7 -0
- package/dist/security/serialization.js +1 -1
- package/dist/storage-keys.d.ts +1 -0
- package/dist/storage-keys.js +1 -0
- package/dist/timers/native/native-timers.d.ts +1 -0
- package/dist/timers/native/native-timers.js +5 -0
- package/dist/transport/transport.d.ts +1 -0
- package/dist/transport/transport.js +18 -0
- package/package.json +6 -2
package/dist/dom/dom-ignore.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RETICLE_OVERLAY_SELECTOR } from '@reticlehq/core';
|
|
2
|
+
import { capturedRootOf } from './shadow-registry.js';
|
|
2
3
|
export const RETICLE_OVERLAY = RETICLE_OVERLAY_SELECTOR;
|
|
3
4
|
const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
|
|
4
5
|
let extraIgnore = '';
|
|
@@ -29,12 +30,53 @@ export function isUnobserved(el) {
|
|
|
29
30
|
export function isReticleUi(node) {
|
|
30
31
|
return node !== null && node.closest(RETICLE_OVERLAY) !== null;
|
|
31
32
|
}
|
|
32
|
-
const HUD_NARRATION = '[data-reticle-log],[data-reticle-harness-log],[data-reticle-chat-pill-text],[data-reticle-agent-said],[data-reticle-agent-note]';
|
|
33
|
+
const HUD_NARRATION = '[data-reticle-log],[data-reticle-harness-log],[data-reticle-chat-pill-text],[data-reticle-agent-said],[data-reticle-agent-note],[data-reticle-act-line]';
|
|
33
34
|
export function isIgnored(el) {
|
|
34
35
|
const ours = exposed() ? HUD_NARRATION : RETICLE_OVERLAY;
|
|
35
36
|
const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
|
|
36
37
|
return el.closest(sel) !== null;
|
|
37
38
|
}
|
|
39
|
+
export function isWordsIgnored(el) {
|
|
40
|
+
return isIgnored(el) || isReticleOverlay(el);
|
|
41
|
+
}
|
|
42
|
+
export const FrameworkErrorDialog = {
|
|
43
|
+
NEXT: 'Next.js error dialog',
|
|
44
|
+
VITE: 'Vite error overlay',
|
|
45
|
+
};
|
|
46
|
+
const NEXT_PORTAL = 'nextjs-portal';
|
|
47
|
+
const NEXT_DIALOG = '[data-nextjs-dialog]';
|
|
48
|
+
const VITE_OVERLAY = 'vite-error-overlay';
|
|
49
|
+
function openFrameworkErrorDialog(doc) {
|
|
50
|
+
const vite = doc.querySelector(VITE_OVERLAY);
|
|
51
|
+
if (vite !== null)
|
|
52
|
+
return { name: FrameworkErrorDialog.VITE, host: vite };
|
|
53
|
+
for (const portal of Array.from(doc.querySelectorAll(NEXT_PORTAL))) {
|
|
54
|
+
const root = portal.shadowRoot ?? capturedRootOf(portal);
|
|
55
|
+
if (null !== (root ?? portal).querySelector(NEXT_DIALOG)) {
|
|
56
|
+
return { name: FrameworkErrorDialog.NEXT, host: portal };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const light = doc.querySelector(NEXT_DIALOG);
|
|
60
|
+
return null === light ? undefined : { name: FrameworkErrorDialog.NEXT, host: light };
|
|
61
|
+
}
|
|
62
|
+
export function dialogCovering(matches) {
|
|
63
|
+
const first = matches[0];
|
|
64
|
+
if (first === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const dialog = openFrameworkErrorDialog(first.ownerDocument);
|
|
67
|
+
if (dialog === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
return matches.some((el) => !isInsideHost(el, dialog.host)) ? dialog.name : undefined;
|
|
70
|
+
}
|
|
71
|
+
function isInsideHost(el, host) {
|
|
72
|
+
for (let node = el; node !== null;) {
|
|
73
|
+
if (node === host)
|
|
74
|
+
return true;
|
|
75
|
+
const root = node.getRootNode();
|
|
76
|
+
node = node.parentNode ?? (root instanceof ShadowRoot ? root.host : null);
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
38
80
|
const APP_MODAL = 'dialog[open], [aria-modal="true"]';
|
|
39
81
|
export function appModalOpen(doc) {
|
|
40
82
|
for (const el of Array.from(doc.querySelectorAll(APP_MODAL))) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const LayoutDefect: {
|
|
2
|
+
readonly CLIPPED: "clipped";
|
|
3
|
+
readonly OVERLAP: "overlap";
|
|
4
|
+
readonly CONTRAST: "contrast";
|
|
5
|
+
};
|
|
6
|
+
export type LayoutDefect = (typeof LayoutDefect)[keyof typeof LayoutDefect];
|
|
7
|
+
export interface LayoutFinding {
|
|
8
|
+
kind: LayoutDefect;
|
|
9
|
+
ref: string;
|
|
10
|
+
what: string;
|
|
11
|
+
detail: string;
|
|
12
|
+
value: number;
|
|
13
|
+
bound: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function auditLayout(root?: Element): LayoutFinding[];
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { refs } from './addressing/refs.js';
|
|
2
|
+
import { getAccessibleName } from './a11y.js';
|
|
3
|
+
import { isIgnored } from './dom-ignore.js';
|
|
4
|
+
export const LayoutDefect = {
|
|
5
|
+
CLIPPED: 'clipped',
|
|
6
|
+
OVERLAP: 'overlap',
|
|
7
|
+
CONTRAST: 'contrast',
|
|
8
|
+
};
|
|
9
|
+
const MAX_FINDINGS = 20;
|
|
10
|
+
const MAX_CONTROLS = 300;
|
|
11
|
+
const WHAT_CHARS = 60;
|
|
12
|
+
const SLACK_PX = 1;
|
|
13
|
+
const OVERLAP_SHARE = 0.25;
|
|
14
|
+
const MIN_CONTRAST = 4.5;
|
|
15
|
+
const MIN_CONTRAST_LARGE = 3;
|
|
16
|
+
const LARGE_PX = 24;
|
|
17
|
+
const LARGE_BOLD_PX = 18.66;
|
|
18
|
+
const BOLD_WEIGHT = 700;
|
|
19
|
+
const CONTROLS = 'button, a[href], input:not([type="hidden"]), select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"]';
|
|
20
|
+
const CLIPPED_AWAY = /rect\(\s*0(px)?[\s,]+0(px)?[\s,]+0(px)?[\s,]+0(px)?\s*\)|inset\(\s*50%/;
|
|
21
|
+
const MIN_BOX_PX = 4;
|
|
22
|
+
const shown = (el) => {
|
|
23
|
+
const rect = el.getBoundingClientRect();
|
|
24
|
+
if (rect.width < MIN_BOX_PX || rect.height < MIN_BOX_PX) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const style = getComputedStyle(el);
|
|
28
|
+
if ('hidden' === style.visibility || 'none' === style.display || '0' === style.opacity) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (CLIPPED_AWAY.test(style.clip) || CLIPPED_AWAY.test(style.clipPath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return rect;
|
|
35
|
+
};
|
|
36
|
+
const whatOf = (el) => (getAccessibleName(el) || (el.textContent ?? ''))
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
.slice(0, WHAT_CHARS);
|
|
40
|
+
const ownText = (el) => [...el.childNodes]
|
|
41
|
+
.filter((n) => Node.TEXT_NODE === n.nodeType)
|
|
42
|
+
.map((n) => n.textContent ?? '')
|
|
43
|
+
.join('')
|
|
44
|
+
.trim();
|
|
45
|
+
const CUTS = new Set(['hidden', 'clip']);
|
|
46
|
+
const clipped = (el) => {
|
|
47
|
+
const style = getComputedStyle(el);
|
|
48
|
+
const wide = el.scrollWidth > el.clientWidth + SLACK_PX && CUTS.has(style.overflowX);
|
|
49
|
+
const tall = el.scrollHeight > el.clientHeight + SLACK_PX && CUTS.has(style.overflowY);
|
|
50
|
+
const declared = 'ellipsis' === style.textOverflow ||
|
|
51
|
+
('' !== style.getPropertyValue('-webkit-line-clamp') &&
|
|
52
|
+
'none' !== style.getPropertyValue('-webkit-line-clamp'));
|
|
53
|
+
if (declared || (!wide && !tall)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return wide
|
|
57
|
+
? {
|
|
58
|
+
detail: `${String(el.scrollWidth)}px of text in a ${String(el.clientWidth)}px box, cut with no ellipsis`,
|
|
59
|
+
value: el.scrollWidth,
|
|
60
|
+
bound: el.clientWidth,
|
|
61
|
+
}
|
|
62
|
+
: {
|
|
63
|
+
detail: `${String(el.scrollHeight)}px of text in a ${String(el.clientHeight)}px box, cut with no line clamp`,
|
|
64
|
+
value: el.scrollHeight,
|
|
65
|
+
bound: el.clientHeight,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const RGB = /rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)/;
|
|
69
|
+
const parse = (value) => {
|
|
70
|
+
const m = RGB.exec(value);
|
|
71
|
+
if (null === m) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const alphaRaw = m[4];
|
|
75
|
+
let alpha = 1;
|
|
76
|
+
if (alphaRaw !== undefined) {
|
|
77
|
+
alpha = alphaRaw.endsWith('%') ? Number(alphaRaw.slice(0, -1)) / 100 : Number(alphaRaw);
|
|
78
|
+
}
|
|
79
|
+
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
|
|
80
|
+
};
|
|
81
|
+
const luminance = ([r, g, b]) => {
|
|
82
|
+
const lin = (c) => {
|
|
83
|
+
const s = c / 255;
|
|
84
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
85
|
+
};
|
|
86
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
87
|
+
};
|
|
88
|
+
const over = (top, under) => {
|
|
89
|
+
const a = top[3];
|
|
90
|
+
return [
|
|
91
|
+
top[0] * a + under[0] * (1 - a),
|
|
92
|
+
top[1] * a + under[1] * (1 - a),
|
|
93
|
+
top[2] * a + under[2] * (1 - a),
|
|
94
|
+
1,
|
|
95
|
+
];
|
|
96
|
+
};
|
|
97
|
+
const backdrop = (el) => {
|
|
98
|
+
const layers = [];
|
|
99
|
+
for (let at = el; null !== at; at = at.parentElement) {
|
|
100
|
+
const style = getComputedStyle(at);
|
|
101
|
+
if ('none' !== style.backgroundImage && '' !== style.backgroundImage) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
const colour = parse(style.backgroundColor);
|
|
105
|
+
if (null !== colour && colour[3] > 0) {
|
|
106
|
+
layers.push(colour);
|
|
107
|
+
if (1 === colour[3]) {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return layers.reduceRight((under, top) => over(top, under), [255, 255, 255, 1]);
|
|
113
|
+
};
|
|
114
|
+
const contrastOf = (el) => {
|
|
115
|
+
const style = getComputedStyle(el);
|
|
116
|
+
const fg = parse(style.color);
|
|
117
|
+
const bg = backdrop(el);
|
|
118
|
+
if (null === fg || null === bg) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const text = over(fg, bg);
|
|
122
|
+
const [hi, lo] = [luminance(text), luminance(bg)].sort((a, b) => b - a);
|
|
123
|
+
const size = Number.parseFloat(style.fontSize);
|
|
124
|
+
const weight = Number.parseInt(style.fontWeight, 10);
|
|
125
|
+
const large = size >= LARGE_PX || (size >= LARGE_BOLD_PX && weight >= BOLD_WEIGHT);
|
|
126
|
+
return { ratio: (hi + 0.05) / (lo + 0.05), min: large ? MIN_CONTRAST_LARGE : MIN_CONTRAST };
|
|
127
|
+
};
|
|
128
|
+
const seenAt = (x, y, ...els) => {
|
|
129
|
+
if ('function' !== typeof document.elementFromPoint) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
const top = document.elementFromPoint(x, y);
|
|
133
|
+
return null !== top && els.some((el) => el === top || el.contains(top));
|
|
134
|
+
};
|
|
135
|
+
const INSET_PX = 2;
|
|
136
|
+
const visible = (el, r) => [
|
|
137
|
+
[r.left + r.width / 2, r.top + r.height / 2],
|
|
138
|
+
[r.left + INSET_PX, r.top + INSET_PX],
|
|
139
|
+
[r.right - INSET_PX, r.top + INSET_PX],
|
|
140
|
+
[r.left + INSET_PX, r.bottom - INSET_PX],
|
|
141
|
+
[r.right - INSET_PX, r.bottom - INSET_PX],
|
|
142
|
+
].some(([x = 0, y = 0]) => seenAt(x, y, el));
|
|
143
|
+
const share = (a, b) => {
|
|
144
|
+
const w = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
145
|
+
const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
146
|
+
if (w <= 0 || h <= 0) {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
return (w * h) / Math.min(a.width * a.height, b.width * b.height);
|
|
150
|
+
};
|
|
151
|
+
export function auditLayout(root = document.body) {
|
|
152
|
+
const found = [];
|
|
153
|
+
const add = (kind, el, measured) => {
|
|
154
|
+
if (found.length < MAX_FINDINGS) {
|
|
155
|
+
found.push({ kind, ref: refs.refFor(el), what: whatOf(el), ...measured });
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
for (const el of root.querySelectorAll('*')) {
|
|
159
|
+
if (found.length >= MAX_FINDINGS) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
if (isIgnored(el) || '' === ownText(el)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const rect = shown(el);
|
|
166
|
+
if (null === rect || !seenAt(rect.left + rect.width / 2, rect.top + rect.height / 2, el)) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const cut = clipped(el);
|
|
170
|
+
if (null !== cut) {
|
|
171
|
+
add(LayoutDefect.CLIPPED, el, cut);
|
|
172
|
+
}
|
|
173
|
+
const contrast = contrastOf(el);
|
|
174
|
+
if (null !== contrast && contrast.ratio < contrast.min) {
|
|
175
|
+
add(LayoutDefect.CONTRAST, el, {
|
|
176
|
+
detail: `contrast ${contrast.ratio.toFixed(2)}:1, under ${String(contrast.min)}:1`,
|
|
177
|
+
value: Math.round(contrast.ratio * 100) / 100,
|
|
178
|
+
bound: contrast.min,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const controls = [...root.querySelectorAll(CONTROLS)]
|
|
183
|
+
.filter((el) => !isIgnored(el))
|
|
184
|
+
.slice(0, MAX_CONTROLS)
|
|
185
|
+
.flatMap((el) => {
|
|
186
|
+
const rect = shown(el);
|
|
187
|
+
return null === rect ? [] : [{ el, rect }];
|
|
188
|
+
});
|
|
189
|
+
for (const [i, a] of controls.entries()) {
|
|
190
|
+
for (const b of controls.slice(i + 1)) {
|
|
191
|
+
if (found.length >= MAX_FINDINGS) {
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const covered = share(a.rect, b.rect);
|
|
198
|
+
if (covered >= OVERLAP_SHARE && visible(a.el, a.rect) && visible(b.el, b.rect)) {
|
|
199
|
+
add(LayoutDefect.OVERLAP, a.el, {
|
|
200
|
+
detail: `drawn over "${whatOf(b.el)}" (${refs.refFor(b.el)})`,
|
|
201
|
+
value: Math.round(covered * 100) / 100,
|
|
202
|
+
bound: OVERLAP_SHARE,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
@@ -4,3 +4,18 @@ export interface PaintContextEntry {
|
|
|
4
4
|
readonly value: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function paintContextOf(el: Element): PaintContextEntry[];
|
|
7
|
+
export type VisibleBounds = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>;
|
|
8
|
+
export interface VisibilityInfo {
|
|
9
|
+
hidden: boolean;
|
|
10
|
+
style: CSSStyleDeclaration | null;
|
|
11
|
+
parent: Element | null;
|
|
12
|
+
clipX: boolean;
|
|
13
|
+
clipY: boolean;
|
|
14
|
+
scrollX: boolean;
|
|
15
|
+
scrollY: boolean;
|
|
16
|
+
hasClipping: boolean;
|
|
17
|
+
clipBox?: VisibleBounds;
|
|
18
|
+
}
|
|
19
|
+
export declare function overflowAxes(style: CSSStyleDeclaration | null): [string, string];
|
|
20
|
+
export declare function clippingBox(el: Element, info: VisibilityInfo): VisibleBounds;
|
|
21
|
+
export declare function scrolledInto(b: VisibleBounds, port: VisibleBounds, alongX: boolean, alongY: boolean): VisibleBounds;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHtmlElement } from './realm.js';
|
|
1
2
|
const PAINT_PROPERTIES = [
|
|
2
3
|
'filter',
|
|
3
4
|
'opacity',
|
|
@@ -36,3 +37,80 @@ export function paintContextOf(el) {
|
|
|
36
37
|
}
|
|
37
38
|
return found;
|
|
38
39
|
}
|
|
40
|
+
export function overflowAxes(style) {
|
|
41
|
+
const shorthand = style?.overflow ?? '';
|
|
42
|
+
const [x = 'visible', y = x] = ('' === shorthand ? 'visible' : shorthand).split(/\s+/);
|
|
43
|
+
return [
|
|
44
|
+
null === style || '' === style.overflowX ? x : style.overflowX,
|
|
45
|
+
null === style || '' === style.overflowY ? y : style.overflowY,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
export function clippingBox(el, info) {
|
|
49
|
+
if (info.clipBox !== undefined) {
|
|
50
|
+
return info.clipBox;
|
|
51
|
+
}
|
|
52
|
+
const rect = el.getBoundingClientRect();
|
|
53
|
+
const style = info.style;
|
|
54
|
+
const px = (value) => Number.parseFloat(value ?? '') || 0;
|
|
55
|
+
const border = (value, kind) => kind === undefined || '' === kind || 'none' === kind || 'hidden' === kind ? 0 : px(value);
|
|
56
|
+
const scaleX = isHtmlElement(el) && el.offsetWidth > 0 ? rect.width / el.offsetWidth : 1;
|
|
57
|
+
const scaleY = isHtmlElement(el) && el.offsetHeight > 0 ? rect.height / el.offsetHeight : 1;
|
|
58
|
+
const left = isHtmlElement(el) && el.offsetWidth > 0
|
|
59
|
+
? rect.left + el.clientLeft * scaleX
|
|
60
|
+
: rect.left + border(style?.borderLeftWidth, style?.borderLeftStyle);
|
|
61
|
+
const top = isHtmlElement(el) && el.offsetHeight > 0
|
|
62
|
+
? rect.top + el.clientTop * scaleY
|
|
63
|
+
: rect.top + border(style?.borderTopWidth, style?.borderTopStyle);
|
|
64
|
+
const right = isHtmlElement(el) && el.offsetWidth > 0
|
|
65
|
+
? left + el.clientWidth * scaleX
|
|
66
|
+
: rect.right - border(style?.borderRightWidth, style?.borderRightStyle);
|
|
67
|
+
const bottom = isHtmlElement(el) && el.offsetHeight > 0
|
|
68
|
+
? top + el.clientHeight * scaleY
|
|
69
|
+
: rect.bottom - border(style?.borderBottomWidth, style?.borderBottomStyle);
|
|
70
|
+
const margin = (style?.overflowClipMargin ?? '').split(/\s+/);
|
|
71
|
+
const outset = px(margin.at(-1));
|
|
72
|
+
const borderBox = margin.includes('border-box');
|
|
73
|
+
const contentBox = margin.includes('content-box');
|
|
74
|
+
const [x, y] = overflowAxes(style);
|
|
75
|
+
const clipLeft = 'clip' === x
|
|
76
|
+
? (borderBox ? rect.left : left + (contentBox ? px(style?.paddingLeft) * scaleX : 0)) -
|
|
77
|
+
outset * scaleX
|
|
78
|
+
: left;
|
|
79
|
+
const clipRight = 'clip' === x
|
|
80
|
+
? (borderBox ? rect.right : right - (contentBox ? px(style?.paddingRight) * scaleX : 0)) +
|
|
81
|
+
outset * scaleX
|
|
82
|
+
: right;
|
|
83
|
+
const clipTop = 'clip' === y
|
|
84
|
+
? (borderBox ? rect.top : top + (contentBox ? px(style?.paddingTop) * scaleY : 0)) -
|
|
85
|
+
outset * scaleY
|
|
86
|
+
: top;
|
|
87
|
+
const clipBottom = 'clip' === y
|
|
88
|
+
? (borderBox ? rect.bottom : bottom - (contentBox ? px(style?.paddingBottom) * scaleY : 0)) +
|
|
89
|
+
outset * scaleY
|
|
90
|
+
: bottom;
|
|
91
|
+
info.clipBox = {
|
|
92
|
+
left: clipLeft,
|
|
93
|
+
right: clipRight,
|
|
94
|
+
top: clipTop,
|
|
95
|
+
bottom: clipBottom,
|
|
96
|
+
width: clipRight - clipLeft,
|
|
97
|
+
height: clipBottom - clipTop,
|
|
98
|
+
};
|
|
99
|
+
return info.clipBox;
|
|
100
|
+
}
|
|
101
|
+
export function scrolledInto(b, port, alongX, alongY) {
|
|
102
|
+
const axis = (lo, hi, size, from, to) => {
|
|
103
|
+
if (hi > from && lo < to) {
|
|
104
|
+
return [lo, hi];
|
|
105
|
+
}
|
|
106
|
+
const span = Math.min(size, to - from);
|
|
107
|
+
return lo >= to ? [to - span, to] : [from, from + span];
|
|
108
|
+
};
|
|
109
|
+
const [left, right] = alongX
|
|
110
|
+
? axis(b.left, b.right, b.width, port.left, port.right)
|
|
111
|
+
: [b.left, b.right];
|
|
112
|
+
const [top, bottom] = alongY
|
|
113
|
+
? axis(b.top, b.bottom, b.height, port.top, port.bottom)
|
|
114
|
+
: [b.top, b.bottom];
|
|
115
|
+
return { left, right, top, bottom, width: right - left, height: bottom - top };
|
|
116
|
+
}
|
package/dist/dom/query.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ElementState, type ElementQuery, type MatchResult, type QueryResult } from '@reticlehq/core';
|
|
2
|
+
export declare function isHiddenNativeOption(el: HTMLElement): boolean;
|
|
2
3
|
export declare const ATTR_VALUE_MAX = 512;
|
|
3
|
-
export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean): MatchResult;
|
|
4
|
+
export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean, diagnose?: boolean): MatchResult;
|
|
4
5
|
export declare function elementsMatching(query: ElementQuery): HTMLElement[];
|
|
5
6
|
export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
|
package/dist/dom/query.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
|
|
2
2
|
import { isActionTarget, isElement, isFrame, isInput, isSelect, isTextArea } from './realm.js';
|
|
3
3
|
import { capturedRootOf } from './shadow-registry.js';
|
|
4
|
-
import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
|
|
5
|
-
import { isIgnored, isReticleOverlay } from './dom-ignore.js';
|
|
4
|
+
import { getAccessibleName, getRole, describe, getStates, isHiddenByAriaOnly, isInViewport, isRenderedOutsideAriaHidden, isVisible, } from './a11y.js';
|
|
5
|
+
import { dialogCovering, isIgnored, isReticleOverlay, isWordsIgnored } from './dom-ignore.js';
|
|
6
6
|
import { isSensitiveKey } from '../security/serialization.js';
|
|
7
7
|
import { declaredTestids } from '../registry/capabilities.js';
|
|
8
8
|
import { identifyComponent } from '../registry/stores/adapters.js';
|
|
9
9
|
import { refs } from './addressing/refs.js';
|
|
10
10
|
import { getTestIdAttr, readTestId, testIdSelector } from './addressing/testid-attr.js';
|
|
11
|
+
import { stableSelectorFor } from './stable-selector.js';
|
|
12
|
+
import { exactVisibleText, fuzzyVisibleText, normaliseVisibleText, comparableText, foldTypography, paintedCase, textMatches, unreadableMatch, } from './text-match.js';
|
|
13
|
+
import { pageRoute } from '../security/page-location.js';
|
|
11
14
|
const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
|
|
12
15
|
const MAX_PRESENT_TESTIDS = 12;
|
|
13
16
|
const MAX_COMPONENT_CANDIDATES = 2000;
|
|
@@ -16,22 +19,6 @@ function elementsUnder(container) {
|
|
|
16
19
|
const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
|
|
17
20
|
return [...self, ...Array.from(container.querySelectorAll('*'))];
|
|
18
21
|
}
|
|
19
|
-
function normaliseVisibleText(value) {
|
|
20
|
-
return value.replace(/\s+/g, ' ').trim().normalize('NFC');
|
|
21
|
-
}
|
|
22
|
-
function fuzzyVisibleText(actual, expected) {
|
|
23
|
-
return normaliseVisibleText(actual)
|
|
24
|
-
.toLowerCase()
|
|
25
|
-
.includes(normaliseVisibleText(expected).toLowerCase());
|
|
26
|
-
}
|
|
27
|
-
function textMatches(actual, query, expected) {
|
|
28
|
-
if (true !== query.matchCase)
|
|
29
|
-
return fuzzyVisibleText(actual, expected);
|
|
30
|
-
return normaliseVisibleText(actual).includes(normaliseVisibleText(expected));
|
|
31
|
-
}
|
|
32
|
-
function exactVisibleText(actual, expected) {
|
|
33
|
-
return normaliseVisibleText(actual) === normaliseVisibleText(expected);
|
|
34
|
-
}
|
|
35
22
|
const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
|
|
36
23
|
function isNonRendered(el) {
|
|
37
24
|
return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
|
|
@@ -50,7 +37,7 @@ function directText(el) {
|
|
|
50
37
|
.join('');
|
|
51
38
|
}
|
|
52
39
|
function visibleTextOf(el, memo) {
|
|
53
|
-
if (isNonRendered(el) ||
|
|
40
|
+
if (isNonRendered(el) || isWordsIgnored(el))
|
|
54
41
|
return '';
|
|
55
42
|
const visible = isVisible(el, memo);
|
|
56
43
|
const apart = laysOutApart(el);
|
|
@@ -139,21 +126,57 @@ function matchesRole(actual, queried) {
|
|
|
139
126
|
return true;
|
|
140
127
|
return false;
|
|
141
128
|
}
|
|
129
|
+
const OPTION_ROLE = 'option';
|
|
130
|
+
const HTML_OPTION_TAG = 'option';
|
|
131
|
+
const HTML_SELECT_TAG = 'select';
|
|
132
|
+
const VISUALLY_HIDDEN_MAX_PX = 1;
|
|
133
|
+
export function isHiddenNativeOption(el) {
|
|
134
|
+
if (HTML_OPTION_TAG !== el.localName)
|
|
135
|
+
return false;
|
|
136
|
+
const select = el.closest(HTML_SELECT_TAG);
|
|
137
|
+
if (!isSelect(select))
|
|
138
|
+
return false;
|
|
139
|
+
if (!isVisible(select))
|
|
140
|
+
return true;
|
|
141
|
+
return isVisuallyHiddenBox(select);
|
|
142
|
+
}
|
|
143
|
+
function isVisuallyHiddenBox(select) {
|
|
144
|
+
const box = select.getBoundingClientRect();
|
|
145
|
+
return (box.width > 0 &&
|
|
146
|
+
box.height > 0 &&
|
|
147
|
+
box.width <= VISUALLY_HIDDEN_MAX_PX &&
|
|
148
|
+
box.height <= VISUALLY_HIDDEN_MAX_PX);
|
|
149
|
+
}
|
|
150
|
+
function preferVisibleActionableOption(role, matches) {
|
|
151
|
+
if (OPTION_ROLE !== role)
|
|
152
|
+
return matches;
|
|
153
|
+
const kept = matches.filter((el) => !isHiddenNativeOption(el));
|
|
154
|
+
return kept.length === matches.length ? matches : kept;
|
|
155
|
+
}
|
|
142
156
|
function queryByRoleAndName(container, role, name) {
|
|
143
|
-
|
|
157
|
+
const matched = elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
|
|
144
158
|
(name === undefined || exactVisibleText(getAccessibleName(el), name)));
|
|
159
|
+
return preferVisibleActionableOption(role, matched);
|
|
145
160
|
}
|
|
146
161
|
function queryByText(container, value, query) {
|
|
147
|
-
return elementsUnder(container).filter((el) =>
|
|
162
|
+
return elementsUnder(container).filter((el) => {
|
|
163
|
+
const own = directText(el);
|
|
164
|
+
if (textMatches(own, query, value))
|
|
165
|
+
return true;
|
|
166
|
+
if (true !== query.matchCase || !fuzzyVisibleText(own, value))
|
|
167
|
+
return false;
|
|
168
|
+
const painted = paintedCase(el, own);
|
|
169
|
+
return painted !== undefined && textMatches(painted, query, value);
|
|
170
|
+
});
|
|
148
171
|
}
|
|
149
172
|
function queryByLabel(container, value) {
|
|
150
|
-
const expected =
|
|
173
|
+
const expected = comparableText(value).toLowerCase();
|
|
151
174
|
const exact = [];
|
|
152
175
|
const fuzzy = [];
|
|
153
176
|
for (const el of elementsUnder(container)) {
|
|
154
177
|
if (!semanticNameTarget(el))
|
|
155
178
|
continue;
|
|
156
|
-
const name =
|
|
179
|
+
const name = comparableText(getAccessibleName(el)).toLowerCase();
|
|
157
180
|
if (!name.includes(expected))
|
|
158
181
|
continue;
|
|
159
182
|
(name === expected ? exact : fuzzy).push(el);
|
|
@@ -264,11 +287,12 @@ function findCandidates(query) {
|
|
|
264
287
|
}
|
|
265
288
|
const seen = new Set();
|
|
266
289
|
const out = [];
|
|
290
|
+
const ignored = wantedTextOf(query) === undefined ? isIgnored : isWordsIgnored;
|
|
267
291
|
const collect = (els) => {
|
|
268
292
|
for (const el of els) {
|
|
269
293
|
if (seen.has(el))
|
|
270
294
|
continue;
|
|
271
|
-
if (
|
|
295
|
+
if (ignored(el))
|
|
272
296
|
continue;
|
|
273
297
|
seen.add(el);
|
|
274
298
|
out.push(el);
|
|
@@ -297,7 +321,7 @@ function inState(el, state, memo) {
|
|
|
297
321
|
return getStates(el, isVisible(el, memo)).includes(state);
|
|
298
322
|
}
|
|
299
323
|
const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
|
|
300
|
-
export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true) {
|
|
324
|
+
export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
|
|
301
325
|
const found = findCandidates(query);
|
|
302
326
|
const scopeMissing = found.scopeMissing;
|
|
303
327
|
const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
|
|
@@ -321,12 +345,16 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
|
|
|
321
345
|
const projected = projectAttrs(el, attrs);
|
|
322
346
|
return projected === undefined ? base : { ...base, attrs: projected };
|
|
323
347
|
});
|
|
348
|
+
const covered = dialogCovering(filtered);
|
|
349
|
+
const unreadable = wantedTextOf(query) === undefined ? undefined : unreadableMatch(described);
|
|
324
350
|
return {
|
|
325
351
|
matched: filtered.length > 0,
|
|
326
352
|
count: filtered.length,
|
|
327
353
|
elements: descriptors,
|
|
328
354
|
...(scopeMissing ? { scopeMissing: true } : {}),
|
|
329
|
-
...(
|
|
355
|
+
...(covered === undefined ? {} : { coveredBy: covered }),
|
|
356
|
+
...(unreadable === undefined ? {} : { unreadable }),
|
|
357
|
+
...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
|
|
330
358
|
};
|
|
331
359
|
}
|
|
332
360
|
function resolveLabelledBy(el) {
|
|
@@ -414,7 +442,7 @@ function splitTextOwner(container, wanted, query) {
|
|
|
414
442
|
return best;
|
|
415
443
|
}
|
|
416
444
|
function caseOnlyMatch(container, wanted) {
|
|
417
|
-
const target =
|
|
445
|
+
const target = comparableText(wanted).toLowerCase();
|
|
418
446
|
if (0 === target.length)
|
|
419
447
|
return undefined;
|
|
420
448
|
const memo = new Map();
|
|
@@ -422,7 +450,7 @@ function caseOnlyMatch(container, wanted) {
|
|
|
422
450
|
if (isIgnored(el))
|
|
423
451
|
continue;
|
|
424
452
|
const text = normaliseVisibleText(visibleTextOf(el, memo));
|
|
425
|
-
const at = text.toLowerCase().indexOf(target);
|
|
453
|
+
const at = foldTypography(text).toLowerCase().indexOf(target);
|
|
426
454
|
if (-1 !== at)
|
|
427
455
|
return text.slice(at, at + target.length);
|
|
428
456
|
}
|
|
@@ -450,7 +478,7 @@ function nameNearMisses(container, query) {
|
|
|
450
478
|
const wanted = query.name;
|
|
451
479
|
if (role === undefined || wanted === undefined || 0 === wanted.length)
|
|
452
480
|
return [];
|
|
453
|
-
const target =
|
|
481
|
+
const target = comparableText(wanted).toLowerCase();
|
|
454
482
|
if (0 === target.length)
|
|
455
483
|
return [];
|
|
456
484
|
const out = [];
|
|
@@ -460,7 +488,7 @@ function nameNearMisses(container, query) {
|
|
|
460
488
|
const name = normaliseVisibleText(getAccessibleName(el));
|
|
461
489
|
if (0 === name.length)
|
|
462
490
|
continue;
|
|
463
|
-
const folded = name.toLowerCase();
|
|
491
|
+
const folded = foldTypography(name).toLowerCase();
|
|
464
492
|
if (!folded.includes(target) && !target.includes(folded))
|
|
465
493
|
continue;
|
|
466
494
|
if (!out.includes(name))
|
|
@@ -483,7 +511,32 @@ function testidFoundUnder(container, query) {
|
|
|
483
511
|
}
|
|
484
512
|
return undefined;
|
|
485
513
|
}
|
|
486
|
-
function
|
|
514
|
+
function textInAriaHidden(container, wanted) {
|
|
515
|
+
let inAriaHidden = false;
|
|
516
|
+
for (const el of elementsUnder(container)) {
|
|
517
|
+
if (isIgnored(el))
|
|
518
|
+
continue;
|
|
519
|
+
if (!isHiddenByAriaOnly(el))
|
|
520
|
+
continue;
|
|
521
|
+
if (fuzzyVisibleText(el.textContent ?? '', wanted)) {
|
|
522
|
+
inAriaHidden = true;
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!inAriaHidden)
|
|
527
|
+
return false;
|
|
528
|
+
const memo = new Map();
|
|
529
|
+
for (const el of elementsUnder(container)) {
|
|
530
|
+
if (isIgnored(el))
|
|
531
|
+
continue;
|
|
532
|
+
if (!isRenderedOutsideAriaHidden(el))
|
|
533
|
+
continue;
|
|
534
|
+
if (fuzzyVisibleText(visibleTextOf(el, memo), wanted))
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
function buildEmptyHint(query, state, diagnose = true) {
|
|
487
540
|
const container = resolveContainer(query.scope).container ?? document.body;
|
|
488
541
|
const all = container.querySelectorAll(testIdSelector());
|
|
489
542
|
const present = [];
|
|
@@ -500,7 +553,7 @@ function buildEmptyHint(query) {
|
|
|
500
553
|
}
|
|
501
554
|
const registered = declaredTestids();
|
|
502
555
|
const knownEmptyState = present.some((id) => registered.includes(id));
|
|
503
|
-
const route =
|
|
556
|
+
const route = pageRoute(false);
|
|
504
557
|
const hint = {
|
|
505
558
|
route,
|
|
506
559
|
presentTestids: present,
|
|
@@ -511,14 +564,24 @@ function buildEmptyHint(query) {
|
|
|
511
564
|
const wanted = wantedTextOf(query);
|
|
512
565
|
if (wanted !== undefined) {
|
|
513
566
|
const owner = splitTextOwner(container, wanted, query);
|
|
514
|
-
if (owner !== undefined)
|
|
515
|
-
|
|
567
|
+
if (owner !== undefined) {
|
|
568
|
+
const described = describe(owner);
|
|
569
|
+
const selector = stableSelectorFor(owner);
|
|
570
|
+
hint.splitText = selector === undefined ? described : { ...described, selector };
|
|
571
|
+
}
|
|
516
572
|
else if (true === query.matchCase) {
|
|
517
573
|
const shown = caseOnlyMatch(container, wanted);
|
|
518
574
|
if (shown !== undefined)
|
|
519
575
|
hint.caseMiss = shown;
|
|
520
576
|
}
|
|
521
577
|
}
|
|
578
|
+
if (diagnose &&
|
|
579
|
+
wanted !== undefined &&
|
|
580
|
+
hint.splitText === undefined &&
|
|
581
|
+
(state === undefined || ElementState.VISIBLE === state || ElementState.IN_VIEWPORT === state)) {
|
|
582
|
+
if (textInAriaHidden(container, wanted))
|
|
583
|
+
hint.ariaHiddenMatch = true;
|
|
584
|
+
}
|
|
522
585
|
const near = nameNearMisses(container, query);
|
|
523
586
|
if (near.length > 0)
|
|
524
587
|
hint.nameNearMiss = near;
|
|
@@ -5,3 +5,4 @@ export declare function isCaptured(host: Element): boolean;
|
|
|
5
5
|
export declare function capturedRootOf(host: Element): ShadowRoot | null;
|
|
6
6
|
export declare function onShadowRoot(listener: (root: ShadowRoot) => void): () => void;
|
|
7
7
|
export declare function installShadowRegistry(): () => void;
|
|
8
|
+
export declare function parentAcrossShadowBoundary(el: Element): Element | null;
|