@reticlehq/browser 3.7.0 → 3.8.1
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 +59 -14
- 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 +94 -46
- 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 +43 -17
- package/dist/actions/scroll.d.ts +2 -0
- package/dist/actions/scroll.js +10 -2
- 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 +0 -2
- package/dist/dom/a11y.js +103 -363
- 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 +60 -5
- 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-finders.d.ts +6 -0
- package/dist/dom/query-finders.js +216 -0
- package/dist/dom/query.d.ts +1 -1
- package/dist/dom/query.js +154 -227
- 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 +87 -35
- 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/dom/visibility.d.ts +7 -0
- package/dist/dom/visibility.js +322 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +7 -1
- 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 +68 -19
- 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 +36 -9
- package/dist/observers/net-detail/network-event-stream.d.ts +5 -0
- package/dist/observers/net-detail/network-event-stream.js +55 -0
- package/dist/observers/net-detail/network-frames.d.ts +2 -0
- package/dist/observers/net-detail/network-frames.js +39 -0
- package/dist/observers/net-detail/network-response-body.d.ts +3 -0
- package/dist/observers/net-detail/network-response-body.js +97 -0
- package/dist/observers/network.js +111 -101
- 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 +700 -645
- 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,6 @@
|
|
|
1
1
|
import { RETICLE_OVERLAY_SELECTOR } from '@reticlehq/core';
|
|
2
|
+
import { capturedRootOf } from './shadow-registry.js';
|
|
3
|
+
import { isVisible } from './visibility.js';
|
|
2
4
|
export const RETICLE_OVERLAY = RETICLE_OVERLAY_SELECTOR;
|
|
3
5
|
const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
|
|
4
6
|
let extraIgnore = '';
|
|
@@ -22,28 +24,81 @@ export function isReticleOverlay(el) {
|
|
|
22
24
|
}
|
|
23
25
|
const HUD_DOCK = '[data-reticle-dock]';
|
|
24
26
|
export function isUnobserved(el) {
|
|
25
|
-
if (!isReticleOverlay(el))
|
|
27
|
+
if (!isReticleOverlay(el)) {
|
|
26
28
|
return false;
|
|
29
|
+
}
|
|
27
30
|
return !exposed() || null === el.closest(HUD_DOCK);
|
|
28
31
|
}
|
|
29
32
|
export function isReticleUi(node) {
|
|
30
33
|
return node !== null && node.closest(RETICLE_OVERLAY) !== null;
|
|
31
34
|
}
|
|
32
|
-
const HUD_NARRATION = '[data-reticle-log],[data-reticle-harness-log],[data-reticle-chat-pill-text],[data-reticle-agent-said],[data-reticle-agent-note]';
|
|
35
|
+
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
36
|
export function isIgnored(el) {
|
|
34
37
|
const ours = exposed() ? HUD_NARRATION : RETICLE_OVERLAY;
|
|
35
38
|
const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
|
|
36
39
|
return el.closest(sel) !== null;
|
|
37
40
|
}
|
|
41
|
+
export function isWordsIgnored(el) {
|
|
42
|
+
return isIgnored(el) || isReticleOverlay(el);
|
|
43
|
+
}
|
|
44
|
+
export const FrameworkErrorDialog = {
|
|
45
|
+
NEXT: 'Next.js error dialog',
|
|
46
|
+
VITE: 'Vite error overlay',
|
|
47
|
+
};
|
|
48
|
+
const NEXT_PORTAL = 'nextjs-portal';
|
|
49
|
+
const NEXT_DIALOG = '[data-nextjs-dialog]';
|
|
50
|
+
const VITE_OVERLAY = 'vite-error-overlay';
|
|
51
|
+
function openFrameworkErrorDialog(doc) {
|
|
52
|
+
const vite = doc.querySelector(VITE_OVERLAY);
|
|
53
|
+
if (vite !== null) {
|
|
54
|
+
return { name: FrameworkErrorDialog.VITE, host: vite };
|
|
55
|
+
}
|
|
56
|
+
for (const portal of Array.from(doc.querySelectorAll(NEXT_PORTAL))) {
|
|
57
|
+
const root = portal.shadowRoot ?? capturedRootOf(portal);
|
|
58
|
+
const dialog = (root ?? portal).querySelector(NEXT_DIALOG);
|
|
59
|
+
if (null !== dialog && isVisible(dialog)) {
|
|
60
|
+
return { name: FrameworkErrorDialog.NEXT, host: portal };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const light = doc.querySelector(NEXT_DIALOG);
|
|
64
|
+
if (null === light || !isVisible(light)) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return { name: FrameworkErrorDialog.NEXT, host: light };
|
|
68
|
+
}
|
|
69
|
+
export function dialogCovering(matches) {
|
|
70
|
+
const first = matches[0];
|
|
71
|
+
if (first === undefined) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const dialog = openFrameworkErrorDialog(first.ownerDocument);
|
|
75
|
+
if (dialog === undefined) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return matches.some((el) => !isInsideHost(el, dialog.host)) ? dialog.name : undefined;
|
|
79
|
+
}
|
|
80
|
+
function isInsideHost(el, host) {
|
|
81
|
+
for (let node = el; node !== null;) {
|
|
82
|
+
if (node === host) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const root = node.getRootNode();
|
|
86
|
+
node = node.parentNode ?? (root instanceof ShadowRoot ? root.host : null);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
38
90
|
const APP_MODAL = 'dialog[open], [aria-modal="true"]';
|
|
39
91
|
export function appModalOpen(doc) {
|
|
40
92
|
for (const el of Array.from(doc.querySelectorAll(APP_MODAL))) {
|
|
41
|
-
if (el.closest(RETICLE_OVERLAY) !== null)
|
|
93
|
+
if (el.closest(RETICLE_OVERLAY) !== null) {
|
|
42
94
|
continue;
|
|
43
|
-
|
|
95
|
+
}
|
|
96
|
+
if (el instanceof HTMLElement && (el.hidden || null !== el.closest('[hidden]'))) {
|
|
44
97
|
continue;
|
|
45
|
-
|
|
98
|
+
}
|
|
99
|
+
if (el instanceof HTMLElement && 'none' === getComputedStyle(el).display) {
|
|
46
100
|
continue;
|
|
101
|
+
}
|
|
47
102
|
return true;
|
|
48
103
|
}
|
|
49
104
|
return false;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ElementQuery } from '@reticlehq/core';
|
|
2
|
+
export declare function elementsUnder(container: HTMLElement): HTMLElement[];
|
|
3
|
+
export declare function isNonRendered(el: Element): boolean;
|
|
4
|
+
export declare function isHiddenNativeOption(el: HTMLElement): boolean;
|
|
5
|
+
export declare function queryByRoleAndName(container: HTMLElement, role: string, name: string | undefined): HTMLElement[];
|
|
6
|
+
export declare function findIn(container: HTMLElement, query: ElementQuery): HTMLElement[];
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { DATA_RETICLE_SOURCE_ATTR, QueryBy } from '@reticlehq/core';
|
|
2
|
+
import { isInput, isSelect, isTextArea } from './realm.js';
|
|
3
|
+
import { getAccessibleName, getRole } from './a11y.js';
|
|
4
|
+
import { isVisible } from './visibility.js';
|
|
5
|
+
import { identifyComponent } from '../registry/stores/adapters.js';
|
|
6
|
+
import { readTestId, testIdSelector } from './addressing/testid-attr.js';
|
|
7
|
+
import { exactVisibleText, fuzzyVisibleText, comparableText, paintedCase, textMatches, } from './text-match.js';
|
|
8
|
+
const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
|
|
9
|
+
const MAX_COMPONENT_CANDIDATES = 2000;
|
|
10
|
+
const componentCandidateSelector = () => `[${SOURCE_ATTR}], ${testIdSelector()}, button, a, input, select, textarea, [role]`;
|
|
11
|
+
export function elementsUnder(container) {
|
|
12
|
+
const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
|
|
13
|
+
return [...self, ...Array.from(container.querySelectorAll('*'))];
|
|
14
|
+
}
|
|
15
|
+
const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
|
|
16
|
+
export function isNonRendered(el) {
|
|
17
|
+
return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
function directText(el) {
|
|
20
|
+
if (isInput(el)) {
|
|
21
|
+
const type = el.type.toLowerCase();
|
|
22
|
+
if ('submit' === type || 'button' === type || 'reset' === type) {
|
|
23
|
+
return el.value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (isNonRendered(el)) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
return Array.from(el.childNodes)
|
|
30
|
+
.filter((node) => Node.TEXT_NODE === node.nodeType)
|
|
31
|
+
.map((node) => node.textContent ?? '')
|
|
32
|
+
.join('');
|
|
33
|
+
}
|
|
34
|
+
function semanticNameTarget(el) {
|
|
35
|
+
if (isInput(el) || isTextArea(el) || isSelect(el)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
return ('button' === tag ||
|
|
40
|
+
'meter' === tag ||
|
|
41
|
+
'output' === tag ||
|
|
42
|
+
'progress' === tag ||
|
|
43
|
+
el.hasAttribute('aria-label') ||
|
|
44
|
+
el.hasAttribute('aria-labelledby'));
|
|
45
|
+
}
|
|
46
|
+
function findBySource(container, source) {
|
|
47
|
+
const prefix = `${source.file}:${source.line}:`.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
48
|
+
try {
|
|
49
|
+
return Array.from(container.querySelectorAll(`[${SOURCE_ATTR}^="${prefix}"]`));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function findByComponentName(container, component) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let scanned = 0;
|
|
58
|
+
for (const el of Array.from(container.querySelectorAll(componentCandidateSelector()))) {
|
|
59
|
+
if (scanned >= MAX_COMPONENT_CANDIDATES) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
scanned += 1;
|
|
63
|
+
const info = identifyComponent(el);
|
|
64
|
+
if (info !== null && info.componentStack[0] === component) {
|
|
65
|
+
out.push(el);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function findByComponent(container, query) {
|
|
71
|
+
if (query.source !== undefined) {
|
|
72
|
+
const bySource = findBySource(container, query.source);
|
|
73
|
+
if (bySource.length > 0) {
|
|
74
|
+
return bySource;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (query.component !== undefined && query.component.length > 0) {
|
|
78
|
+
return findByComponentName(container, query.component);
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
function matchesRole(actual, queried) {
|
|
83
|
+
if (actual === queried) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if ('textbox' === queried && 'searchbox' === actual) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const OPTION_ROLE = 'option';
|
|
92
|
+
const HTML_OPTION_TAG = 'option';
|
|
93
|
+
const HTML_SELECT_TAG = 'select';
|
|
94
|
+
const VISUALLY_HIDDEN_MAX_PX = 1;
|
|
95
|
+
export function isHiddenNativeOption(el) {
|
|
96
|
+
if (HTML_OPTION_TAG !== el.localName) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const select = el.closest(HTML_SELECT_TAG);
|
|
100
|
+
if (!isSelect(select)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (!isVisible(select)) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return isVisuallyHiddenBox(select);
|
|
107
|
+
}
|
|
108
|
+
function isVisuallyHiddenBox(select) {
|
|
109
|
+
const box = select.getBoundingClientRect();
|
|
110
|
+
return (box.width > 0 &&
|
|
111
|
+
box.height > 0 &&
|
|
112
|
+
box.width <= VISUALLY_HIDDEN_MAX_PX &&
|
|
113
|
+
box.height <= VISUALLY_HIDDEN_MAX_PX);
|
|
114
|
+
}
|
|
115
|
+
function preferVisibleActionableOption(role, matches) {
|
|
116
|
+
if (OPTION_ROLE !== role) {
|
|
117
|
+
return matches;
|
|
118
|
+
}
|
|
119
|
+
const kept = matches.filter((el) => !isHiddenNativeOption(el));
|
|
120
|
+
return kept.length === matches.length ? matches : kept;
|
|
121
|
+
}
|
|
122
|
+
export function queryByRoleAndName(container, role, name) {
|
|
123
|
+
const matched = elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
|
|
124
|
+
(name === undefined || exactVisibleText(getAccessibleName(el), name)));
|
|
125
|
+
return preferVisibleActionableOption(role, matched);
|
|
126
|
+
}
|
|
127
|
+
function queryByText(container, value, query) {
|
|
128
|
+
return elementsUnder(container).filter((el) => {
|
|
129
|
+
const own = directText(el);
|
|
130
|
+
if (textMatches(own, query, value)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
if (true !== query.matchCase || !fuzzyVisibleText(own, value)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const painted = paintedCase(el, own);
|
|
137
|
+
return painted !== undefined && textMatches(painted, query, value);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function queryByLabel(container, value) {
|
|
141
|
+
const expected = comparableText(value).toLowerCase();
|
|
142
|
+
const exact = [];
|
|
143
|
+
const fuzzy = [];
|
|
144
|
+
for (const el of elementsUnder(container)) {
|
|
145
|
+
if (!semanticNameTarget(el)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const name = comparableText(getAccessibleName(el)).toLowerCase();
|
|
149
|
+
if (!name.includes(expected)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
(name === expected ? exact : fuzzy).push(el);
|
|
153
|
+
}
|
|
154
|
+
return exact.length > 0 ? exact : fuzzy;
|
|
155
|
+
}
|
|
156
|
+
function queryByPlaceholder(container, value) {
|
|
157
|
+
return elementsUnder(container).filter((el) => {
|
|
158
|
+
const placeholder = el.getAttribute('placeholder');
|
|
159
|
+
return placeholder !== null && fuzzyVisibleText(placeholder, value);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function queryByTestId(container, value) {
|
|
163
|
+
return elementsUnder(container).filter((el) => readTestId(el) === value);
|
|
164
|
+
}
|
|
165
|
+
function queryByAlt(container, value) {
|
|
166
|
+
return elementsUnder(container).filter((el) => {
|
|
167
|
+
const alt = el.getAttribute('alt');
|
|
168
|
+
return alt !== null && fuzzyVisibleText(alt, value);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
export function findIn(container, query) {
|
|
172
|
+
const by = query.by;
|
|
173
|
+
const value = query.value;
|
|
174
|
+
if (by !== undefined && value !== undefined) {
|
|
175
|
+
switch (by) {
|
|
176
|
+
case QueryBy.ROLE:
|
|
177
|
+
return queryByRoleAndName(container, value, query.name);
|
|
178
|
+
case QueryBy.TEXT:
|
|
179
|
+
return queryByText(container, value, query);
|
|
180
|
+
case QueryBy.LABEL:
|
|
181
|
+
return queryByLabel(container, value);
|
|
182
|
+
case QueryBy.PLACEHOLDER:
|
|
183
|
+
return queryByPlaceholder(container, value);
|
|
184
|
+
case QueryBy.TESTID:
|
|
185
|
+
return queryByTestId(container, value);
|
|
186
|
+
case QueryBy.ALT:
|
|
187
|
+
return queryByAlt(container, value);
|
|
188
|
+
case QueryBy.COMPONENT:
|
|
189
|
+
return findByComponent(container, { ...query, component: query.component ?? value });
|
|
190
|
+
default:
|
|
191
|
+
throw new Error(`unsupported query strategy '${String(by)}' - use one of: ${Object.values(QueryBy).join(', ')}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (query.component !== undefined || query.source !== undefined) {
|
|
195
|
+
return findByComponent(container, query);
|
|
196
|
+
}
|
|
197
|
+
if (query.role !== undefined) {
|
|
198
|
+
return queryByRoleAndName(container, query.role, query.name);
|
|
199
|
+
}
|
|
200
|
+
if (query.text !== undefined) {
|
|
201
|
+
return queryByText(container, query.text, query);
|
|
202
|
+
}
|
|
203
|
+
if (query.label !== undefined) {
|
|
204
|
+
return queryByLabel(container, query.label);
|
|
205
|
+
}
|
|
206
|
+
if (query.placeholder !== undefined) {
|
|
207
|
+
return queryByPlaceholder(container, query.placeholder);
|
|
208
|
+
}
|
|
209
|
+
if (query.testid !== undefined) {
|
|
210
|
+
return queryByTestId(container, query.testid);
|
|
211
|
+
}
|
|
212
|
+
if (query.alt !== undefined) {
|
|
213
|
+
return queryByAlt(container, query.alt);
|
|
214
|
+
}
|
|
215
|
+
return [];
|
|
216
|
+
}
|
package/dist/dom/query.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ElementState, type ElementQuery, type MatchResult, 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): MatchResult;
|
|
3
|
+
export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean, diagnose?: boolean): MatchResult;
|
|
4
4
|
export declare function elementsMatching(query: ElementQuery): HTMLElement[];
|
|
5
5
|
export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
|