interestfor 1.0.10 → 1.0.11
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/README.md +19 -5
- package/package.json +1 -1
- package/src/interestfor.js +187 -38
- package/src/interestfor.min.js +1 -1
- package/test/basic_test.html +60 -17
- package/test/test.html +839 -284
package/test/test.html
CHANGED
|
@@ -1,329 +1,884 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<link rel="author" href="mailto:masonf@chromium.org">
|
|
5
|
+
<link rel="help" href="https://open-ui.org/components/interest-invokers.explainer/">
|
|
2
6
|
|
|
3
|
-
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
</
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<button
|
|
19
|
-
<div
|
|
20
|
-
<a href=foo id=unrelated class="hide test-element">Unrelated</a>
|
|
21
|
-
<a interestfor=already_present_target href=foo id=already_present class="hide test-element">Link</a></div>
|
|
22
|
-
<div popover id="already_present_target" class="hide">
|
|
23
|
-
Popover - ignore me
|
|
24
|
-
</div>
|
|
25
|
-
</div>
|
|
26
|
-
<button class="test_content">Focusable thing after test stuff</button>
|
|
27
|
-
<div popover="manual" class="test_content" id="focus_warning"><h1>Get ready to use <span id="type_str">SOMETHING</span>...</h1></div>
|
|
7
|
+
<!-- Pre-existing elements in the initial HTML to verify eager page-load setup -->
|
|
8
|
+
<div id="preexisting_fixture" style="position:fixed;top:-9999px;left:-9999px">
|
|
9
|
+
<!-- Case A: icon-only button, plain hint -->
|
|
10
|
+
<button type="button" id="pre_a" interestfor="pre_tip_a"></button>
|
|
11
|
+
<div id="pre_tip_a" popover="hint">Alpha</div>
|
|
12
|
+
|
|
13
|
+
<!-- Case B: aria-label matching plain hint -->
|
|
14
|
+
<button type="button" id="pre_b" aria-label="Bravo" interestfor="pre_tip_b"></button>
|
|
15
|
+
<div id="pre_tip_b" popover="hint">Bravo</div>
|
|
16
|
+
|
|
17
|
+
<!-- Case C: aria-labelledby pointing at plain hint -->
|
|
18
|
+
<button type="button" id="pre_c" aria-labelledby="pre_tip_c" interestfor="pre_tip_c"></button>
|
|
19
|
+
<div id="pre_tip_c" popover="hint">Charlie</div>
|
|
20
|
+
|
|
21
|
+
<!-- Case D: inner text matching plain hint -->
|
|
22
|
+
<button type="button" id="pre_d" interestfor="pre_tip_d">Delta</button>
|
|
23
|
+
<div id="pre_tip_d" popover="hint">Delta</div>
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
25
|
+
<!-- Case E: icon-only button, plain hint with display:none -->
|
|
26
|
+
<button type="button" id="pre_e" interestfor="pre_tip_e"></button>
|
|
27
|
+
<div id="pre_tip_e" popover="hint" style="display:none">Echo</div>
|
|
28
|
+
|
|
29
|
+
<!-- Case F: distinct plain hint text (should become aria-describedby) -->
|
|
30
|
+
<button type="button" id="pre_f" interestfor="pre_tip_f">Edit</button>
|
|
31
|
+
<div id="pre_tip_f" popover="hint">Modify account settings</div>
|
|
32
|
+
|
|
33
|
+
<!-- Rich hint in initial HTML -->
|
|
34
|
+
<button type="button" id="pre_rich" interestfor="pre_tip_rich">Profile</button>
|
|
35
|
+
<div id="pre_tip_rich" popover="auto"><a href="#details">View profile</a></div>
|
|
33
36
|
</div>
|
|
34
37
|
|
|
38
|
+
<script>
|
|
39
|
+
async function runTests() {
|
|
40
|
+
// =========================================================================
|
|
41
|
+
section("1. Accessibility: Plain & Rich Hints (Eager Init & Mutations)");
|
|
42
|
+
// =========================================================================
|
|
43
|
+
|
|
44
|
+
await test("Pre-existing plain hints A-F have accessibility attributes set on page load before any interaction", () => {
|
|
45
|
+
const preA = document.getElementById("pre_a");
|
|
46
|
+
assert_equals(preA.getAttribute("aria-labelledby"), "pre_tip_a", "Case A aria-labelledby");
|
|
47
|
+
assert_equals(preA.getAttribute("aria-describedby"), null, "Case A aria-describedby");
|
|
48
|
+
|
|
49
|
+
const preB = document.getElementById("pre_b");
|
|
50
|
+
assert_equals(preB.getAttribute("aria-labelledby"), null, "Case B aria-labelledby");
|
|
51
|
+
assert_equals(preB.getAttribute("aria-describedby"), null, "Case B aria-describedby");
|
|
52
|
+
|
|
53
|
+
const preC = document.getElementById("pre_c");
|
|
54
|
+
assert_equals(preC.getAttribute("aria-labelledby"), "pre_tip_c", "Case C aria-labelledby");
|
|
55
|
+
assert_equals(preC.getAttribute("aria-describedby"), null, "Case C aria-describedby");
|
|
56
|
+
|
|
57
|
+
const preD = document.getElementById("pre_d");
|
|
58
|
+
assert_equals(preD.getAttribute("aria-labelledby"), null, "Case D aria-labelledby");
|
|
59
|
+
assert_equals(preD.getAttribute("aria-describedby"), null, "Case D aria-describedby");
|
|
60
|
+
|
|
61
|
+
const preE = document.getElementById("pre_e");
|
|
62
|
+
assert_equals(preE.getAttribute("aria-labelledby"), "pre_tip_e", "Case E aria-labelledby");
|
|
63
|
+
assert_equals(preE.getAttribute("aria-describedby"), null, "Case E aria-describedby");
|
|
64
|
+
|
|
65
|
+
const preF = document.getElementById("pre_f");
|
|
66
|
+
assert_equals(preF.getAttribute("aria-labelledby"), null, "Case F aria-labelledby");
|
|
67
|
+
assert_equals(preF.getAttribute("aria-describedby"), "pre_tip_f", "Case F aria-describedby");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test("Pre-existing rich hint has aria-details, aria-expanded=false, and role=tooltip on page load", () => {
|
|
71
|
+
const preRich = document.getElementById("pre_rich");
|
|
72
|
+
const preTipRich = document.getElementById("pre_tip_rich");
|
|
73
|
+
assert_equals(preRich.getAttribute("aria-details"), "pre_tip_rich");
|
|
74
|
+
assert_equals(preRich.getAttribute("aria-expanded"), "false");
|
|
75
|
+
assert_equals(preTipRich.getAttribute("role"), "tooltip");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await test("Plain hint with distinct aria-label on invoker sets aria-describedby", async () => {
|
|
79
|
+
const { invoker, target } = createPair({
|
|
80
|
+
invokerAttrs: { "aria-label": "Bold" },
|
|
81
|
+
invokerHTML: "",
|
|
82
|
+
popoverType: "hint",
|
|
83
|
+
targetHTML: "Bold (Cmd+B)",
|
|
84
|
+
});
|
|
85
|
+
await nextTick();
|
|
86
|
+
assert_equals(invoker.getAttribute("aria-labelledby"), null);
|
|
87
|
+
assert_equals(invoker.getAttribute("aria-describedby"), target.id);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test("Plain hint respects <img alt>, <label>, title, and ignores aria-hidden children", async () => {
|
|
91
|
+
// 1. <img alt="Save"> with distinct hint -> aria-describedby
|
|
92
|
+
const { invoker: imgBtn, target: imgTip } = createPair({
|
|
93
|
+
invokerHTML: '<img alt="Save">',
|
|
94
|
+
popoverType: "hint",
|
|
95
|
+
targetHTML: "Save document",
|
|
96
|
+
});
|
|
97
|
+
// 2. <img alt="Save"> with matching hint -> no aria-describedby
|
|
98
|
+
const { invoker: imgSameBtn } = createPair({
|
|
99
|
+
invokerHTML: '<img alt="Save">',
|
|
100
|
+
popoverType: "hint",
|
|
101
|
+
targetHTML: "Save",
|
|
102
|
+
});
|
|
103
|
+
// 3. <img alt=""> (decorative) + <span aria-hidden="true">icon</span> -> no name -> aria-labelledby
|
|
104
|
+
const { invoker: decBtn, target: decTip } = createPair({
|
|
105
|
+
invokerHTML: '<img alt=""><span aria-hidden="true">*</span>',
|
|
106
|
+
popoverType: "hint",
|
|
107
|
+
targetHTML: "Favorite",
|
|
108
|
+
});
|
|
109
|
+
// 4. Associated <label>
|
|
110
|
+
const lbl = document.createElement("label");
|
|
111
|
+
lbl.htmlFor = "labeled_btn";
|
|
112
|
+
lbl.textContent = "Search";
|
|
113
|
+
sandbox.appendChild(lbl);
|
|
114
|
+
const { invoker: lblBtn, target: lblTip } = createPair({
|
|
115
|
+
invokerAttrs: { id: "labeled_btn" },
|
|
116
|
+
invokerHTML: "",
|
|
117
|
+
popoverType: "hint",
|
|
118
|
+
targetHTML: "Search the site",
|
|
119
|
+
});
|
|
120
|
+
// 5. title attribute
|
|
121
|
+
const { invoker: titleBtn, target: titleTip } = createPair({
|
|
122
|
+
invokerAttrs: { title: "Settings" },
|
|
123
|
+
invokerHTML: "",
|
|
124
|
+
popoverType: "hint",
|
|
125
|
+
targetHTML: "Open settings",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await nextTick();
|
|
129
|
+
assert_equals(imgBtn.getAttribute("aria-describedby"), imgTip.id, "img alt distinct");
|
|
130
|
+
assert_equals(imgSameBtn.getAttribute("aria-describedby"), null, "img alt matching");
|
|
131
|
+
assert_equals(imgSameBtn.getAttribute("aria-labelledby"), null, "img alt matching no labelledby");
|
|
132
|
+
assert_equals(decBtn.getAttribute("aria-labelledby"), decTip.id, "decorative children -> labelledby");
|
|
133
|
+
assert_equals(decBtn.getAttribute("aria-describedby"), null, "decorative children -> no describedby");
|
|
134
|
+
assert_equals(lblBtn.getAttribute("aria-describedby"), lblTip.id, "<label> provides name -> describedby");
|
|
135
|
+
assert_equals(titleBtn.getAttribute("aria-describedby"), titleTip.id, "title provides name -> describedby");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test("Plain vs. Rich hint classification (focusables, headings/lists, ARIA roles)", async () => {
|
|
139
|
+
// Plain hint with allowed role (generic, presentation, none, image)
|
|
140
|
+
const { invoker: plainInv, target: plainTip } = createPair({
|
|
141
|
+
invokerHTML: "Info",
|
|
142
|
+
popoverType: "hint",
|
|
143
|
+
targetHTML: '<span role="generic">Plain text</span><span role="presentation">!</span>',
|
|
144
|
+
});
|
|
145
|
+
// Rich hint because of focusable link inside popover="hint"
|
|
146
|
+
const { invoker: focusInv, target: focusTip } = createPair({
|
|
147
|
+
invokerHTML: "Link hint",
|
|
148
|
+
popoverType: "hint",
|
|
149
|
+
targetHTML: 'Text with <a href="#">link</a>',
|
|
150
|
+
});
|
|
151
|
+
// Rich hint because of structural heading inside popover="hint"
|
|
152
|
+
const { invoker: structInv, target: structTip } = createPair({
|
|
153
|
+
invokerHTML: "Heading hint",
|
|
154
|
+
popoverType: "hint",
|
|
155
|
+
targetHTML: "<h3>Title</h3><p>Body</p>",
|
|
156
|
+
});
|
|
157
|
+
// Rich hint because of non-trivial ARIA role inside popover="hint"
|
|
158
|
+
const { invoker: roleInv, target: roleTip } = createPair({
|
|
159
|
+
invokerHTML: "Role hint",
|
|
160
|
+
popoverType: "hint",
|
|
161
|
+
targetHTML: '<div role="region">Region text</div>',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await nextTick();
|
|
165
|
+
assert_equals(plainInv.getAttribute("aria-describedby"), plainTip.id, "allowed roles remain plain hint");
|
|
166
|
+
assert_equals(plainInv.getAttribute("aria-details"), null);
|
|
167
|
+
|
|
168
|
+
for (const [inv, tip, label] of [
|
|
169
|
+
[focusInv, focusTip, "focusable"],
|
|
170
|
+
[structInv, structTip, "structural"],
|
|
171
|
+
[roleInv, roleTip, "ARIA role"],
|
|
172
|
+
]) {
|
|
173
|
+
assert_equals(inv.getAttribute("aria-details"), tip.id, `${label} -> aria-details`);
|
|
174
|
+
assert_equals(inv.getAttribute("aria-expanded"), "false", `${label} -> aria-expanded`);
|
|
175
|
+
assert_equals(tip.getAttribute("role"), "tooltip", `${label} -> role=tooltip`);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test("Delayed target insertion (invokersMissingTarget) sets up accessibility when target arrives", async () => {
|
|
180
|
+
const invoker = document.createElement("button");
|
|
181
|
+
invoker.setAttribute("interestfor", "delayed_target");
|
|
182
|
+
sandbox.appendChild(invoker);
|
|
183
|
+
await nextTick();
|
|
184
|
+
assert_equals(invoker.getAttribute("aria-labelledby"), null);
|
|
185
|
+
|
|
186
|
+
const target = document.createElement("div");
|
|
187
|
+
target.id = "delayed_target";
|
|
188
|
+
target.setAttribute("popover", "hint");
|
|
189
|
+
target.textContent = "Arrived later";
|
|
190
|
+
sandbox.appendChild(target);
|
|
191
|
+
await nextTick();
|
|
192
|
+
assert_equals(invoker.getAttribute("aria-labelledby"), "delayed_target");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// =========================================================================
|
|
196
|
+
section("2. Element Validity & interestForElement IDL");
|
|
197
|
+
// =========================================================================
|
|
198
|
+
|
|
199
|
+
await test("interestForElement IDL reflection and tree scope resolution", () => {
|
|
200
|
+
const { invoker, target } = createPair();
|
|
201
|
+
assert_equals(invoker.interestForElement, target, "reflects IDREF target");
|
|
202
|
+
|
|
203
|
+
const otherTarget = document.createElement("div");
|
|
204
|
+
sandbox.appendChild(otherTarget);
|
|
205
|
+
invoker.interestForElement = otherTarget;
|
|
206
|
+
assert_equals(invoker.getAttribute("interestfor"), "", "explicit element sets empty attr");
|
|
207
|
+
assert_equals(invoker.interestForElement, otherTarget, "returns explicit target");
|
|
208
|
+
|
|
209
|
+
// Changing attribute to non-empty ID clears explicit target
|
|
210
|
+
invoker.setAttribute("interestfor", target.id);
|
|
211
|
+
assert_equals(invoker.interestForElement, target, "reverts to IDREF lookup");
|
|
212
|
+
|
|
213
|
+
// Setting null removes attribute
|
|
214
|
+
invoker.interestForElement = null;
|
|
215
|
+
assert_false(invoker.hasAttribute("interestfor"), "null removes attribute");
|
|
216
|
+
assert_equals(invoker.interestForElement, null);
|
|
217
|
+
|
|
218
|
+
assert_throws_js(
|
|
219
|
+
TypeError,
|
|
220
|
+
() => {
|
|
221
|
+
invoker.interestForElement = "not-an-element";
|
|
222
|
+
},
|
|
223
|
+
"non-Element throws TypeError"
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test("Shadow DOM tree-scope rules for interestForElement and InterestEvent.source retargeting", async () => {
|
|
228
|
+
const outerTarget = document.createElement("div");
|
|
229
|
+
outerTarget.id = "outer_target";
|
|
230
|
+
outerTarget.setAttribute("popover", "auto");
|
|
231
|
+
sandbox.appendChild(outerTarget);
|
|
232
|
+
|
|
233
|
+
const host = document.createElement("div");
|
|
234
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
235
|
+
const shadowBtn = document.createElement("button");
|
|
236
|
+
shadowBtn.style.setProperty("--interest-delay-start", "10ms");
|
|
237
|
+
shadowBtn.style.setProperty("--interest-delay-end", "10ms");
|
|
238
|
+
const innerTarget = document.createElement("div");
|
|
239
|
+
shadow.append(shadowBtn, innerTarget);
|
|
240
|
+
sandbox.appendChild(host);
|
|
241
|
+
|
|
242
|
+
// Invoker inside shadow root can reference target in ancestor document tree
|
|
243
|
+
shadowBtn.interestForElement = outerTarget;
|
|
244
|
+
assert_equals(shadowBtn.interestForElement, outerTarget, "shadow invoker sees ancestor tree target");
|
|
245
|
+
|
|
246
|
+
// Invoker in document cannot reference target inside a descendant shadow root
|
|
247
|
+
const lightBtn = document.createElement("button");
|
|
248
|
+
sandbox.appendChild(lightBtn);
|
|
249
|
+
lightBtn.interestForElement = innerTarget;
|
|
250
|
+
assert_equals(lightBtn.interestForElement, null, "light invoker cannot target inner shadow element");
|
|
251
|
+
|
|
252
|
+
// Verify InterestEvent.source retargeting when event is observed in outer document
|
|
253
|
+
let observedSource = null;
|
|
254
|
+
outerTarget.addEventListener("interest", (e) => {
|
|
255
|
+
observedSource = e.source;
|
|
256
|
+
});
|
|
257
|
+
hover(shadowBtn);
|
|
258
|
+
await wait(25);
|
|
259
|
+
assert_equals(observedSource, host, "InterestEvent.source retargets to shadow host");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await test("Disabled <button>, <a> without href, and non-invoker elements cannot show interest", async () => {
|
|
263
|
+
const { invoker: disabledBtn, target: t1 } = createPair({
|
|
264
|
+
invokerAttrs: { disabled: "" },
|
|
265
|
+
});
|
|
266
|
+
const anchorNoHref = document.createElement("a");
|
|
267
|
+
anchorNoHref.setAttribute("interestfor", "t_nohref");
|
|
268
|
+
anchorNoHref.style.setProperty("--interest-delay-start", "10ms");
|
|
269
|
+
const t2 = document.createElement("div");
|
|
270
|
+
t2.id = "t_nohref";
|
|
271
|
+
t2.setAttribute("popover", "auto");
|
|
272
|
+
sandbox.append(anchorNoHref, t2);
|
|
273
|
+
|
|
274
|
+
let events = 0;
|
|
275
|
+
t1.addEventListener("interest", () => events++);
|
|
276
|
+
t2.addEventListener("interest", () => events++);
|
|
277
|
+
|
|
278
|
+
hover(disabledBtn);
|
|
279
|
+
hover(anchorNoHref);
|
|
280
|
+
await wait(30);
|
|
281
|
+
assert_equals(events, 0, "neither disabled button nor href-less anchor fires interest");
|
|
282
|
+
|
|
283
|
+
// Enabling button and adding href makes them valid
|
|
284
|
+
disabledBtn.removeAttribute("disabled");
|
|
285
|
+
anchorNoHref.setAttribute("href", "#");
|
|
286
|
+
await nextTick();
|
|
287
|
+
hover(disabledBtn);
|
|
288
|
+
hover(anchorNoHref);
|
|
289
|
+
await wait(30);
|
|
290
|
+
assert_equals(events, 2, "both fire interest once valid");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// =========================================================================
|
|
294
|
+
section("3. Hover, Focus, Delays, Events & Cancellation");
|
|
295
|
+
// =========================================================================
|
|
296
|
+
|
|
297
|
+
await test("Hover gains interest after delay, opens popover, sets classes, and dehover loses interest", async () => {
|
|
298
|
+
const { invoker, target } = createPair({ delayStart: "20ms", delayEnd: "20ms" });
|
|
299
|
+
const events = [];
|
|
300
|
+
target.addEventListener("interest", (e) => events.push(`interest:${e.source.tagName}`));
|
|
301
|
+
target.addEventListener("loseinterest", (e) => events.push(`loseinterest:${e.source.tagName}`));
|
|
302
|
+
|
|
303
|
+
hover(invoker);
|
|
304
|
+
assert_false(target.matches(":popover-open"), "not open immediately");
|
|
305
|
+
assert_equals(events.length, 0);
|
|
306
|
+
|
|
307
|
+
await wait(35);
|
|
308
|
+
assert_true(target.matches(":popover-open"), "open after start delay");
|
|
309
|
+
assert_array_equals(events, ["interest:BUTTON"]);
|
|
310
|
+
assert_true(invoker.classList.contains("interest-source"), "has interest-source class");
|
|
311
|
+
assert_true(target.classList.contains("interest-target"), "has interest-target class");
|
|
312
|
+
assert_equals(invoker.getAttribute("aria-expanded"), "true", "rich hint aria-expanded=true");
|
|
313
|
+
|
|
314
|
+
dehover(invoker);
|
|
315
|
+
assert_true(target.matches(":popover-open"), "still open immediately after mouseout");
|
|
316
|
+
|
|
317
|
+
await wait(35);
|
|
318
|
+
assert_false(target.matches(":popover-open"), "closed after end delay");
|
|
319
|
+
assert_array_equals(events, ["interest:BUTTON", "loseinterest:BUTTON"]);
|
|
320
|
+
assert_false(invoker.classList.contains("interest-source"));
|
|
321
|
+
assert_false(target.classList.contains("interest-target"));
|
|
322
|
+
assert_equals(invoker.getAttribute("aria-expanded"), "false");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
await test("Dehovering before start delay cancels gaining interest; re-hovering before end delay cancels losing interest", async () => {
|
|
326
|
+
const { invoker, target } = createPair({ delayStart: "30ms", delayEnd: "30ms" });
|
|
327
|
+
let interestCount = 0;
|
|
328
|
+
let loseCount = 0;
|
|
329
|
+
target.addEventListener("interest", () => interestCount++);
|
|
330
|
+
target.addEventListener("loseinterest", () => loseCount++);
|
|
331
|
+
|
|
332
|
+
hover(invoker);
|
|
333
|
+
await wait(10);
|
|
334
|
+
dehover(invoker);
|
|
335
|
+
await wait(35);
|
|
336
|
+
assert_equals(interestCount, 0, "early mouseout cancelled interest gain");
|
|
337
|
+
|
|
338
|
+
hover(invoker);
|
|
339
|
+
await wait(45);
|
|
340
|
+
assert_equals(interestCount, 1, "gained interest");
|
|
341
|
+
|
|
342
|
+
dehover(invoker);
|
|
343
|
+
await wait(10);
|
|
344
|
+
hover(invoker);
|
|
345
|
+
await wait(35);
|
|
346
|
+
assert_equals(loseCount, 0, "re-hover cancelled interest loss");
|
|
347
|
+
assert_equals(interestCount, 1, "did not fire duplicate interest event");
|
|
348
|
+
assert_true(target.matches(":popover-open"));
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await test("Focus and blur gain and lose interest, and shorthand --interest-delay works", async () => {
|
|
352
|
+
const { invoker, target } = createPair({ delayStart: null, delayEnd: null });
|
|
353
|
+
invoker.style.setProperty("--interest-delay", "0.015s 15ms");
|
|
354
|
+
const events = [];
|
|
355
|
+
target.addEventListener("interest", () => events.push("interest"));
|
|
356
|
+
target.addEventListener("loseinterest", () => events.push("loseinterest"));
|
|
357
|
+
|
|
358
|
+
focusIn(invoker);
|
|
359
|
+
await wait(30);
|
|
360
|
+
assert_array_equals(events, ["interest"]);
|
|
361
|
+
assert_true(target.matches(":popover-open"));
|
|
362
|
+
|
|
363
|
+
focusOut(invoker);
|
|
364
|
+
await wait(30);
|
|
365
|
+
assert_array_equals(events, ["interest", "loseinterest"]);
|
|
366
|
+
assert_false(target.matches(":popover-open"));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await test("Canceling interest and loseinterest events with preventDefault()", async () => {
|
|
370
|
+
const { invoker, target } = createPair({ delayStart: "15ms", delayEnd: "15ms" });
|
|
371
|
+
let blockInterest = true;
|
|
372
|
+
let blockLoseInterest = true;
|
|
373
|
+
target.addEventListener("interest", (e) => {
|
|
374
|
+
if (blockInterest) e.preventDefault();
|
|
375
|
+
});
|
|
376
|
+
target.addEventListener("loseinterest", (e) => {
|
|
377
|
+
if (blockLoseInterest) e.preventDefault();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
hover(invoker);
|
|
381
|
+
await wait(30);
|
|
382
|
+
assert_false(target.matches(":popover-open"), "preventDefault on interest stopped popover from opening");
|
|
383
|
+
assert_false(invoker.classList.contains("interest-source"));
|
|
384
|
+
|
|
385
|
+
blockInterest = false;
|
|
386
|
+
dehover(invoker);
|
|
387
|
+
hover(invoker);
|
|
388
|
+
await wait(30);
|
|
389
|
+
assert_true(target.matches(":popover-open"), "opened when interest not canceled");
|
|
390
|
+
|
|
391
|
+
dehover(invoker);
|
|
392
|
+
await wait(30);
|
|
393
|
+
assert_true(target.matches(":popover-open"), "preventDefault on loseinterest kept popover open");
|
|
394
|
+
assert_true(invoker.classList.contains("interest-source"));
|
|
395
|
+
|
|
396
|
+
blockLoseInterest = false;
|
|
397
|
+
hover(invoker);
|
|
398
|
+
dehover(invoker);
|
|
399
|
+
await wait(30);
|
|
400
|
+
assert_false(target.matches(":popover-open"), "closed when loseinterest not canceled");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// =========================================================================
|
|
404
|
+
section("4. Escape Key, Light Dismiss, Pre-Opened & Nested Popovers");
|
|
405
|
+
// =========================================================================
|
|
406
|
+
|
|
407
|
+
await test("Escape key immediately dismisses active interest invokers with non-cancelable loseinterest", async () => {
|
|
408
|
+
const { invoker, target } = createPair({ delayStart: "10ms", delayEnd: "500ms" });
|
|
409
|
+
let cancelableSeen = null;
|
|
410
|
+
target.addEventListener("loseinterest", (e) => {
|
|
411
|
+
cancelableSeen = e.cancelable;
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
hover(invoker);
|
|
416
|
+
await wait(25);
|
|
417
|
+
assert_true(target.matches(":popover-open"));
|
|
418
|
+
|
|
419
|
+
pressEscape();
|
|
420
|
+
assert_equals(cancelableSeen, false, "Escape loseinterest is not cancelable");
|
|
421
|
+
assert_false(target.matches(":popover-open"), "popover closed immediately on Escape");
|
|
422
|
+
assert_false(invoker.classList.contains("interest-source"));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
await test("External hidePopover() synchronously triggers loseinterest via beforetoggle", async () => {
|
|
426
|
+
const { invoker, target } = createPair({ delayStart: "10ms", delayEnd: "500ms" });
|
|
427
|
+
let lost = false;
|
|
428
|
+
target.addEventListener("loseinterest", () => {
|
|
429
|
+
lost = true;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
hover(invoker);
|
|
433
|
+
await wait(25);
|
|
434
|
+
assert_true(target.matches(":popover-open"));
|
|
435
|
+
|
|
436
|
+
target.hidePopover();
|
|
437
|
+
assert_true(lost, "loseinterest fired synchronously when popover hid");
|
|
438
|
+
assert_false(invoker.classList.contains("interest-source"));
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await test("Already-open popover is not closed when interest is lost", async () => {
|
|
442
|
+
const { invoker, target } = createPair({
|
|
443
|
+
popoverType: "manual",
|
|
444
|
+
delayStart: "10ms",
|
|
445
|
+
delayEnd: "10ms",
|
|
446
|
+
});
|
|
447
|
+
target.showPopover();
|
|
448
|
+
assert_true(target.matches(":popover-open"));
|
|
449
|
+
|
|
450
|
+
const events = [];
|
|
451
|
+
target.addEventListener("interest", () => events.push("interest"));
|
|
452
|
+
target.addEventListener("loseinterest", () => events.push("loseinterest"));
|
|
453
|
+
|
|
454
|
+
hover(invoker);
|
|
455
|
+
await wait(25);
|
|
456
|
+
assert_array_equals(events, ["interest"]);
|
|
457
|
+
|
|
458
|
+
dehover(invoker);
|
|
459
|
+
await wait(25);
|
|
460
|
+
assert_array_equals(events, ["interest", "loseinterest"]);
|
|
461
|
+
assert_true(target.matches(":popover-open"), "pre-opened popover remains open after loseinterest");
|
|
462
|
+
target.hidePopover();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
await test("Nested interest invokers keep upstream popovers open while inner popover is hovered", async () => {
|
|
466
|
+
const { invoker: outerInvoker, target: outerPopover } = createPair({
|
|
467
|
+
targetId: "outer_pop",
|
|
468
|
+
delayStart: "15ms",
|
|
469
|
+
delayEnd: "25ms",
|
|
470
|
+
targetHTML: '<button id="inner_invoker" interestfor="inner_pop">Inner</button>',
|
|
471
|
+
});
|
|
472
|
+
const innerInvoker = outerPopover.querySelector("#inner_invoker");
|
|
473
|
+
innerInvoker.style.setProperty("--interest-delay-start", "15ms");
|
|
474
|
+
innerInvoker.style.setProperty("--interest-delay-end", "25ms");
|
|
475
|
+
|
|
476
|
+
const innerPopover = document.createElement("div");
|
|
477
|
+
innerPopover.id = "inner_pop";
|
|
478
|
+
innerPopover.setAttribute("popover", "auto");
|
|
479
|
+
innerPopover.textContent = "Nested popover";
|
|
480
|
+
sandbox.appendChild(innerPopover);
|
|
481
|
+
|
|
482
|
+
hover(outerInvoker);
|
|
483
|
+
await wait(30);
|
|
484
|
+
assert_true(outerPopover.matches(":popover-open"), "outer popover open");
|
|
485
|
+
|
|
486
|
+
// Move from outerInvoker into innerInvoker (inside outerPopover)
|
|
487
|
+
dehover(outerInvoker);
|
|
488
|
+
hover(innerInvoker);
|
|
489
|
+
await wait(35);
|
|
490
|
+
assert_true(outerPopover.matches(":popover-open"), "outer popover stays open");
|
|
491
|
+
assert_true(innerPopover.matches(":popover-open"), "inner popover opens");
|
|
492
|
+
|
|
493
|
+
// Move from innerInvoker into innerPopover itself
|
|
494
|
+
dehover(innerInvoker);
|
|
495
|
+
hover(innerPopover);
|
|
496
|
+
await wait(35);
|
|
497
|
+
assert_true(outerPopover.matches(":popover-open"), "outer stays open while hovering innerPopover");
|
|
498
|
+
assert_true(innerPopover.matches(":popover-open"), "inner stays open while hovering innerPopover");
|
|
499
|
+
|
|
500
|
+
// Leave innerPopover completely
|
|
501
|
+
dehover(innerPopover);
|
|
502
|
+
await wait(45);
|
|
503
|
+
assert_false(innerPopover.matches(":popover-open"), "inner closes after leaving");
|
|
504
|
+
assert_false(outerPopover.matches(":popover-open"), "outer closes after leaving");
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// =========================================================================
|
|
508
|
+
section("5. ::interest-button & Touch Long-Press (ExplicitInterest)");
|
|
509
|
+
// =========================================================================
|
|
510
|
+
|
|
511
|
+
await test("--interest-button-content creates .interest-button sibling that triggers ExplicitInterest on click", async () => {
|
|
512
|
+
const { invoker, target } = createPair({ delayStart: "500ms", delayEnd: "15ms" });
|
|
513
|
+
invoker.style.setProperty("--interest-button-content", '"?"');
|
|
514
|
+
// Trigger mutation observer to re-evaluate interest button
|
|
515
|
+
invoker.setAttribute("interestfor", target.id);
|
|
516
|
+
await nextTick();
|
|
517
|
+
|
|
518
|
+
const btn = invoker.nextElementSibling;
|
|
519
|
+
assert_true(!!btn && btn.classList.contains("interest-button"), "interest-button span inserted after invoker");
|
|
520
|
+
assert_equals(btn.getAttribute("aria-hidden"), "true");
|
|
521
|
+
|
|
522
|
+
// Clicking the interest button opens immediately without waiting for 500ms start delay
|
|
523
|
+
btn.click();
|
|
524
|
+
assert_true(target.matches(":popover-open"), "opened immediately via interest button");
|
|
525
|
+
|
|
526
|
+
// Dehovering or blurring the invoker does NOT lose ExplicitInterest
|
|
527
|
+
dehover(invoker);
|
|
528
|
+
focusOut(invoker);
|
|
529
|
+
await wait(35);
|
|
530
|
+
assert_true(target.matches(":popover-open"), "ExplicitInterest not lost on dehover/blur");
|
|
531
|
+
|
|
532
|
+
// Escape closes it
|
|
533
|
+
pressEscape();
|
|
534
|
+
assert_false(target.matches(":popover-open"), "ExplicitInterest closed by Escape");
|
|
535
|
+
|
|
536
|
+
// Removing --interest-button-content removes the button element on update
|
|
537
|
+
invoker.style.removeProperty("--interest-button-content");
|
|
538
|
+
invoker.setAttribute("interestfor", target.id);
|
|
539
|
+
await nextTick();
|
|
540
|
+
assert_false(invoker.nextElementSibling === btn, "interest-button removed when content cleared");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
await test("Touch long-press cancellations on touchmove / early touchend", async () => {
|
|
544
|
+
const { invoker, target } = createPair({ delayStart: "10ms", delayEnd: "10ms" });
|
|
545
|
+
invoker.dispatchEvent(new Event("touchstart", { bubbles: true }));
|
|
546
|
+
// Cancel immediately via touchmove
|
|
547
|
+
invoker.dispatchEvent(new Event("touchmove", { bubbles: true }));
|
|
548
|
+
invoker.dispatchEvent(new Event("touchend", { bubbles: true }));
|
|
549
|
+
await wait(30);
|
|
550
|
+
assert_false(target.matches(":popover-open"), "canceled touch did not open popover");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await test("Emulated mouseover and focusin after touchend do not show interest until non-touch input occurs", async () => {
|
|
554
|
+
const { invoker, target } = createPair({ delayStart: "10ms", delayEnd: "10ms" });
|
|
555
|
+
invoker.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
|
|
556
|
+
invoker.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }));
|
|
557
|
+
invoker.dispatchEvent(new Event("touchstart", { bubbles: true }));
|
|
558
|
+
await wait(10);
|
|
559
|
+
invoker.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerType: "touch" }));
|
|
560
|
+
invoker.dispatchEvent(new PointerEvent("pointerout", { bubbles: true, pointerType: "touch" }));
|
|
561
|
+
invoker.dispatchEvent(new Event("touchend", { bubbles: true }));
|
|
562
|
+
// Emulated compatibility mouse and focus events arrive after touchend:
|
|
563
|
+
await wait(5);
|
|
564
|
+
hover(invoker);
|
|
565
|
+
focusIn(invoker);
|
|
566
|
+
await wait(25);
|
|
567
|
+
assert_false(target.matches(":popover-open"), "post-touchend emulated mouseover/focusin ignored");
|
|
568
|
+
|
|
569
|
+
// Switching to mouse re-enables hover interest:
|
|
570
|
+
invoker.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
|
571
|
+
hover(invoker);
|
|
572
|
+
await wait(25);
|
|
573
|
+
assert_true(target.matches(":popover-open"), "mouse pointerover re-enables hover interest");
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
</script>
|
|
577
|
+
|
|
578
|
+
<!-- ===================================================================== -->
|
|
579
|
+
<!-- END OF TESTS -->
|
|
580
|
+
<!-- ===================================================================== -->
|
|
581
|
+
|
|
35
582
|
<style>
|
|
36
|
-
|
|
37
|
-
|
|
583
|
+
body {
|
|
584
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
585
|
+
margin: 20px;
|
|
586
|
+
color: #222;
|
|
587
|
+
}
|
|
588
|
+
header {
|
|
589
|
+
display: flex;
|
|
590
|
+
align-items: center;
|
|
591
|
+
gap: 16px;
|
|
592
|
+
flex-wrap: wrap;
|
|
593
|
+
margin-bottom: 16px;
|
|
594
|
+
}
|
|
595
|
+
header h2 {
|
|
596
|
+
margin: 0;
|
|
597
|
+
}
|
|
598
|
+
#summary {
|
|
599
|
+
padding: 10px 14px;
|
|
600
|
+
border-radius: 6px;
|
|
601
|
+
font-weight: 600;
|
|
602
|
+
margin-bottom: 16px;
|
|
603
|
+
}
|
|
604
|
+
.pass-bg {
|
|
605
|
+
background-color: #d4edda;
|
|
606
|
+
color: #155724;
|
|
607
|
+
border: 1px solid #c3e6cb;
|
|
608
|
+
}
|
|
609
|
+
.fail-bg {
|
|
610
|
+
background-color: #f8d7da;
|
|
611
|
+
color: #721c24;
|
|
612
|
+
border: 1px solid #f5c6cb;
|
|
38
613
|
}
|
|
39
|
-
.
|
|
40
|
-
background-color:
|
|
614
|
+
.running-bg {
|
|
615
|
+
background-color: #fff3cd;
|
|
616
|
+
color: #856404;
|
|
617
|
+
border: 1px solid #ffeeba;
|
|
41
618
|
}
|
|
42
|
-
|
|
619
|
+
table {
|
|
620
|
+
border-collapse: collapse;
|
|
43
621
|
width: 100%;
|
|
622
|
+
max-width: 960px;
|
|
623
|
+
font-size: 14px;
|
|
44
624
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
border: 1px solid red;
|
|
625
|
+
th, td {
|
|
626
|
+
border: 1px solid #ddd;
|
|
627
|
+
padding: 6px 10px;
|
|
628
|
+
text-align: left;
|
|
50
629
|
}
|
|
51
|
-
|
|
52
|
-
|
|
630
|
+
th {
|
|
631
|
+
background: #f5f5f5;
|
|
53
632
|
}
|
|
54
|
-
.
|
|
55
|
-
|
|
633
|
+
td.status {
|
|
634
|
+
width: 60px;
|
|
635
|
+
font-weight: 600;
|
|
636
|
+
text-align: center;
|
|
56
637
|
}
|
|
57
|
-
|
|
58
|
-
|
|
638
|
+
td.pass {
|
|
639
|
+
background-color: #d4edda;
|
|
640
|
+
color: #155724;
|
|
59
641
|
}
|
|
60
|
-
|
|
61
|
-
|
|
642
|
+
td.fail {
|
|
643
|
+
background-color: #f8d7da;
|
|
644
|
+
color: #721c24;
|
|
62
645
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
646
|
+
tr.section-row td {
|
|
647
|
+
background-color: #eef2f7;
|
|
648
|
+
font-weight: 600;
|
|
649
|
+
padding-top: 10px;
|
|
650
|
+
}
|
|
651
|
+
.error-detail {
|
|
652
|
+
color: #721c24;
|
|
653
|
+
font-family: monospace;
|
|
654
|
+
font-size: 12px;
|
|
655
|
+
margin-top: 4px;
|
|
656
|
+
white-space: pre-wrap;
|
|
657
|
+
}
|
|
658
|
+
#sandbox {
|
|
659
|
+
position: fixed;
|
|
660
|
+
top: -9999px;
|
|
661
|
+
left: -9999px;
|
|
70
662
|
}
|
|
71
663
|
</style>
|
|
72
664
|
|
|
665
|
+
<header>
|
|
666
|
+
<h2>Interestfor Polyfill Automated Test Suite</h2>
|
|
667
|
+
<label>
|
|
668
|
+
Target:
|
|
669
|
+
<select id="target_select">
|
|
670
|
+
<option value="src">src/interestfor.js</option>
|
|
671
|
+
<option value="min">src/interestfor.min.js</option>
|
|
672
|
+
</select>
|
|
673
|
+
</label>
|
|
674
|
+
<button id="rerun" type="button">Re-run</button>
|
|
675
|
+
</header>
|
|
676
|
+
|
|
677
|
+
<div id="summary" class="running-bg">Running tests...</div>
|
|
678
|
+
<table>
|
|
679
|
+
<thead>
|
|
680
|
+
<tr>
|
|
681
|
+
<th>Status</th>
|
|
682
|
+
<th>Test</th>
|
|
683
|
+
</tr>
|
|
684
|
+
</thead>
|
|
685
|
+
<tbody id="log"></tbody>
|
|
686
|
+
</table>
|
|
687
|
+
|
|
688
|
+
<div id="sandbox"></div>
|
|
689
|
+
|
|
73
690
|
<script>
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
691
|
+
const params = new URLSearchParams(location.search);
|
|
692
|
+
const targetMode = params.get("target") === "min" ? "min" : "src";
|
|
693
|
+
const targetSelect = document.getElementById("target_select");
|
|
694
|
+
targetSelect.value = targetMode;
|
|
695
|
+
targetSelect.addEventListener("change", () => {
|
|
696
|
+
const url = new URL(location.href);
|
|
697
|
+
url.searchParams.set("target", targetSelect.value);
|
|
698
|
+
location.href = url.toString();
|
|
699
|
+
});
|
|
700
|
+
document.getElementById("rerun").addEventListener("click", () => location.reload());
|
|
701
|
+
|
|
702
|
+
// Force the polyfill to install even in browsers with native support.
|
|
703
|
+
window.interestForUsePolyfillAlways = true;
|
|
704
|
+
const polyfillScript = document.createElement("script");
|
|
705
|
+
polyfillScript.src = targetMode === "min" ? "../src/interestfor.min.js" : "../src/interestfor.js";
|
|
706
|
+
const polyfillLoaded = new Promise((resolve, reject) => {
|
|
707
|
+
polyfillScript.onload = resolve;
|
|
708
|
+
polyfillScript.onerror = () => reject(new Error(`Failed to load ${polyfillScript.src}`));
|
|
709
|
+
});
|
|
710
|
+
document.head.appendChild(polyfillScript);
|
|
711
|
+
|
|
712
|
+
// Minimal test harness
|
|
713
|
+
const logBody = document.getElementById("log");
|
|
714
|
+
const sandbox = document.getElementById("sandbox");
|
|
715
|
+
let totalCount = 0;
|
|
716
|
+
let passCount = 0;
|
|
717
|
+
let failCount = 0;
|
|
718
|
+
|
|
719
|
+
function section(title) {
|
|
720
|
+
const tr = document.createElement("tr");
|
|
721
|
+
tr.className = "section-row";
|
|
722
|
+
const td = document.createElement("td");
|
|
723
|
+
td.colSpan = 2;
|
|
724
|
+
td.textContent = title;
|
|
725
|
+
tr.appendChild(td);
|
|
726
|
+
logBody.appendChild(tr);
|
|
82
727
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
whatToTest.textContent = 'POLYFILL';
|
|
91
|
-
} else if (document.querySelector('#option_use_min_polyfill').selected) {
|
|
92
|
-
whatToTest.textContent = 'POLYFILL (minified)';
|
|
728
|
+
|
|
729
|
+
function recordResult(name, passed, errorMsg) {
|
|
730
|
+
totalCount++;
|
|
731
|
+
if (passed) {
|
|
732
|
+
passCount++;
|
|
733
|
+
} else {
|
|
734
|
+
failCount++;
|
|
93
735
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const loaded = new Promise(resolve => polyfill.onload = resolve);
|
|
106
|
-
document.body.appendChild(polyfill);
|
|
107
|
-
await loaded;
|
|
736
|
+
const tr = document.createElement("tr");
|
|
737
|
+
const statusTd = document.createElement("td");
|
|
738
|
+
statusTd.className = `status ${passed ? "pass" : "fail"}`;
|
|
739
|
+
statusTd.textContent = passed ? "PASS" : "FAIL";
|
|
740
|
+
const nameTd = document.createElement("td");
|
|
741
|
+
nameTd.textContent = name;
|
|
742
|
+
if (!passed && errorMsg) {
|
|
743
|
+
const detail = document.createElement("div");
|
|
744
|
+
detail.className = "error-detail";
|
|
745
|
+
detail.textContent = errorMsg;
|
|
746
|
+
nameTd.appendChild(detail);
|
|
108
747
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
testHarness();
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
function addResult(success,msg) {
|
|
115
|
-
const newRow = document.createElement('tr');
|
|
116
|
-
newRow.innerHTML = `<td class=${success ? 'pass' : 'fail'}>${success ? 'PASS' : 'FAIL'}</td><td>${msg}</td>`;
|
|
117
|
-
document.getElementById('log').appendChild(newRow);
|
|
748
|
+
tr.append(statusTd, nameTd);
|
|
749
|
+
logBody.appendChild(tr);
|
|
118
750
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
console.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
} else if (logSuccess) {
|
|
131
|
-
addResult(true,msg);
|
|
132
|
-
return true;
|
|
751
|
+
|
|
752
|
+
async function test(name, fn) {
|
|
753
|
+
sandbox.replaceChildren();
|
|
754
|
+
try {
|
|
755
|
+
await fn();
|
|
756
|
+
recordResult(name, true);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
console.error(`FAIL: ${name}`, err);
|
|
759
|
+
recordResult(name, false, err && err.message ? err.message : String(err));
|
|
760
|
+
} finally {
|
|
761
|
+
sandbox.replaceChildren();
|
|
133
762
|
}
|
|
134
763
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
764
|
+
|
|
765
|
+
function assert_true(cond, msg = "expected true") {
|
|
766
|
+
if (!cond) {
|
|
767
|
+
throw new Error(msg);
|
|
768
|
+
}
|
|
140
769
|
}
|
|
141
770
|
|
|
142
|
-
function
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
return false;
|
|
771
|
+
function assert_false(cond, msg = "expected false") {
|
|
772
|
+
if (cond) {
|
|
773
|
+
throw new Error(msg);
|
|
146
774
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function assert_equals(actual, expected, msg = "") {
|
|
778
|
+
if (!Object.is(actual, expected)) {
|
|
779
|
+
const prefix = msg ? `${msg}: ` : "";
|
|
780
|
+
throw new Error(`${prefix}expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
150
781
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
if (expected[i] !== actual[i]) {
|
|
158
|
-
val = assert_true(false,`${description}, expected property ${i} to be ${expected[i]} but got ${actual[i]} (expected array ${expected} got ${actual})`)
|
|
159
|
-
&& val;
|
|
160
|
-
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function assert_array_equals(actual, expected, msg = "") {
|
|
785
|
+
assert_equals(actual.length, expected.length, `${msg} (length)`);
|
|
786
|
+
for (let i = 0; i < expected.length; i++) {
|
|
787
|
+
assert_equals(actual[i], expected[i], `${msg} (index ${i})`);
|
|
161
788
|
}
|
|
162
|
-
|
|
163
|
-
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function assert_throws_js(ErrorConstructor, fn, msg = "") {
|
|
792
|
+
let threw = false;
|
|
793
|
+
try {
|
|
794
|
+
fn();
|
|
795
|
+
} catch (e) {
|
|
796
|
+
threw = true;
|
|
797
|
+
assert_true(
|
|
798
|
+
e instanceof ErrorConstructor,
|
|
799
|
+
`${msg}: expected ${ErrorConstructor.name}, got ${e}`
|
|
800
|
+
);
|
|
164
801
|
}
|
|
802
|
+
assert_true(threw, `${msg}: expected ${ErrorConstructor.name} to be thrown`);
|
|
165
803
|
}
|
|
166
804
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
case 'focus':
|
|
187
|
-
goBackText = 'FOCUS ME WITH THE KEYBOARD'
|
|
188
|
-
element.innerText = goBackText;
|
|
189
|
-
await new Promise(resolve => element.addEventListener('focus',resolve, {once:true}));
|
|
190
|
-
element.innerText = 'STAY FOCUSED ON THIS';
|
|
191
|
-
break;
|
|
192
|
-
default:
|
|
193
|
-
notreached('invalid type');
|
|
194
|
-
}
|
|
195
|
-
await new Promise(resolve => setTimeout(resolve,0));
|
|
196
|
-
midEvents = [...events];
|
|
197
|
-
midTargetOpen = target.matches(':popover-open');
|
|
198
|
-
let gotTimeout = false;
|
|
199
|
-
const timeout = new Promise(resolve => setTimeout(() => {
|
|
200
|
-
gotTimeout=true;
|
|
201
|
-
resolve();
|
|
202
|
-
},timeMilliseconds*1.5));
|
|
203
|
-
// Make sure the user doesn't leave too early:
|
|
204
|
-
switch (type) {
|
|
205
|
-
case 'hover':
|
|
206
|
-
const mouseLeft = new Promise(resolve => element.addEventListener('mouseleave',resolve, {once:true}));
|
|
207
|
-
await Promise.race([timeout,mouseLeft]);
|
|
208
|
-
break;
|
|
209
|
-
case 'focus':
|
|
210
|
-
const focusLeft = new Promise(resolve => element.addEventListener('blur',resolve, {once:true}));
|
|
211
|
-
await Promise.race([timeout,focusLeft]);
|
|
212
|
-
break;
|
|
213
|
-
default:
|
|
214
|
-
notreached('invalid type');
|
|
215
|
-
}
|
|
216
|
-
done = gotTimeout;
|
|
217
|
-
if (!done) {
|
|
218
|
-
element.innerText = goBackText;
|
|
219
|
-
await timeout;
|
|
220
|
-
}
|
|
221
|
-
} while (!done);
|
|
222
|
-
controller.abort();
|
|
223
|
-
await new Promise(resolve => setTimeout(resolve,0));
|
|
224
|
-
return {midEvents, midTargetOpen, endEvents: [...events], endTargetOpen: target.matches(':popover-open')};
|
|
805
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
806
|
+
const nextTick = () => wait(0);
|
|
807
|
+
|
|
808
|
+
function hover(el) {
|
|
809
|
+
el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, composed: true }));
|
|
810
|
+
}
|
|
811
|
+
function dehover(el) {
|
|
812
|
+
el.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, composed: true }));
|
|
813
|
+
}
|
|
814
|
+
function focusIn(el) {
|
|
815
|
+
el.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true }));
|
|
816
|
+
}
|
|
817
|
+
function focusOut(el) {
|
|
818
|
+
el.dispatchEvent(new FocusEvent("focusout", { bubbles: true, composed: true }));
|
|
819
|
+
}
|
|
820
|
+
function pressEscape(target = document.body) {
|
|
821
|
+
return target.dispatchEvent(
|
|
822
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })
|
|
823
|
+
);
|
|
225
824
|
}
|
|
226
825
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
invoker.
|
|
240
|
-
invoker.style.setProperty('--interest-delay-end',hoverTimeMilliseconds + 'ms');
|
|
241
|
-
invoker.style.setProperty('interest-delay-end',hoverTimeMilliseconds + 'ms');
|
|
826
|
+
function createPair({
|
|
827
|
+
invokerTag = "button",
|
|
828
|
+
invokerAttrs = {},
|
|
829
|
+
invokerHTML = "Invoker",
|
|
830
|
+
targetId = "t_" + Math.random().toString(36).slice(2),
|
|
831
|
+
popoverType = "auto",
|
|
832
|
+
targetHTML = "Target content",
|
|
833
|
+
delayStart = "15ms",
|
|
834
|
+
delayEnd = "15ms",
|
|
835
|
+
} = {}) {
|
|
836
|
+
const invoker = document.createElement(invokerTag);
|
|
837
|
+
if (invokerTag === "a" && !("href" in invokerAttrs)) {
|
|
838
|
+
invoker.setAttribute("href", "#");
|
|
242
839
|
}
|
|
243
|
-
invoker.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// Lose interest in the invoker
|
|
256
|
-
({midEvents, midTargetOpen, endEvents, endTargetOpen} = await waitForHoverOrFocus(type,unrelated,target,hoverTimeMilliseconds));
|
|
257
|
-
const endString = type=='hover' ? 'de-hovering' : 'blurring';
|
|
258
|
-
assert_true(midTargetOpen,`popover should *not* be immediately hidden by ${endString}`,true);
|
|
259
|
-
assert_array_equals(midEvents,[],`No events should be immediately fired by ${endString}`,true);
|
|
260
|
-
assert_false(endTargetOpen,`popover should hidden after delay after ${endString}`,true);
|
|
261
|
-
assert_array_equals(endEvents,['loseinterest'],`The loseinterest event should be fired after ${endString}`,true);
|
|
262
|
-
invoker.classList.add('hide');
|
|
263
|
-
unrelated.classList.add('hide');
|
|
264
|
-
}
|
|
840
|
+
invoker.setAttribute("interestfor", targetId);
|
|
841
|
+
for (const [k, v] of Object.entries(invokerAttrs)) {
|
|
842
|
+
invoker.setAttribute(k, v);
|
|
843
|
+
}
|
|
844
|
+
if (delayStart !== null) {
|
|
845
|
+
invoker.style.setProperty("--interest-delay-start", delayStart);
|
|
846
|
+
}
|
|
847
|
+
if (delayEnd !== null) {
|
|
848
|
+
invoker.style.setProperty("--interest-delay-end", delayEnd);
|
|
849
|
+
}
|
|
850
|
+
invoker.innerHTML = invokerHTML;
|
|
265
851
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
case 'hover':
|
|
271
|
-
typeStr.innerText = "THE MOUSE to activate elements";
|
|
272
|
-
break;
|
|
273
|
-
case 'focus':
|
|
274
|
-
typeStr.innerText = "THE KEYBOARD to activate elements (and move the mouse away)";
|
|
275
|
-
break;
|
|
276
|
-
default:
|
|
277
|
-
notreached('invalid type');
|
|
852
|
+
const target = document.createElement("div");
|
|
853
|
+
target.id = targetId;
|
|
854
|
+
if (popoverType !== null) {
|
|
855
|
+
target.setAttribute("popover", popoverType);
|
|
278
856
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
857
|
+
target.innerHTML = targetHTML;
|
|
858
|
+
|
|
859
|
+
sandbox.append(invoker, target);
|
|
860
|
+
return { invoker, target };
|
|
282
861
|
}
|
|
283
862
|
|
|
284
|
-
async function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
await showFocusWarning(type);
|
|
289
|
-
await testOne(already_present,already_present_target,type,'Pre-existing elements');
|
|
290
|
-
|
|
291
|
-
// Dynamically add elements:
|
|
292
|
-
const targets = document.getElementById('targets');
|
|
293
|
-
const dynamic_invoker = document.createElement('a');
|
|
294
|
-
dynamic_invoker.href = 'foo';
|
|
295
|
-
dynamic_invoker.setAttribute('interestfor','dynamic_target');
|
|
296
|
-
dynamic_invoker.classList='hide test-element';
|
|
297
|
-
const dynamic_target = document.createElement('div');
|
|
298
|
-
dynamic_target.popover = 'auto';
|
|
299
|
-
dynamic_target.id = 'dynamic_target';
|
|
300
|
-
dynamic_target.classList = 'hide';
|
|
301
|
-
targets.append(dynamic_invoker,dynamic_target);
|
|
302
|
-
await testOne(dynamic_invoker,dynamic_target,type,'Dynamically-added elements');
|
|
303
|
-
dynamic_invoker.remove();
|
|
304
|
-
dynamic_target.remove();
|
|
305
|
-
|
|
306
|
-
// Dynamically add elements for shorthand test:
|
|
307
|
-
const shorthand_invoker = document.createElement('a');
|
|
308
|
-
shorthand_invoker.href = 'foo';
|
|
309
|
-
shorthand_invoker.setAttribute('interestfor','shorthand_target');
|
|
310
|
-
shorthand_invoker.classList='hide test-element';
|
|
311
|
-
const shorthand_target = document.createElement('div');
|
|
312
|
-
shorthand_target.popover = 'auto';
|
|
313
|
-
shorthand_target.id = 'shorthand_target';
|
|
314
|
-
shorthand_target.classList = 'hide';
|
|
315
|
-
targets.append(shorthand_invoker,shorthand_target);
|
|
316
|
-
await testOne(shorthand_invoker,shorthand_target,type,'Dynamically-added elements (shorthand)', true);
|
|
317
|
-
shorthand_invoker.remove();
|
|
318
|
-
shorthand_target.remove();
|
|
863
|
+
async function main() {
|
|
864
|
+
await polyfillLoaded;
|
|
865
|
+
if (document.readyState !== "complete") {
|
|
866
|
+
await new Promise((r) => window.addEventListener("load", r, { once: true }));
|
|
319
867
|
}
|
|
868
|
+
await nextTick();
|
|
869
|
+
|
|
870
|
+
await runTests();
|
|
320
871
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
872
|
+
const summary = document.getElementById("summary");
|
|
873
|
+
if (failCount === 0) {
|
|
874
|
+
summary.className = "pass-bg";
|
|
875
|
+
summary.textContent = `PASS — All ${passCount} tests passed (${targetSelect.options[targetSelect.selectedIndex].text})`;
|
|
876
|
+
} else {
|
|
877
|
+
summary.className = "fail-bg";
|
|
878
|
+
summary.textContent = `FAIL — ${failCount} of ${totalCount} tests failed (${passCount} passed)`;
|
|
879
|
+
}
|
|
880
|
+
console.log(summary.textContent);
|
|
327
881
|
}
|
|
328
882
|
|
|
883
|
+
main();
|
|
329
884
|
</script>
|