@teaflask/assistant 0.7.0 → 0.7.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.
@@ -0,0 +1,315 @@
1
+ import { CLOSE_SETTLE_MS, clicked, OPEN_SETTLE_MS, PICK_SETTLE_MS, pressKey, pressLikeAPointer, settledReading, theClickWouldSubmitOrLeaveThePage, theControlIsInert, } from "./page-gestures.js";
2
+ import { CONTROL_REDRAWN_REASON, CURATED_OPTIONS_REASON, DROPDOWN_NEVER_OPENED_REASON, DROPDOWN_PICK_DID_NOT_LAND_REASON, INERT_FIELD_REASON, MULTI_CHOICE_REASON, NO_OPTIONS_SHOWN_REASON, noMatchingOptionReasonListing, SUBMITTING_CONTROL_REASON, unavailableOptionReason, } from "./prefill-reasons.js";
3
+ import { accessibleNameOf, } from "../reader/accessible-name.js";
4
+ import { ariaBooleanOf, roleOf, theControlIsDisabled, } from "../reader/roles.js";
5
+ import { alreadyShowsTheValue, comboboxOwnerOf, confirmsLabel, displayNamesTheMatch, ownDisplayIsReliable, ownerShowsThePick, shownTextOf, } from "./dropdown-display.js";
6
+ import { _normalizedNodeName } from "../reader/snapshot-node.js";
7
+ import { lastReadIgnoreSelectors } from "../reader/snapshot.js";
8
+ import { theElementSitsInACuratedSubtree } from "../reader/snapshot-root.js";
9
+ import { visibilityOracleOf } from "../reader/visibility.js";
10
+ export async function customDropdownRefusalOf(element, value, probe, listingBudget, whenNoListOpens) {
11
+ const entryRefusal = _dropdownEntryRefusalOf(element);
12
+ if (entryRefusal !== null) {
13
+ return entryRefusal;
14
+ }
15
+ const want = value.trim();
16
+ if (ownDisplayIsReliable(element) &&
17
+ alreadyShowsTheValue(element, want, probe, null)) {
18
+ return null;
19
+ }
20
+ if (!(element instanceof HTMLInputElement) &&
21
+ theClickWouldSubmitOrLeaveThePage(element)) {
22
+ return SUBMITTING_CONTROL_REASON;
23
+ }
24
+ const found = await _openedListboxFor(element, probe);
25
+ const listbox = found.listbox;
26
+ const closedIfOurs = async () => {
27
+ if (found.provenance === "ours") {
28
+ await _closedPopup(element, listbox, probe);
29
+ }
30
+ };
31
+ if (listbox === null) {
32
+ if (!element.isConnected) {
33
+ return CONTROL_REDRAWN_REASON;
34
+ }
35
+ if (found.gesturesWereDispatched) {
36
+ await _closedPopup(element, listbox, probe);
37
+ }
38
+ return whenNoListOpens !== undefined
39
+ ? whenNoListOpens(DROPDOWN_NEVER_OPENED_REASON)
40
+ : DROPDOWN_NEVER_OPENED_REASON;
41
+ }
42
+ if (!element.isConnected) {
43
+ return CONTROL_REDRAWN_REASON;
44
+ }
45
+ if (!ownDisplayIsReliable(element) &&
46
+ alreadyShowsTheValue(element, want, probe, listbox)) {
47
+ await closedIfOurs();
48
+ return null;
49
+ }
50
+ if (theElementSitsInACuratedSubtree(listbox, lastReadIgnoreSelectors())) {
51
+ await closedIfOurs();
52
+ return CURATED_OPTIONS_REASON;
53
+ }
54
+ if (ariaBooleanOf(listbox.getAttribute("aria-multiselectable")) === true) {
55
+ await closedIfOurs();
56
+ return MULTI_CHOICE_REASON;
57
+ }
58
+ const options = _visibleOptionsOf(listbox, probe);
59
+ if (options.length === 0) {
60
+ await closedIfOurs();
61
+ return whenNoListOpens !== undefined
62
+ ? whenNoListOpens(NO_OPTIONS_SHOWN_REASON)
63
+ : NO_OPTIONS_SHOWN_REASON;
64
+ }
65
+ const cache = new Map();
66
+ const nameOf = (option) => accessibleNameOf(option, probe, cache).trim();
67
+ const match = _optionMatching(options, want, value, nameOf);
68
+ if (match === null) {
69
+ await closedIfOurs();
70
+ return _noMatchRefusalOf(element, options, nameOf, listingBudget);
71
+ }
72
+ const label = nameOf(match) !== "" ? nameOf(match) : want;
73
+ if (match.getAttribute("aria-selected") === "true" &&
74
+ displayNamesTheMatch(shownTextOf(element, probe, listbox), label, options, nameOf)) {
75
+ await closedIfOurs();
76
+ return null;
77
+ }
78
+ if (theControlIsDisabled(match, "option")) {
79
+ await closedIfOurs();
80
+ return unavailableOptionReason(label);
81
+ }
82
+ if (theClickWouldSubmitOrLeaveThePage(match)) {
83
+ await closedIfOurs();
84
+ return SUBMITTING_CONTROL_REASON;
85
+ }
86
+ return _confirmedPickRefusalOf(element, listbox, match, label, probe);
87
+ }
88
+ async function _confirmedPickRefusalOf(element, listbox, match, label, probe) {
89
+ const beforeText = shownTextOf(element, probe, listbox);
90
+ clicked(match);
91
+ const after = await settledReading(() => shownTextOf(element, probe, listbox), (text) => !element.isConnected ||
92
+ (text !== beforeText && confirmsLabel(text, label)), PICK_SETTLE_MS);
93
+ await _closedPopup(element, listbox, probe);
94
+ if (!element.isConnected) {
95
+ return CONTROL_REDRAWN_REASON;
96
+ }
97
+ const landed = after !== beforeText && confirmsLabel(after, label);
98
+ return landed ? null : DROPDOWN_PICK_DID_NOT_LAND_REASON;
99
+ }
100
+ export async function listboxPickRefusalOf(element, value, probe, listingBudget) {
101
+ if (theControlIsInert(element)) {
102
+ return INERT_FIELD_REASON;
103
+ }
104
+ if (ariaBooleanOf(element.getAttribute("aria-multiselectable")) === true) {
105
+ return MULTI_CHOICE_REASON;
106
+ }
107
+ const options = _visibleOptionsOf(element, probe);
108
+ if (options.length === 0) {
109
+ return NO_OPTIONS_SHOWN_REASON;
110
+ }
111
+ const cache = new Map();
112
+ const nameOf = (option) => accessibleNameOf(option, probe, cache).trim();
113
+ const want = value.trim();
114
+ const match = _optionMatching(options, want, value, nameOf);
115
+ if (match === null) {
116
+ return _noMatchRefusalOf(element, options, nameOf, listingBudget);
117
+ }
118
+ if (match.getAttribute("aria-selected") === "true") {
119
+ return null;
120
+ }
121
+ const label = nameOf(match) !== "" ? nameOf(match) : want;
122
+ if (theControlIsDisabled(match, "option")) {
123
+ return unavailableOptionReason(label);
124
+ }
125
+ if (theClickWouldSubmitOrLeaveThePage(match)) {
126
+ return SUBMITTING_CONTROL_REASON;
127
+ }
128
+ const owner = comboboxOwnerOf(element);
129
+ const ownerBefore = owner === null ? "" : shownTextOf(owner, probe, element);
130
+ clicked(match);
131
+ const selected = await settledReading(() => match.getAttribute("aria-selected"), (state) => state === "true" || !element.isConnected || !match.isConnected, PICK_SETTLE_MS);
132
+ if (element.isConnected && match.isConnected) {
133
+ return selected === "true" ? null : DROPDOWN_PICK_DID_NOT_LAND_REASON;
134
+ }
135
+ if (owner !== null) {
136
+ return (await ownerShowsThePick(owner, ownerBefore, label, probe))
137
+ ? null
138
+ : DROPDOWN_PICK_DID_NOT_LAND_REASON;
139
+ }
140
+ return CONTROL_REDRAWN_REASON;
141
+ }
142
+ async function _openedListboxFor(element, probe) {
143
+ if (element.getAttribute("aria-expanded") === "true") {
144
+ const alreadyOpen = _openedListboxOf(element, probe, new Set(), (candidate) => _popupBelongsToTrigger(candidate, element, probe));
145
+ return {
146
+ listbox: alreadyOpen,
147
+ provenance: alreadyOpen !== null ? "users" : "unattributed",
148
+ gesturesWereDispatched: false,
149
+ };
150
+ }
151
+ const alreadyShowing = visibilityOracleOf(probe);
152
+ const before = new Set(Array.from(document.querySelectorAll('[role="listbox"]')).filter((candidate) => alreadyShowing.isVisible(candidate)));
153
+ if (element instanceof HTMLElement) {
154
+ element.focus();
155
+ }
156
+ pressKey(element, "ArrowDown");
157
+ const viaKeyboard = await settledReading(() => _openedListboxOf(element, probe, before, () => true), (listbox) => listbox !== null || !element.isConnected, OPEN_SETTLE_MS);
158
+ if (viaKeyboard !== null) {
159
+ return {
160
+ listbox: viaKeyboard,
161
+ provenance: "ours",
162
+ gesturesWereDispatched: true,
163
+ };
164
+ }
165
+ if (!element.isConnected ||
166
+ theClickWouldSubmitOrLeaveThePage(element) ||
167
+ element.getAttribute("aria-expanded") === "true") {
168
+ return {
169
+ listbox: null,
170
+ provenance: "unattributed",
171
+ gesturesWereDispatched: true,
172
+ };
173
+ }
174
+ pressLikeAPointer(element);
175
+ const viaPointer = await settledReading(() => _openedListboxOf(element, probe, before, () => true), (listbox) => listbox !== null || !element.isConnected, OPEN_SETTLE_MS);
176
+ return {
177
+ listbox: viaPointer,
178
+ provenance: viaPointer !== null ? "ours" : "unattributed",
179
+ gesturesWereDispatched: true,
180
+ };
181
+ }
182
+ const KINSHIP_CLIMB_MAX = 5;
183
+ function _popupBelongsToTrigger(candidate, trigger, probe) {
184
+ if (candidate.contains(trigger) || trigger.contains(candidate)) {
185
+ return true;
186
+ }
187
+ const oracle = visibilityOracleOf(probe);
188
+ const body = trigger.ownerDocument.body;
189
+ let current = trigger.parentElement;
190
+ for (let depth = 0; depth < KINSHIP_CLIMB_MAX && current !== null && current !== body; depth++) {
191
+ const visibleInside = Array.from(current.querySelectorAll('[role="listbox"]')).filter((listbox) => oracle.isVisible(listbox));
192
+ if (visibleInside.length > 0) {
193
+ return visibleInside.length === 1 && visibleInside[0] === candidate;
194
+ }
195
+ current = current.parentElement;
196
+ }
197
+ return false;
198
+ }
199
+ function _openedListboxOf(trigger, probe, before, unlinkedIsOurs) {
200
+ const oracle = visibilityOracleOf(probe);
201
+ const byId = _idReferencedListboxOf(trigger, "aria-controls") ??
202
+ _idReferencedListboxOf(trigger, "aria-owns");
203
+ if (byId !== null && oracle.isVisible(byId)) {
204
+ return byId;
205
+ }
206
+ const active = _resolvedIdNear(trigger, trigger.getAttribute("aria-activedescendant"));
207
+ const viaActive = active?.closest('[role="listbox"]') ?? null;
208
+ if (viaActive !== null && oracle.isVisible(viaActive)) {
209
+ return viaActive;
210
+ }
211
+ for (const candidate of Array.from(document.querySelectorAll('[role="listbox"]'))) {
212
+ if (!before.has(candidate) &&
213
+ oracle.isVisible(candidate) &&
214
+ unlinkedIsOurs(candidate)) {
215
+ return candidate;
216
+ }
217
+ }
218
+ return null;
219
+ }
220
+ function _idReferencedListboxOf(trigger, attribute) {
221
+ const ids = (trigger.getAttribute(attribute) ?? "")
222
+ .split(/\s+/)
223
+ .filter((id) => id !== "");
224
+ for (const id of ids) {
225
+ const hit = _resolvedIdNear(trigger, id);
226
+ if (hit === null) {
227
+ continue;
228
+ }
229
+ if (roleOf(hit) === "listbox") {
230
+ return hit;
231
+ }
232
+ const inner = hit.querySelector('[role="listbox"]');
233
+ if (inner !== null) {
234
+ return inner;
235
+ }
236
+ }
237
+ return null;
238
+ }
239
+ function _resolvedIdNear(trigger, id) {
240
+ if (id === null || id === "") {
241
+ return null;
242
+ }
243
+ const root = trigger.getRootNode();
244
+ if (root instanceof Document || root instanceof ShadowRoot) {
245
+ return root.getElementById(id);
246
+ }
247
+ return document.getElementById(id);
248
+ }
249
+ function _optionMatching(options, want, rawValue, nameOf) {
250
+ if (want === "") {
251
+ return null;
252
+ }
253
+ return (options.find((option) => nameOf(option) === want) ??
254
+ options.find((option) => nameOf(option).toLowerCase() === want.toLowerCase()) ??
255
+ options.find((option) => {
256
+ const shownToTheModel = _normalizedNodeName(nameOf(option));
257
+ const comparable = shownToTheModel.trimEnd();
258
+ return (shownToTheModel !== nameOf(option) &&
259
+ (comparable === want || comparable.toLowerCase() === want.toLowerCase()));
260
+ }) ??
261
+ options.find((option) => option.getAttribute("data-value") === rawValue) ??
262
+ null);
263
+ }
264
+ function _visibleOptionsOf(listbox, probe) {
265
+ const oracle = visibilityOracleOf(probe);
266
+ const ignoreSelectors = lastReadIgnoreSelectors();
267
+ return Array.from(listbox.querySelectorAll('[role="option"]')).filter((option) => oracle.isVisible(option) &&
268
+ !theElementSitsInACuratedSubtree(option, ignoreSelectors));
269
+ }
270
+ async function _closedPopup(control, listbox, probe) {
271
+ if (_popupLooksClosed(control, listbox, probe)) {
272
+ return;
273
+ }
274
+ const active = control.ownerDocument.activeElement;
275
+ const focused = active !== null &&
276
+ (control.contains(active) || (listbox?.contains(active) ?? false))
277
+ ? active
278
+ : null;
279
+ if (focused instanceof HTMLInputElement) {
280
+ focused.blur();
281
+ }
282
+ else if (control instanceof HTMLInputElement) {
283
+ control.blur();
284
+ }
285
+ else {
286
+ pressKey(focused ?? control, "Escape");
287
+ }
288
+ await settledReading(() => _popupLooksClosed(control, listbox, probe), (closed) => closed, CLOSE_SETTLE_MS);
289
+ }
290
+ function _popupLooksClosed(control, listbox, probe) {
291
+ if (!control.isConnected) {
292
+ return true;
293
+ }
294
+ if (control.hasAttribute("aria-expanded")) {
295
+ return control.getAttribute("aria-expanded") !== "true";
296
+ }
297
+ return !_popupShowing(listbox, probe);
298
+ }
299
+ function _popupShowing(listbox, probe) {
300
+ return (listbox !== null &&
301
+ listbox.isConnected &&
302
+ visibilityOracleOf(probe).isVisible(listbox));
303
+ }
304
+ function _dropdownEntryRefusalOf(element) {
305
+ if (theControlIsInert(element) || theControlIsDisabled(element, "combobox")) {
306
+ return INERT_FIELD_REASON;
307
+ }
308
+ return null;
309
+ }
310
+ function _noMatchRefusalOf(element, options, nameOf, listingBudget) {
311
+ const labels = options
312
+ .filter((option) => !theControlIsDisabled(option, "option"))
313
+ .map(nameOf);
314
+ return noMatchingOptionReasonListing(labels, listingBudget, element instanceof HTMLInputElement && !element.readOnly);
315
+ }
@@ -0,0 +1,39 @@
1
+ import { clicked, settledReading, theClickWouldSubmitOrLeaveThePage, theControlIsInert, TOGGLE_SETTLE_MS, } from "./page-gestures.js";
2
+ import { CHECKBOX_VALUE_REASON, CHECKED_STATE_UNKNOWN_REASON, CONTROL_REDRAWN_REASON, INERT_FIELD_REASON, RADIO_VALUE_REASON, SUBMITTING_CONTROL_REASON, VALUE_DID_NOT_LAND_REASON, } from "./prefill-reasons.js";
3
+ import { roleOf } from "../reader/roles.js";
4
+ export async function ariaToggleRefusalOf(element, value) {
5
+ if (roleOf(element) === "radio" && value !== "true") {
6
+ return RADIO_VALUE_REASON;
7
+ }
8
+ if (value !== "true" && value !== "false") {
9
+ return CHECKBOX_VALUE_REASON;
10
+ }
11
+ if (theControlIsInert(element)) {
12
+ return INERT_FIELD_REASON;
13
+ }
14
+ const state = _rawCheckedOf(element);
15
+ if (state === null) {
16
+ return CHECKED_STATE_UNKNOWN_REASON;
17
+ }
18
+ if (state === value) {
19
+ return null;
20
+ }
21
+ if (theClickWouldSubmitOrLeaveThePage(element)) {
22
+ return SUBMITTING_CONTROL_REASON;
23
+ }
24
+ for (let attempt = 0; attempt < 2; attempt++) {
25
+ const before = _rawCheckedOf(element);
26
+ clicked(element);
27
+ const after = await settledReading(() => _rawCheckedOf(element), (read) => read === value || !element.isConnected, TOGGLE_SETTLE_MS);
28
+ if (after === value || after === before) {
29
+ break;
30
+ }
31
+ }
32
+ if (!element.isConnected) {
33
+ return CONTROL_REDRAWN_REASON;
34
+ }
35
+ return _rawCheckedOf(element) === value ? null : VALUE_DID_NOT_LAND_REASON;
36
+ }
37
+ function _rawCheckedOf(element) {
38
+ return element.getAttribute("aria-checked")?.toLowerCase() ?? null;
39
+ }
@@ -0,0 +1,129 @@
1
+ import { PICK_SETTLE_MS, settledReading } from "./page-gestures.js";
2
+ import { roleOf } from "../reader/roles.js";
3
+ import { lastReadIgnoreSelectors } from "../reader/snapshot.js";
4
+ import { _theSubtreeIsCurated } from "../reader/snapshot-root.js";
5
+ import { visibilityOracleOf, } from "../reader/visibility.js";
6
+ export function shownTextOf(control, probe, excluding) {
7
+ if (control instanceof HTMLInputElement) {
8
+ const own = _normalized(control.value);
9
+ if (own !== "") {
10
+ return own;
11
+ }
12
+ return _nearestAncestorTextOf(control, probe, excluding);
13
+ }
14
+ const text = _visibleTextOf(control, probe, excluding);
15
+ if (text !== "") {
16
+ return text;
17
+ }
18
+ const inner = Array.from(control.querySelectorAll("input")).find((candidate) => !excluding?.contains(candidate));
19
+ return inner === undefined ? "" : _normalized(inner.value);
20
+ }
21
+ export function ownDisplayIsReliable(element) {
22
+ return element instanceof HTMLInputElement
23
+ ? element.value.trim() !== ""
24
+ : element.querySelector('[role="listbox"]') === null;
25
+ }
26
+ export function alreadyShowsTheValue(element, want, probe, excluding) {
27
+ return (want !== "" &&
28
+ _normalized(shownTextOf(element, probe, excluding)).toLowerCase() ===
29
+ _normalized(want).toLowerCase());
30
+ }
31
+ export function confirmsLabel(text, label) {
32
+ const shown = _normalized(text).toLowerCase();
33
+ const wanted = _normalized(label).toLowerCase();
34
+ if (shown === wanted) {
35
+ return true;
36
+ }
37
+ return _containsAsAWholeToken(shown, wanted);
38
+ }
39
+ export function displayNamesTheMatch(shown, matchLabel, options, nameOf) {
40
+ if (!confirmsLabel(shown, matchLabel)) {
41
+ return false;
42
+ }
43
+ return options.every((option) => {
44
+ const other = nameOf(option);
45
+ return other.length <= matchLabel.length || !confirmsLabel(shown, other);
46
+ });
47
+ }
48
+ export function comboboxOwnerOf(listbox) {
49
+ const id = listbox.id;
50
+ if (id === "") {
51
+ return null;
52
+ }
53
+ const root = listbox.getRootNode();
54
+ const scope = root instanceof Document || root instanceof ShadowRoot ? root : document;
55
+ for (const candidate of Array.from(scope.querySelectorAll("[aria-controls], [aria-owns]"))) {
56
+ const named = [
57
+ candidate.getAttribute("aria-controls") ?? "",
58
+ candidate.getAttribute("aria-owns") ?? "",
59
+ ]
60
+ .join(" ")
61
+ .split(/\s+/)
62
+ .filter((token) => token !== "");
63
+ if (named.includes(id) && roleOf(candidate) === "combobox") {
64
+ return candidate;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ export async function ownerShowsThePick(owner, ownerBefore, label, probe) {
70
+ const after = await settledReading(() => shownTextOf(owner, probe, null), (text) => text !== ownerBefore && confirmsLabel(text, label), PICK_SETTLE_MS);
71
+ return after !== ownerBefore && confirmsLabel(after, label);
72
+ }
73
+ function _containsAsAWholeToken(shown, wanted) {
74
+ if (wanted === "") {
75
+ return false;
76
+ }
77
+ for (let from = 0;; from += 1) {
78
+ const at = shown.indexOf(wanted, from);
79
+ if (at === -1) {
80
+ return false;
81
+ }
82
+ const before = at === 0 ? "" : shown[at - 1];
83
+ const after = at + wanted.length >= shown.length ? "" : shown[at + wanted.length];
84
+ if (!_isWordCharacter(before) && !_isWordCharacter(after)) {
85
+ return true;
86
+ }
87
+ from = at;
88
+ }
89
+ }
90
+ function _isWordCharacter(character) {
91
+ return character !== "" && /[\p{L}\p{N}_]/u.test(character);
92
+ }
93
+ function _nearestAncestorTextOf(control, probe, excluding) {
94
+ const body = control.ownerDocument.body;
95
+ let current = control.parentElement;
96
+ for (let depth = 0; depth < 5 && current !== null && current !== body; depth++) {
97
+ const text = _visibleTextOf(current, probe, excluding);
98
+ if (text !== "") {
99
+ return text;
100
+ }
101
+ current = current.parentElement;
102
+ }
103
+ return "";
104
+ }
105
+ function _visibleTextOf(root, probe, excluding) {
106
+ return _normalized(_textWithin(root, visibilityOracleOf(probe), excluding));
107
+ }
108
+ function _textWithin(node, oracle, excluding) {
109
+ if (node.nodeType === Node.TEXT_NODE) {
110
+ return node.textContent ?? "";
111
+ }
112
+ if (!(node instanceof Element) || node === excluding) {
113
+ return "";
114
+ }
115
+ if (node.getAttribute("aria-hidden") === "true" || !oracle.isVisible(node)) {
116
+ return "";
117
+ }
118
+ if (_theSubtreeIsCurated(node, lastReadIgnoreSelectors())) {
119
+ return "";
120
+ }
121
+ let text = "";
122
+ for (const child of Array.from(node.childNodes)) {
123
+ text += _textWithin(child, oracle, excluding);
124
+ }
125
+ return text;
126
+ }
127
+ function _normalized(text) {
128
+ return text.replace(/\s+/g, " ").trim();
129
+ }
@@ -0,0 +1,91 @@
1
+ import { ariaBooleanOf, roleOf, theControlIsDisabled, } from "../reader/roles.js";
2
+ export const TOGGLE_SETTLE_MS = 500;
3
+ export const OPEN_SETTLE_MS = 750;
4
+ export const PICK_SETTLE_MS = 750;
5
+ export const CLOSE_SETTLE_MS = 500;
6
+ const SETTLE_POLL_MS = 25;
7
+ export function typeLikeAUser(control, value) {
8
+ const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(control), "value");
9
+ if (descriptor?.set !== undefined) {
10
+ descriptor.set.call(control, value);
11
+ }
12
+ else {
13
+ control.value = value;
14
+ }
15
+ control.dispatchEvent(new Event("input", { bubbles: true }));
16
+ control.dispatchEvent(new Event("change", { bubbles: true }));
17
+ }
18
+ export function pressKey(target, key) {
19
+ const init = {
20
+ key,
21
+ code: key,
22
+ bubbles: true,
23
+ cancelable: true,
24
+ };
25
+ target.dispatchEvent(new KeyboardEvent("keydown", init));
26
+ target.dispatchEvent(new KeyboardEvent("keyup", init));
27
+ }
28
+ export function pressLikeAPointer(target) {
29
+ const armed = target.dispatchEvent(_pointerEventOf("pointerdown"));
30
+ if (armed) {
31
+ target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
32
+ }
33
+ target.dispatchEvent(_pointerEventOf("pointerup"));
34
+ if (armed) {
35
+ target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
36
+ }
37
+ clicked(target);
38
+ }
39
+ export function clicked(target) {
40
+ if (target instanceof HTMLElement) {
41
+ target.click();
42
+ }
43
+ else {
44
+ target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
45
+ }
46
+ }
47
+ function _pointerEventOf(type) {
48
+ const init = { bubbles: true, cancelable: true, pointerId: 1 };
49
+ return typeof PointerEvent === "function"
50
+ ? new PointerEvent(type, init)
51
+ : new MouseEvent(type, init);
52
+ }
53
+ export async function settledReading(read, isSettled, budgetMs) {
54
+ const deadline = Date.now() + budgetMs;
55
+ await _afterMs(0);
56
+ let value = read();
57
+ while (!isSettled(value) && Date.now() < deadline) {
58
+ await _afterMs(SETTLE_POLL_MS);
59
+ value = read();
60
+ }
61
+ return value;
62
+ }
63
+ export function settledValueOf(read) {
64
+ return settledReading(read, () => true, 0);
65
+ }
66
+ function _afterMs(ms) {
67
+ return new Promise((resolve) => {
68
+ setTimeout(resolve, ms);
69
+ });
70
+ }
71
+ export function theClickWouldSubmitOrLeaveThePage(element) {
72
+ if (element.closest("a[href], area[href]") !== null) {
73
+ return true;
74
+ }
75
+ const button = element.closest("button");
76
+ return button !== null && button.form !== null && button.type !== "button";
77
+ }
78
+ export function theControlIsInert(element) {
79
+ if (theControlIsDisabled(element, roleOf(element))) {
80
+ return true;
81
+ }
82
+ if (ariaBooleanOf(element.getAttribute("aria-readonly")) === true) {
83
+ return true;
84
+ }
85
+ const group = element.closest('[role="radiogroup"]');
86
+ if (group !== null &&
87
+ ariaBooleanOf(group.getAttribute("aria-readonly")) === true) {
88
+ return true;
89
+ }
90
+ return element.closest("[inert]") !== null;
91
+ }
@@ -0,0 +1,75 @@
1
+ export const SECRET_FIELD_REASON = "This field holds a secret (a password or payment detail) — the user " +
2
+ "must type it themselves.";
3
+ export const FILE_FIELD_REASON = "File pickers cannot be filled by the assistant — the user must choose " +
4
+ "the file themselves.";
5
+ export const INERT_FIELD_REASON = "This field is disabled or read-only and cannot take a value right now.";
6
+ export const NOT_FILLABLE_REASON = "This element is not a fillable form field — take a fresh read and " +
7
+ "pick a textbox, dropdown, checkbox, radio button, switch, or editor ref.";
8
+ export const CHECKBOX_VALUE_REASON = 'A checkbox or switch takes "true" or "false", nothing else — call ' +
9
+ "again with one of those.";
10
+ export const RADIO_VALUE_REASON = 'A radio button can only be selected — pass "true" on the option to ' +
11
+ "select instead.";
12
+ const NO_MATCHING_OPTION_REASON = "No option in this dropdown matches that value — read the page to see " +
13
+ "the options it offers.";
14
+ export const VALUE_DID_NOT_LAND_REASON = "The page's own scripts rejected this value — it did not stay in the " +
15
+ "field.";
16
+ export const EDITOR_UNAVAILABLE_REASON = "This editor cannot be filled on this page — tell the user what to " +
17
+ "type instead.";
18
+ export const MIRROR_INPUT_REASON = "This checkbox is the page's hidden mirror of a styled control — take " +
19
+ "a fresh read and fill the visible checkbox or switch beside it instead.";
20
+ export const CHECKED_STATE_UNKNOWN_REASON = "This control does not report its checked state — tell the user to " +
21
+ "toggle it themselves.";
22
+ export const CONTROL_REDRAWN_REASON = "The page redrew this control while it was being set — take a fresh " +
23
+ "read and use the new ref.";
24
+ export const SUBMITTING_CONTROL_REASON = "Clicking this control would submit the form or leave the page — tell " +
25
+ "the user to set it themselves.";
26
+ export const DROPDOWN_NEVER_OPENED_REASON = "This dropdown would not open its options — tell the user to pick the " +
27
+ "option themselves.";
28
+ export const CURATED_OPTIONS_REASON = "This dropdown's options sit in a part of the page that has opted out " +
29
+ "of page reads — tell the user to pick it themselves.";
30
+ export const MULTI_CHOICE_REASON = "This control takes several choices at once, which prefill cannot set " +
31
+ "safely — tell the user which options to pick themselves.";
32
+ export const NO_OPTIONS_SHOWN_REASON = "This dropdown opened with nothing to pick — tell the user to fill it " +
33
+ "themselves.";
34
+ export const DROPDOWN_PICK_DID_NOT_LAND_REASON = "Picking that option did not change the dropdown's value — tell the " +
35
+ "user to pick it themselves.";
36
+ const OPTION_LIST_MAX = 15;
37
+ const OPTION_LABEL_MAX = 60;
38
+ const LISTING_REPORT_BUDGET_CHARS = 8_000;
39
+ export function reportListingBudget() {
40
+ return { remainingChars: LISTING_REPORT_BUDGET_CHARS };
41
+ }
42
+ export function noMatchingOptionReasonListing(labels, budget, freeTextHint = false) {
43
+ const named = labels.filter((label) => label !== "");
44
+ const shown = named.slice(0, OPTION_LIST_MAX);
45
+ if (shown.length === 0) {
46
+ return NO_MATCHING_OPTION_REASON;
47
+ }
48
+ const more = named.length - shown.length;
49
+ const overflow = more > 0 ? `, and ${String(more)} more` : "";
50
+ const next = shown.some((label) => label.length > OPTION_LABEL_MAX)
51
+ ? "— a label ending in … is cut short here: read the page for its " +
52
+ "full text, then call prefill_form again with one exactly."
53
+ : "— call prefill_form again with one of these exactly.";
54
+ const hint = freeTextHint
55
+ ? " If this field accepts typed text instead, tell the user the " +
56
+ "exact value to enter."
57
+ : "";
58
+ const listed = "No option in this dropdown matches that value. It offers: " +
59
+ `${shown.map(_quotedLabel).join(", ")}${overflow} ${next}${hint}`;
60
+ if (listed.length > budget.remainingChars) {
61
+ return NO_MATCHING_OPTION_REASON;
62
+ }
63
+ budget.remainingChars -= listed.length;
64
+ return listed;
65
+ }
66
+ export function unavailableOptionReason(label) {
67
+ return (`The option ${_quotedLabel(label)} is disabled right now — call ` +
68
+ "again with a different option or tell the user to pick it themselves.");
69
+ }
70
+ function _quotedLabel(label) {
71
+ const cut = label.length > OPTION_LABEL_MAX
72
+ ? `${label.slice(0, OPTION_LABEL_MAX)}…`
73
+ : label;
74
+ return `"${cut}"`;
75
+ }
@@ -1,26 +1,15 @@
1
+ import { settledValueOf, theClickWouldSubmitOrLeaveThePage, theControlIsInert, typeLikeAUser, } from "./page-gestures.js";
2
+ import { CHECKBOX_VALUE_REASON, CONTROL_REDRAWN_REASON, EDITOR_UNAVAILABLE_REASON, FILE_FIELD_REASON, INERT_FIELD_REASON, MIRROR_INPUT_REASON, MULTI_CHOICE_REASON, noMatchingOptionReasonListing, NOT_FILLABLE_REASON, RADIO_VALUE_REASON, reportListingBudget, SECRET_FIELD_REASON, SUBMITTING_CONTROL_REASON, unavailableOptionReason, VALUE_DID_NOT_LAND_REASON, } from "./prefill-reasons.js";
1
3
  import { theFieldHoldsASecret } from "../reader/redaction.js";
2
- import { resolvedElementOfRef } from "../reader/snapshot.js";
3
- import { isContentEditableElement } from "../reader/roles.js";
4
- const SECRET_FIELD_REASON = "This field holds a secret (a password or payment detail) — the user " +
5
- "must type it themselves.";
6
- const FILE_FIELD_REASON = "File pickers cannot be filled by the assistant — the user must choose " +
7
- "the file themselves.";
8
- const INERT_FIELD_REASON = "This field is disabled or read-only and cannot take a value right now.";
9
- const NOT_FILLABLE_REASON = "This element is not a fillable form field — take a fresh read and " +
10
- "pick a textbox, dropdown, checkbox, or editor ref.";
11
- const CHECKBOX_VALUE_REASON = 'A checkbox takes "true" or "false", nothing else.';
12
- const RADIO_VALUE_REASON = 'A radio button can only be selected — pass "true" on the option to ' +
13
- "select instead.";
14
- const NO_MATCHING_OPTION_REASON = "No option in this dropdown matches that value — read the page to see " +
15
- "the options it offers.";
16
- const VALUE_DID_NOT_LAND_REASON = "The page's own scripts rejected this value — it did not stay in the " +
17
- "field.";
18
- const EDITOR_UNAVAILABLE_REASON = "This editor cannot be filled on this page — tell the user what to " +
19
- "type instead.";
20
- export function prefilledFieldsReport(fields) {
4
+ import { renderedOptionTextOf } from "../reader/render.js";
5
+ import { lastReadIgnoreSelectors, resolvedElementOfRef, } from "../reader/snapshot.js";
6
+ import { theElementSitsInACuratedSubtree } from "../reader/snapshot-root.js";
7
+ import { isContentEditableElement, roleOf, theControlIsDisabled, } from "../reader/roles.js";
8
+ export async function prefilledFieldsReport(fields, probe) {
21
9
  const report = { filled: [], refused: [] };
10
+ const listingBudget = reportListingBudget();
22
11
  for (const field of fields) {
23
- const refusal = _filledFieldRefusalOf(field);
12
+ const refusal = await _filledFieldRefusalOf(field, probe, listingBudget);
24
13
  if (refusal === null) {
25
14
  report.filled.push(field.refId);
26
15
  }
@@ -30,26 +19,52 @@ export function prefilledFieldsReport(fields) {
30
19
  }
31
20
  return report;
32
21
  }
33
- function _filledFieldRefusalOf(field) {
22
+ async function _filledFieldRefusalOf(field, probe, listingBudget) {
34
23
  const resolved = resolvedElementOfRef(field.refId);
35
24
  if (resolved.error !== null) {
36
25
  return resolved.error;
37
26
  }
38
27
  const element = resolved.element;
39
28
  if (element instanceof HTMLInputElement) {
40
- return _inputRefusalOf(element, field.value);
29
+ return _inputRefusalOf(element, field.value, probe, listingBudget);
41
30
  }
42
31
  if (element instanceof HTMLTextAreaElement) {
43
32
  return _textAreaRefusalOf(element, field.value);
44
33
  }
45
34
  if (element instanceof HTMLSelectElement) {
46
- return _selectRefusalOf(element, field.value);
35
+ return _selectRefusalOf(element, field.value, listingBudget);
47
36
  }
48
37
  if (isContentEditableElement(element)) {
49
38
  return _editorRefusalOf(element, field.value);
50
39
  }
40
+ if (_isAriaToggle(element)) {
41
+ const { ariaToggleRefusalOf } = await import("./aria-toggles.js");
42
+ return ariaToggleRefusalOf(element, field.value);
43
+ }
44
+ if (_isCustomDropdown(element)) {
45
+ const { customDropdownRefusalOf } = await import("./aria-dropdowns.js");
46
+ return customDropdownRefusalOf(element, field.value, probe, listingBudget);
47
+ }
48
+ if (_isStandingListbox(element)) {
49
+ const { listboxPickRefusalOf } = await import("./aria-dropdowns.js");
50
+ return listboxPickRefusalOf(element, field.value, probe, listingBudget);
51
+ }
51
52
  return NOT_FILLABLE_REASON;
52
53
  }
54
+ const _TOGGLE_ROLES = new Set(["checkbox", "radio", "switch"]);
55
+ function _isAriaToggle(element) {
56
+ return _TOGGLE_ROLES.has(roleOf(element));
57
+ }
58
+ function _isCustomDropdown(element) {
59
+ if (element instanceof HTMLSelectElement) {
60
+ return false;
61
+ }
62
+ return (roleOf(element) === "combobox" ||
63
+ (element.getAttribute("aria-haspopup") ?? "").toLowerCase() === "listbox");
64
+ }
65
+ function _isStandingListbox(element) {
66
+ return roleOf(element) === "listbox";
67
+ }
53
68
  const _NOT_A_FIELD_INPUT_TYPES = new Set([
54
69
  "hidden",
55
70
  "submit",
@@ -57,7 +72,7 @@ const _NOT_A_FIELD_INPUT_TYPES = new Set([
57
72
  "reset",
58
73
  "image",
59
74
  ]);
60
- function _inputRefusalOf(input, value) {
75
+ async function _inputRefusalOf(input, value, probe, listingBudget) {
61
76
  if (input.type === "password" || theFieldHoldsASecret(input)) {
62
77
  return SECRET_FIELD_REASON;
63
78
  }
@@ -67,59 +82,105 @@ function _inputRefusalOf(input, value) {
67
82
  if (_NOT_A_FIELD_INPUT_TYPES.has(input.type)) {
68
83
  return NOT_FILLABLE_REASON;
69
84
  }
70
- if (input.disabled || input.readOnly) {
85
+ const isComboboxInput = roleOf(input) === "combobox";
86
+ if (theControlIsInert(input) ||
87
+ input.disabled ||
88
+ (input.readOnly && !isComboboxInput)) {
71
89
  return INERT_FIELD_REASON;
72
90
  }
73
91
  if (input.type === "checkbox" || input.type === "radio") {
92
+ if (input.getAttribute("aria-hidden") === "true") {
93
+ return MIRROR_INPUT_REASON;
94
+ }
74
95
  return _checkedRefusalOf(input, value);
75
96
  }
76
- _setThroughThePrototype(input, value);
77
- _announceTheEdit(input);
78
- return input.value === value ? null : VALUE_DID_NOT_LAND_REASON;
97
+ if (isComboboxInput) {
98
+ const { customDropdownRefusalOf } = await import("./aria-dropdowns.js");
99
+ return customDropdownRefusalOf(input, value, probe, listingBudget, async (fallbackReason) => input.readOnly
100
+ ? fallbackReason
101
+ : _typedValueRefusalOf(input, value, () => input.value));
102
+ }
103
+ return _typedValueRefusalOf(input, value, () => input.value);
79
104
  }
80
- function _checkedRefusalOf(input, value) {
81
- if (value !== "true" && value !== "false") {
82
- return CHECKBOX_VALUE_REASON;
105
+ async function _typedValueRefusalOf(control, value, read) {
106
+ typeLikeAUser(control, value);
107
+ const landed = (await settledValueOf(read)) === value;
108
+ if (!control.isConnected) {
109
+ return CONTROL_REDRAWN_REASON;
83
110
  }
84
- if (input.type === "radio" && value === "false") {
111
+ return landed ? null : VALUE_DID_NOT_LAND_REASON;
112
+ }
113
+ async function _checkedRefusalOf(input, value) {
114
+ if (input.type === "radio" && value !== "true") {
85
115
  return RADIO_VALUE_REASON;
86
116
  }
117
+ if (value !== "true" && value !== "false") {
118
+ return CHECKBOX_VALUE_REASON;
119
+ }
87
120
  const desired = value === "true";
88
- if (input.checked !== desired) {
89
- input.click();
121
+ if (input.checked === desired) {
122
+ return null;
123
+ }
124
+ if (theClickWouldSubmitOrLeaveThePage(input)) {
125
+ return SUBMITTING_CONTROL_REASON;
90
126
  }
91
- return input.checked === desired ? null : VALUE_DID_NOT_LAND_REASON;
127
+ input.click();
128
+ const landed = (await settledValueOf(() => input.checked)) === desired;
129
+ if (!input.isConnected) {
130
+ return CONTROL_REDRAWN_REASON;
131
+ }
132
+ return landed ? null : VALUE_DID_NOT_LAND_REASON;
92
133
  }
93
- function _textAreaRefusalOf(textArea, value) {
134
+ async function _textAreaRefusalOf(textArea, value) {
94
135
  if (theFieldHoldsASecret(textArea)) {
95
136
  return SECRET_FIELD_REASON;
96
137
  }
97
- if (textArea.disabled || textArea.readOnly) {
138
+ if (theControlIsInert(textArea) || textArea.disabled || textArea.readOnly) {
98
139
  return INERT_FIELD_REASON;
99
140
  }
100
- _setThroughThePrototype(textArea, value);
101
- _announceTheEdit(textArea);
102
- return textArea.value === value ? null : VALUE_DID_NOT_LAND_REASON;
141
+ return _typedValueRefusalOf(textArea, value, () => textArea.value);
103
142
  }
104
- function _selectRefusalOf(select, value) {
105
- if (select.disabled) {
143
+ async function _selectRefusalOf(select, value, listingBudget) {
144
+ if (theControlIsInert(select) || select.disabled) {
106
145
  return INERT_FIELD_REASON;
107
146
  }
147
+ if (select.multiple) {
148
+ return MULTI_CHOICE_REASON;
149
+ }
108
150
  const option = _optionMatching(select, value);
109
151
  if (option === null) {
110
- return NO_MATCHING_OPTION_REASON;
152
+ const labels = _matchableOptionsOf(select)
153
+ .filter((candidate) => !theControlIsDisabled(candidate, "option"))
154
+ .map((candidate) => candidate.label.trim());
155
+ return noMatchingOptionReasonListing(labels, listingBudget);
156
+ }
157
+ if (select.value === option.value) {
158
+ return null;
111
159
  }
112
- _setThroughThePrototype(select, option.value);
113
- _announceTheEdit(select);
114
- return select.value === option.value ? null : VALUE_DID_NOT_LAND_REASON;
160
+ if (theControlIsDisabled(option, "option")) {
161
+ return unavailableOptionReason(option.label.trim());
162
+ }
163
+ return _typedValueRefusalOf(select, option.value, () => select.value);
115
164
  }
116
165
  function _optionMatching(select, value) {
117
- const options = Array.from(select.options);
166
+ const options = _matchableOptionsOf(select);
118
167
  return (options.find((option) => option.value === value) ??
119
168
  options.find((option) => option.label.trim() === value.trim()) ??
169
+ options.find((option) => {
170
+ const shownToTheModel = renderedOptionTextOf(option);
171
+ const full = option.text.replace(/\s+/g, " ").trim();
172
+ return (shownToTheModel !== full && shownToTheModel.trimEnd() === value.trim());
173
+ }) ??
120
174
  null);
121
175
  }
122
- function _editorRefusalOf(element, value) {
176
+ function _matchableOptionsOf(select) {
177
+ const ignoreSelectors = lastReadIgnoreSelectors();
178
+ return Array.from(select.options).filter((option) => !theElementSitsInACuratedSubtree(option, ignoreSelectors));
179
+ }
180
+ async function _editorRefusalOf(element, value) {
181
+ if (theControlIsInert(element)) {
182
+ return INERT_FIELD_REASON;
183
+ }
123
184
  if (!(element instanceof HTMLElement)) {
124
185
  return EDITOR_UNAVAILABLE_REASON;
125
186
  }
@@ -133,19 +194,9 @@ function _editorRefusalOf(element, value) {
133
194
  }
134
195
  selection.selectAllChildren(element);
135
196
  document.execCommand("insertText", false, value);
136
- const landed = element.textContent.includes(value);
137
- return landed ? null : VALUE_DID_NOT_LAND_REASON;
138
- }
139
- function _setThroughThePrototype(control, value) {
140
- const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(control), "value");
141
- if (descriptor?.set !== undefined) {
142
- descriptor.set.call(control, value);
197
+ const landed = await settledValueOf(() => element.textContent.includes(value));
198
+ if (!element.isConnected) {
199
+ return CONTROL_REDRAWN_REASON;
143
200
  }
144
- else {
145
- control.value = value;
146
- }
147
- }
148
- function _announceTheEdit(control) {
149
- control.dispatchEvent(new Event("input", { bubbles: true }));
150
- control.dispatchEvent(new Event("change", { bubbles: true }));
201
+ return landed ? null : VALUE_DID_NOT_LAND_REASON;
151
202
  }
@@ -55,7 +55,7 @@ export function executionHandlersFor(navigate, executeActionIntent, threadId) {
55
55
  }
56
56
  handlers.set(READ_PAGE_REQUEST_KIND, (entry) => _readPageOutcomeOf(threadId, entry));
57
57
  handlers.set(HIGHLIGHT_REQUEST_KIND, (entry) => Promise.resolve(_highlightOutcomeOf(entry)));
58
- handlers.set(PREFILL_FORM_REQUEST_KIND, (entry) => Promise.resolve(_prefillFormOutcomeOf(entry)));
58
+ handlers.set(PREFILL_FORM_REQUEST_KIND, (entry) => _prefillFormOutcomeOf(entry));
59
59
  return handlers;
60
60
  }
61
61
  async function _navigateOutcomeOf(navigate, entry) {
@@ -316,13 +316,13 @@ function _highlightFailedOutcome(error) {
316
316
  },
317
317
  };
318
318
  }
319
- function _prefillFormOutcomeOf(entry) {
319
+ async function _prefillFormOutcomeOf(entry) {
320
320
  const fields = _prefillFieldsOf(entry.action);
321
321
  if (fields === null) {
322
322
  return UNUSABLE_FIELDS_OUTCOME;
323
323
  }
324
324
  try {
325
- const result = prefilledFieldsReport(fields);
325
+ const result = await prefilledFieldsReport(fields, browserLayoutProbe());
326
326
  return { ok: true, result: { ...result } };
327
327
  }
328
328
  catch (error) {
@@ -55,10 +55,7 @@ function _renderNode(node, depth, lines) {
55
55
  function _renderSelectOptions(select, depth, lines) {
56
56
  const indent = _indentOf(depth);
57
57
  for (const option of Array.from(select.options)) {
58
- const text = option.text
59
- .replace(/\s+/g, " ")
60
- .trim()
61
- .substring(0, MAX_OPTION_TEXT_LENGTH);
58
+ const text = renderedOptionTextOf(option);
62
59
  let line = `${indent}- option "${_escapedText(text)}"`;
63
60
  if (option.selected) {
64
61
  line += " (selected)";
@@ -115,3 +112,9 @@ function _escapedText(text) {
115
112
  function _indentOf(depth) {
116
113
  return " ".repeat(depth);
117
114
  }
115
+ export function renderedOptionTextOf(option) {
116
+ return option.text
117
+ .replace(/\s+/g, " ")
118
+ .trim()
119
+ .substring(0, MAX_OPTION_TEXT_LENGTH);
120
+ }
@@ -1,3 +1,4 @@
1
+ import { parentAcrossShadowBoundaryOf } from "./visibility.js";
1
2
  const IMPLICIT_ROLE_BY_TAG = {
2
3
  a: "link",
3
4
  button: "button",
@@ -131,7 +132,7 @@ export const ARIA_CHECKED_ROLES = new Set([
131
132
  "menuitemradio",
132
133
  "switch",
133
134
  ]);
134
- export const ARIA_DISABLED_ROLES = new Set([
135
+ const ARIA_DISABLED_ROLES = new Set([
135
136
  "application",
136
137
  "button",
137
138
  "composite",
@@ -213,6 +214,32 @@ export function ariaBooleanOf(attribute) {
213
214
  }
214
215
  return attribute.toLowerCase() === "true";
215
216
  }
217
+ export function theControlIsDisabled(element, role) {
218
+ if (!ARIA_DISABLED_ROLES.has(role)) {
219
+ return false;
220
+ }
221
+ if (_isNativelyDisabled(element)) {
222
+ return true;
223
+ }
224
+ for (let current = element; current !== null; current = parentAcrossShadowBoundaryOf(current)) {
225
+ if (ariaBooleanOf(current.getAttribute("aria-disabled")) === true) {
226
+ return true;
227
+ }
228
+ }
229
+ return false;
230
+ }
231
+ function _isNativelyDisabled(element) {
232
+ const tag = element.tagName.toLowerCase();
233
+ if (!["button", "input", "select", "textarea", "option", "optgroup"].includes(tag)) {
234
+ return false;
235
+ }
236
+ try {
237
+ return element.matches(":disabled");
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
216
243
  function _implicitRoleOf(element) {
217
244
  const tag = element.tagName.toLowerCase();
218
245
  if (isContentEditableElement(element)) {
@@ -1,8 +1,7 @@
1
1
  import { accessibleNameOf } from "./accessible-name.js";
2
2
  import { CONTENT_REF_ROLES, INTERACTIVE_REF_ROLES, interactivitySignalsOf, isScrollable, } from "./interactivity.js";
3
3
  import { textboxValueOf } from "./redaction.js";
4
- import { ariaBooleanOf, ariaLevelOf, ARIA_CHECKED_ROLES, ARIA_DISABLED_ROLES, ARIA_SELECTED_ROLES, roleOf, } from "./roles.js";
5
- import { parentAcrossShadowBoundaryOf } from "./visibility.js";
4
+ import { ariaBooleanOf, ariaLevelOf, ARIA_CHECKED_ROLES, ARIA_SELECTED_ROLES, roleOf, theControlIsDisabled, } from "./roles.js";
6
5
  const MAX_NODE_NAME_LENGTH = 100;
7
6
  export function _snapshotNodeOf(element, context, refFor) {
8
7
  const { options, oracle, nameCache } = context;
@@ -35,7 +34,7 @@ export function _snapshotNodeOf(element, context, refFor) {
35
34
  checked: _checkedStateOf(element, roleLower),
36
35
  selected: _selectedStateOf(element, roleLower),
37
36
  focused: element.ownerDocument.activeElement === element,
38
- disabled: _disabledStateOf(element, roleLower),
37
+ disabled: theControlIsDisabled(element, roleLower),
39
38
  offscreen: !hidden && oracle.isOffscreen(element),
40
39
  refId,
41
40
  hints,
@@ -63,32 +62,6 @@ function _selectedStateOf(element, role) {
63
62
  }
64
63
  return ariaBooleanOf(element.getAttribute("aria-selected")) === true;
65
64
  }
66
- function _disabledStateOf(element, role) {
67
- if (!ARIA_DISABLED_ROLES.has(role)) {
68
- return false;
69
- }
70
- if (_isNativelyDisabled(element)) {
71
- return true;
72
- }
73
- for (let current = element; current !== null; current = parentAcrossShadowBoundaryOf(current)) {
74
- if (ariaBooleanOf(current.getAttribute("aria-disabled")) === true) {
75
- return true;
76
- }
77
- }
78
- return false;
79
- }
80
- function _isNativelyDisabled(element) {
81
- const tag = element.tagName.toLowerCase();
82
- if (!["button", "input", "select", "textarea", "option", "optgroup"].includes(tag)) {
83
- return false;
84
- }
85
- try {
86
- return element.matches(":disabled");
87
- }
88
- catch {
89
- return false;
90
- }
91
- }
92
65
  export function _normalizedNodeName(name) {
93
66
  return name.replace(/\s+/g, " ").trim().substring(0, MAX_NODE_NAME_LENGTH);
94
67
  }
@@ -34,17 +34,23 @@ export function _resolvedRootOf(options, elementByRef) {
34
34
  return { root: body, error: null };
35
35
  }
36
36
  function _rootUnlessCurated(root, options) {
37
- for (let current = root; current !== null; current = parentAcrossShadowBoundaryOf(current)) {
38
- if (_theSubtreeIsCurated(current, options.ignoreSelectors)) {
39
- return {
40
- root: null,
41
- error: "That part of the page has opted out of page reads — " +
42
- "take a full read to see what is available.",
43
- };
44
- }
37
+ if (theElementSitsInACuratedSubtree(root, options.ignoreSelectors)) {
38
+ return {
39
+ root: null,
40
+ error: "That part of the page has opted out of page reads — " +
41
+ "take a full read to see what is available.",
42
+ };
45
43
  }
46
44
  return { root, error: null };
47
45
  }
46
+ export function theElementSitsInACuratedSubtree(element, ignoreSelectors) {
47
+ for (let current = element; current !== null; current = parentAcrossShadowBoundaryOf(current)) {
48
+ if (_theSubtreeIsCurated(current, ignoreSelectors)) {
49
+ return true;
50
+ }
51
+ }
52
+ return false;
53
+ }
48
54
  function _safeQuerySelector(selector) {
49
55
  try {
50
56
  const direct = document.querySelector(selector);
@@ -18,6 +18,10 @@ const SKIP_TAGS = new Set([
18
18
  let _refCounter = 0;
19
19
  const _refBadgeByElement = new WeakMap();
20
20
  const _elementByRef = new Map();
21
+ let _lastReadIgnoreSelectors = [];
22
+ export function lastReadIgnoreSelectors() {
23
+ return _lastReadIgnoreSelectors;
24
+ }
21
25
  export function snapshotTreeOf(options) {
22
26
  const context = {
23
27
  options,
@@ -30,6 +34,7 @@ export function snapshotTreeOf(options) {
30
34
  }
31
35
  const root = rooting.root;
32
36
  _elementByRef.clear();
37
+ _lastReadIgnoreSelectors = options.ignoreSelectors;
33
38
  const fragment = _fragmentNode();
34
39
  _traverse(root, 0, fragment, context);
35
40
  _mergeStringChildren(fragment);
@@ -52,7 +57,7 @@ export function resolvedElementOfRef(refId) {
52
57
  rootElement: null,
53
58
  refId,
54
59
  selector: null,
55
- ignoreSelectors: [],
60
+ ignoreSelectors: _lastReadIgnoreSelectors,
56
61
  };
57
62
  const rooting = _resolvedRootOf(address, _elementByRef);
58
63
  if (rooting.error !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teaflask/assistant",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "The AI assistant for your product: drop it into your React app or any page with a script tag.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "author": "Teaflask",