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/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
- <h3>Interestfor Polyfill Test</h3>
4
- <ul>
5
- <li><p><label>
6
- <select id="polyfill_select">
7
- <option id="option_use_polyfill">Test using the polyfill</option>
8
- <option id="option_use_min_polyfill">Test using the minified polyfill</option>
9
- <option id="option_use_native">Test the native feature</option>
10
- </select>
11
- <li><p>Interestfor is <u id="native_support"></u> in this browser.</p>
12
- <li><p>This test will test the <u id="what_to_test"></u>.</p>
13
- <li><p>To cleanly re-test different cases (removing all polyfill traces), reload the page.</p></li>
14
- <li><p><h3 style="display:inline">Note!</h3> This is a *manual* test - you must follow the instructions carefully while testing.</p></li>
15
- </ul>
16
- <button id="go">Run the test</button>
17
-
18
- <button class="test_content">Focusable thing before test stuff</button>
19
- <div class="test_content" id="targets">
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
- <div id=results class="hide">
30
- <h3>Results:</h3>
31
- <h3 id="summary"></h3>
32
- <table id=log></table>
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
- .pass {
37
- background-color: lightgreen;
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
- .fail {
40
- background-color: red;
614
+ .running-bg {
615
+ background-color: #fff3cd;
616
+ color: #856404;
617
+ border: 1px solid #ffeeba;
41
618
  }
42
- html,body {
619
+ table {
620
+ border-collapse: collapse;
43
621
  width: 100%;
622
+ max-width: 960px;
623
+ font-size: 14px;
44
624
  }
45
- .test-element {
46
- width:300px;
47
- height:100px;
48
- margin: 10px 0;
49
- border: 1px solid red;
625
+ th, td {
626
+ border: 1px solid #ddd;
627
+ padding: 6px 10px;
628
+ text-align: left;
50
629
  }
51
- .test-element:not([popover]) {
52
- display:block;
630
+ th {
631
+ background: #f5f5f5;
53
632
  }
54
- .hide {
55
- visibility: hidden;
633
+ td.status {
634
+ width: 60px;
635
+ font-weight: 600;
636
+ text-align: center;
56
637
  }
57
- body.testing :not(.test_content,.test_content *) {
58
- display:none;
638
+ td.pass {
639
+ background-color: #d4edda;
640
+ color: #155724;
59
641
  }
60
- :focus-visible {
61
- outline: 3px solid red;
642
+ td.fail {
643
+ background-color: #f8d7da;
644
+ color: #721c24;
62
645
  }
63
- #focus_warning {
64
- inset:auto;
65
- top:50px;
66
- left:50px;
67
- width: 600px;
68
- height: 400px;
69
- border: 2px solid red;
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 nativelySupported = HTMLButtonElement.prototype.hasOwnProperty("interestForElement");
75
- const polyfillSelect = document.querySelector('#polyfill_select');
76
- const goButton = document.querySelector('#go');
77
- const whatToTest = document.querySelector('#what_to_test');
78
- if (!nativelySupported) {
79
- const useNativeOption = document.querySelector('#option_use_native');
80
- useNativeOption.textContent = 'Test the native feature (Unavailable)';
81
- useNativeOption.disabled = true;
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
- document.querySelector('#native_support').textContent = nativelySupported ? 'Supported' : 'NOT supported';
84
- polyfillSelect.onchange = () => updateWhatToTest();
85
- updateWhatToTest();
86
- function updateWhatToTest() {
87
- if (document.querySelector('#option_use_native').selected) {
88
- whatToTest.textContent = 'NATIVE IMPLEMENTATION';
89
- } else if (document.querySelector('#option_use_polyfill').selected) {
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
- goButton.onclick = async () => {
96
- goButton.disabled = true;
97
- polyfillSelect.disabled = true;
98
- const normalPolyfill = document.querySelector('#option_use_polyfill').selected;
99
- const minPolyfill = document.querySelector('#option_use_min_polyfill').selected;
100
- if (normalPolyfill || minPolyfill) {
101
- // This will tell the polyfill to load even if natively supported:
102
- window.interestForUsePolyfillAlways = true;
103
- const polyfill = document.createElement('script');
104
- polyfill.src = "../src/interestfor." + (minPolyfill ? "min.js" : "js")
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
- document.querySelector('#results').classList.remove('hide');
110
- // Run the tests
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
- window.onerror = function(message, source, lineno, colno, error) {
120
- addResult(false,`Uncaught exception: ${message} at line ${lineno} in file ${source}`);
121
- };
122
-
123
- let any_failures = false;
124
- function assert_true(val,msg,logSuccess) {
125
- if (!val) {
126
- console.log('=====> FAIL! ',msg);
127
- any_failures = true;
128
- addResult(false,msg);
129
- return false;
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
- function assert_false(val,msg,logSuccess) {
136
- return assert_true(!val,msg,logSuccess);
137
- }
138
- function notreached(msg) {
139
- return assert_true(false,msg,/*logSuccess*/true);
764
+
765
+ function assert_true(cond, msg = "expected true") {
766
+ if (!cond) {
767
+ throw new Error(msg);
768
+ }
140
769
  }
141
770
 
142
- function assert_array_equals(actual, expected, description, logSuccess) {
143
- if (!(typeof actual === "object" && actual !== null && "length" in actual)) {
144
- assert_true(false, `${description}, value is ${actual}, expected array`);
145
- return false;
771
+ function assert_false(cond, msg = "expected false") {
772
+ if (cond) {
773
+ throw new Error(msg);
146
774
  }
147
- if (actual.length !== expected.length) {
148
- assert_true(false,`${description}, lengths differ, expected array ${expected} length ${expected.length}, got ${actual} length ${actual.length}`);
149
- return false;
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
- let val = true;
152
- for (var i = 0; i < actual.length; i++) {
153
- if (actual.hasOwnProperty(i) !== expected.hasOwnProperty(i)) {
154
- val = assert_true(false,`${description}, expected property ${i} to be ${expected[i]} but was ${actual[i]} (expected array ${expected} got ${actual})`)
155
- && val;
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
- if (val) {
163
- assert_true(true,description,logSuccess);
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 hoverTimeMilliseconds = 500;
168
- async function waitForHoverOrFocus(type,element,target,timeMilliseconds) {
169
- let events = [];
170
- const controller = new AbortController();
171
- const signal = controller.signal;
172
- target.addEventListener('interest',() => events.push('interest'),{signal});
173
- target.addEventListener('loseinterest',() => events.push('loseinterest'),{signal});
174
-
175
- let done = false;
176
- do {
177
- events = [];
178
- let goBackText = "";
179
- switch (type) {
180
- case 'hover':
181
- goBackText = 'HOVER ME WITH THE MOUSE'
182
- element.innerText = goBackText;
183
- await new Promise(resolve => element.addEventListener('mouseenter',resolve, {once:true}));
184
- element.innerText = 'STAY HOVERING ON THIS';
185
- break;
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
- async function testOne(invoker, target, type, description, useShorthand = false) {
228
- assert_true(true,'---' + description,true);
229
- assert_false(target.matches(':popover-open'),'popover should start closed',true);
230
- if (useShorthand) {
231
- invoker.style.setProperty('--interest-delay', `${hoverTimeMilliseconds}ms ${hoverTimeMilliseconds}ms`);
232
- invoker.style.setProperty('interest-delay', `${hoverTimeMilliseconds}ms ${hoverTimeMilliseconds}ms`);
233
- invoker.style.removeProperty('--interest-delay-start');
234
- invoker.style.removeProperty('interest-delay-start');
235
- invoker.style.removeProperty('--interest-delay-end');
236
- invoker.style.removeProperty('interest-delay-end');
237
- } else {
238
- invoker.style.setProperty('--interest-delay-start',hoverTimeMilliseconds + 'ms');
239
- invoker.style.setProperty('interest-delay-start',hoverTimeMilliseconds + 'ms');
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.classList.remove('hide');
244
-
245
- // Show interest in the invoker
246
- let {midEvents, midTargetOpen, endEvents, endTargetOpen} = await waitForHoverOrFocus(type,invoker,target,hoverTimeMilliseconds);
247
- const startString = type=='hover' ? 'hovering' : 'focusing';
248
- assert_false(midTargetOpen,`popover should *not* be immediately invoked by ${startString}`,true);
249
- assert_array_equals(midEvents,[],`No events should be immediately fired by ${startString}`,true);
250
- assert_true(endTargetOpen,`popover should invoked after delay after ${startString}`,true);
251
- assert_array_equals(endEvents,['interest'],`The interest event should be fired after ${startString}`,true);
252
- invoker.classList.add('hide');
253
- unrelated.classList.remove('hide');
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
- async function showFocusWarning(type) {
267
- const popover = document.getElementById('focus_warning');
268
- const typeStr = document.getElementById('type_str');
269
- switch (type) {
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
- popover.showPopover();
280
- await new Promise(resolve => setTimeout(resolve,3000));
281
- popover.hidePopover();
857
+ target.innerHTML = targetHTML;
858
+
859
+ sandbox.append(invoker, target);
860
+ return { invoker, target };
282
861
  }
283
862
 
284
- async function testHarness() {
285
- document.body.classList.add('testing');
286
-
287
- for(type of ['hover','focus']) {
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
- // Test complete
322
- targets.style.display = 'none';
323
- const summary = document.getElementById('summary');
324
- summary.innerText = any_failures ? 'FAILURE' : 'PASS';
325
- summary.classList = any_failures ? 'fail' : 'pass';
326
- document.body.classList.remove('testing');
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>