interestfor 1.0.3
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/CODEOWNERS +1 -0
- package/LICENSE +28 -0
- package/README.md +118 -0
- package/package.json +17 -0
- package/src/interestfor.js +378 -0
- package/src/interestfor.min.js +2 -0
- package/test/basic_test.html +6 -0
- package/test/test.html +303 -0
package/CODEOWNERS
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @mfreed7 @keithamus
|
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, Mason Freed
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# `interestfor` Polyfill
|
|
2
|
+
|
|
3
|
+
A polyfill for the `interestfor` attribute. This is an emerging standard for
|
|
4
|
+
an API that hopes to be implemented in browsers. See the explainer for all of
|
|
5
|
+
the details for the "real" feature:
|
|
6
|
+
|
|
7
|
+
https://open-ui.org/components/interest-invokers.explainer/
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
To use this polyfill, simply load it:
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<script src="interestfor.min.js" async></script>
|
|
14
|
+
|
|
15
|
+
<a interestfor=foo href=bar>Link</a>
|
|
16
|
+
<div popover id=foo>Popover</div>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Behavior Summary
|
|
20
|
+
|
|
21
|
+
Adding the `interestfor` attribute to a button or link (the "interest
|
|
22
|
+
invoker"), and giving it a value that is the IDREF of another element (the
|
|
23
|
+
"target" element), will cause the target to get "interest" and "loseinterest"
|
|
24
|
+
events when the user "shows interest" in the interest invoker. Showing interest
|
|
25
|
+
can be done by hovering the element with the mouse, or focusing it with the
|
|
26
|
+
keyboard. If the target is a popover, it will automatically be shown and hidden
|
|
27
|
+
upon receiving and losing interest.
|
|
28
|
+
|
|
29
|
+
Again, this is a quick summary. For the complete details, please [read the
|
|
30
|
+
explainer](https://open-ui.org/components/interest-invokers.explainer).
|
|
31
|
+
|
|
32
|
+
## Touchscreen not supported
|
|
33
|
+
|
|
34
|
+
This polyfill will not provide touchscreen users with any way to show interest
|
|
35
|
+
in the invoker, because on touchscreen, long-press is the user-understood way to
|
|
36
|
+
"show interest" in an element. On today's browsers, long-pressing a link element
|
|
37
|
+
brings up a useful context menu that contains items like "Share" and "Open in
|
|
38
|
+
new tab". In order for the polyfill to support interest on mobile, that context
|
|
39
|
+
menu would need to be surpressed completely. There is currently no way to
|
|
40
|
+
provide access to the long-press context menu and *also* provide a way to show
|
|
41
|
+
interest. Since the context menu contains items that users often use,
|
|
42
|
+
eliminating access to the context menu is not what most developers want. So
|
|
43
|
+
this polyfill does nothing on mobile touchscreen.
|
|
44
|
+
|
|
45
|
+
This is one of the primary motivations for Chromium championing this as a
|
|
46
|
+
native API in all browsers: to support all users, including those using
|
|
47
|
+
touchscreens. If you'd like to weigh in on the need for this native API,
|
|
48
|
+
please feel free to comment on the standards positions for the non-supporting
|
|
49
|
+
browsers:
|
|
50
|
+
|
|
51
|
+
- WebKit: https://github.com/WebKit/standards-positions/issues/464
|
|
52
|
+
- Mozilla: https://github.com/mozilla/standards-positions/issues/1181
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Implementation
|
|
56
|
+
The polyfill adds event listeners to document.body that monitor for hover
|
|
57
|
+
or keyboard events relevant fo the `interestfor` API. When interesting
|
|
58
|
+
events happen to elements with the `interestfor` attribute or elements that
|
|
59
|
+
are the targets of those elements, it fires `interest` and `loseinterest`
|
|
60
|
+
events on the target of the `interestfor` attribute. If the
|
|
61
|
+
target is a popover, it also handles showing or hiding the popover.
|
|
62
|
+
|
|
63
|
+
Because this is a polyfill, a few things are slightly different than the
|
|
64
|
+
native feature:
|
|
65
|
+
|
|
66
|
+
- The CSS properties for controlling delays are implemented as custom
|
|
67
|
+
properties, so you'll want to add both the regular property and the custom
|
|
68
|
+
property, prefixed with `--`, to your stylesheet:
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
[interestfor] {
|
|
72
|
+
interest-show-delay: 400ms;
|
|
73
|
+
--interest-show-delay: 400ms;
|
|
74
|
+
interest-hide-delay: 200ms;
|
|
75
|
+
--interest-hide-delay: 200ms;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- The CSS pseudo classes for detecting invoker and target interest states are
|
|
80
|
+
implemented as regular class names, so again add both to your stylesheet:
|
|
81
|
+
|
|
82
|
+
```css
|
|
83
|
+
:is([interestfor]:has-interest),
|
|
84
|
+
[interestfor].has-interest {
|
|
85
|
+
/* invoker styles */
|
|
86
|
+
}
|
|
87
|
+
:is([popover]:target-of-interest),
|
|
88
|
+
[popover].target-of-interest {
|
|
89
|
+
/* target popover styles */
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Note the `:is()` wrapped around the "normal" selector for the real pseudo
|
|
94
|
+
class names. This is to avoid the entire selector being invalidated if the
|
|
95
|
+
browser does not recognize the native pseudo class (e.g. `:has-interest`).
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
The `test/test.html` file exercises the various parts of this feature and tests
|
|
101
|
+
for correctness. This is a manual test, since hovering, keyboard-focusing, etc.
|
|
102
|
+
cannot be done programmatically. The test is capable of testing either the
|
|
103
|
+
polyfill (optionally minified) or the native feature. You can run tests directly
|
|
104
|
+
from this repo,
|
|
105
|
+
[here](https://mfreed7.github.io/interestfor/test/test.html).
|
|
106
|
+
|
|
107
|
+
## Things that don't (yet?) work
|
|
108
|
+
|
|
109
|
+
These things are currently not handled correctly by this polyfill:
|
|
110
|
+
|
|
111
|
+
- There is no support for the `--interest-delay` shorthand.
|
|
112
|
+
- There is no touchscreen support, as mentioned above.
|
|
113
|
+
|
|
114
|
+
## Improvements / Bugs
|
|
115
|
+
|
|
116
|
+
If you find issues with the polyfill, feel free to file them [here](https://github.com/mfreed7/interestfor/issues).
|
|
117
|
+
Even better, if you would like to contribute to this polyfill, I'm happy to review [pull requests](https://github.com/mfreed7/interestfor/pulls).
|
|
118
|
+
Thanks in advance!
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "interestfor",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "A polyfill for the `interestfor` API",
|
|
5
|
+
"keywords": "interestfor, interesttarget, polyfill, hover-triggering",
|
|
6
|
+
"homepage": "https://github.com/mfreed7/interestfor#readme",
|
|
7
|
+
"bugs": "https://github.com/mfreed7/interestfor/issues",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git://github.com/mfreed7/interestfor.git"
|
|
11
|
+
},
|
|
12
|
+
"author": "Mason Freed <mfreed@mfreed.com>",
|
|
13
|
+
"license": "BSD-3-Clause",
|
|
14
|
+
"main": "src/interestfor.min.js",
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// Copyright (c) 2025, Mason Freed
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
//
|
|
4
|
+
// This source code is licensed under the BSD-style license found in the
|
|
5
|
+
// LICENSE file in the root directory of this source tree.
|
|
6
|
+
|
|
7
|
+
// This is a polyfill of the `interestfor` attribute, as described here:
|
|
8
|
+
// https://open-ui.org/components/interest-invokers.explainer/
|
|
9
|
+
|
|
10
|
+
(function () {
|
|
11
|
+
const attributeName = "interestfor";
|
|
12
|
+
const interestEventName = "interest";
|
|
13
|
+
const loseInterestEventName = "loseinterest";
|
|
14
|
+
const showDelayProp = "--interest-show-delay";
|
|
15
|
+
const hideDelayProp = "--interest-hide-delay";
|
|
16
|
+
const dataField = "__interestForData";
|
|
17
|
+
const targetDataField = "__interestForTargetData";
|
|
18
|
+
|
|
19
|
+
// Feature detection
|
|
20
|
+
if (window.interestForPolyfillInstalled) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
window.interestForPolyfillInstalled = true;
|
|
24
|
+
const nativeSupported = HTMLButtonElement.prototype.hasOwnProperty(
|
|
25
|
+
"interestForElement"
|
|
26
|
+
);
|
|
27
|
+
if (nativeSupported && !window.interestForUsePolyfillAlways) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (nativeSupported) {
|
|
31
|
+
// "Break" the existing feature, so the polyfill can take effect.
|
|
32
|
+
const cancel = (e) => {
|
|
33
|
+
if (e.isTrusted) {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
e.stopImmediatePropagation();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
document.body.addEventListener(interestEventName,cancel,{capture:true});
|
|
39
|
+
document.body.addEventListener(loseInterestEventName,cancel,{capture:true});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Enum-like state and source
|
|
43
|
+
const InterestState = {
|
|
44
|
+
NoInterest: "none",
|
|
45
|
+
PartialInterest: "partial",
|
|
46
|
+
FullInterest: "full",
|
|
47
|
+
};
|
|
48
|
+
const Source = {
|
|
49
|
+
Hover: "hover",
|
|
50
|
+
DeHover: "dehover",
|
|
51
|
+
Focus: "focus",
|
|
52
|
+
Blur: "blur",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Gain or lose interest
|
|
56
|
+
function GainOrLoseInterest(invoker, target, newState) {
|
|
57
|
+
if (!invoker || !target) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
!invoker.isConnected ||
|
|
62
|
+
GetInterestForTarget(invoker) !== target ||
|
|
63
|
+
(newState === InterestState.NoInterest &&
|
|
64
|
+
GetInterestInvoker(target) !== invoker)
|
|
65
|
+
) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If gaining, handle existing invoker
|
|
70
|
+
if (newState !== InterestState.NoInterest) {
|
|
71
|
+
const existing = GetInterestInvoker(target);
|
|
72
|
+
if (existing) {
|
|
73
|
+
if (existing === invoker) {
|
|
74
|
+
existing[dataField].clearLostTask();
|
|
75
|
+
return false;
|
|
76
|
+
} else {
|
|
77
|
+
if (!GainOrLoseInterest(existing, target, InterestState.NoInterest)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// re-check preconditions
|
|
81
|
+
if (!invoker.isConnected || GetInterestForTarget(invoker) !== target) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// finally apply interest
|
|
87
|
+
return applyState(invoker, newState);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// losing interest
|
|
91
|
+
clearState(invoker);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Schedule tasks
|
|
96
|
+
function ScheduleInterestGainedTask(invoker, newState) {
|
|
97
|
+
const delay = getDelaySeconds(invoker, showDelayProp) * 1000;
|
|
98
|
+
if (!isFinite(delay) || delay < 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
invoker[dataField].clearGainedTask();
|
|
102
|
+
invoker[dataField].gainedTimer = setTimeout(() => {
|
|
103
|
+
GainOrLoseInterest(invoker, GetInterestForTarget(invoker), newState);
|
|
104
|
+
}, delay);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ScheduleInterestLostTask(invoker) {
|
|
108
|
+
const delay = getDelaySeconds(invoker, hideDelayProp) * 1000;
|
|
109
|
+
if (!isFinite(delay) || delay < 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
invoker[dataField].clearLostTask();
|
|
113
|
+
invoker[dataField].lostTimer = setTimeout(() => {
|
|
114
|
+
GainOrLoseInterest(
|
|
115
|
+
invoker,
|
|
116
|
+
GetInterestForTarget(invoker),
|
|
117
|
+
InterestState.NoInterest
|
|
118
|
+
);
|
|
119
|
+
}, delay);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Helpers
|
|
123
|
+
function GetInterestInvoker(target) {
|
|
124
|
+
const inv = target[targetDataField]?.invoker || null;
|
|
125
|
+
return inv && inv[dataField]?.state !== InterestState.NoInterest
|
|
126
|
+
? inv
|
|
127
|
+
: null;
|
|
128
|
+
}
|
|
129
|
+
function GetInterestForTarget(el) {
|
|
130
|
+
const id = el.getAttribute(attributeName);
|
|
131
|
+
return document.getElementById(id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getDelaySeconds(el, prop) {
|
|
135
|
+
const raw = getComputedStyle(el).getPropertyValue(prop).trim();
|
|
136
|
+
const m = raw.match(/^([\d.]+)s$/);
|
|
137
|
+
if (m) {
|
|
138
|
+
return parseFloat(m[1]);
|
|
139
|
+
}
|
|
140
|
+
return parseFloat(raw) || 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Actual state transitions
|
|
144
|
+
function applyState(invoker, newState) {
|
|
145
|
+
const data = invoker[dataField];
|
|
146
|
+
const target = GetInterestForTarget(invoker);
|
|
147
|
+
switch (newState) {
|
|
148
|
+
case InterestState.PartialInterest:
|
|
149
|
+
if (data.state === InterestState.NoInterest) {
|
|
150
|
+
if (!target.dispatchEvent(new Event(interestEventName))) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
target.showPopover();
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
data.state = InterestState.PartialInterest;
|
|
158
|
+
if (!target[targetDataField]) {
|
|
159
|
+
target[targetDataField] = {};
|
|
160
|
+
}
|
|
161
|
+
target[targetDataField].invoker = invoker;
|
|
162
|
+
invoker.classList.add("has-partial-interest", "has-interest");
|
|
163
|
+
target.classList.add(
|
|
164
|
+
"target-of-partial-interest",
|
|
165
|
+
"target-of-interest"
|
|
166
|
+
);
|
|
167
|
+
disableFocusable(target);
|
|
168
|
+
break;
|
|
169
|
+
case InterestState.FullInterest:
|
|
170
|
+
if (data.state !== InterestState.PartialInterest) {
|
|
171
|
+
if (!target.dispatchEvent(new Event(interestEventName))) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
target.showPopover();
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
data.state = InterestState.FullInterest;
|
|
179
|
+
if (!target[targetDataField]) {
|
|
180
|
+
target[targetDataField] = {};
|
|
181
|
+
}
|
|
182
|
+
target[targetDataField].invoker = invoker;
|
|
183
|
+
invoker.classList.remove("has-partial-interest");
|
|
184
|
+
invoker.classList.add("has-interest");
|
|
185
|
+
target.classList.remove("target-of-partial-interest");
|
|
186
|
+
target.classList.add("target-of-interest");
|
|
187
|
+
restoreFocusable(target);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function clearState(invoker) {
|
|
194
|
+
const data = invoker[dataField];
|
|
195
|
+
clearTimeout(data.gainedTimer);
|
|
196
|
+
clearTimeout(data.lostTimer);
|
|
197
|
+
if (data.state !== InterestState.NoInterest) {
|
|
198
|
+
const target = GetInterestForTarget(invoker);
|
|
199
|
+
if (!target.dispatchEvent(new Event(loseInterestEventName))) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
target.hidePopover();
|
|
204
|
+
} catch {}
|
|
205
|
+
target[targetDataField] = null;
|
|
206
|
+
invoker.classList.remove("has-partial-interest", "has-interest");
|
|
207
|
+
target.classList.remove(
|
|
208
|
+
"target-of-partial-interest",
|
|
209
|
+
"target-of-interest"
|
|
210
|
+
);
|
|
211
|
+
restoreFocusable(target);
|
|
212
|
+
data.state = InterestState.NoInterest;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Focusability utilities
|
|
217
|
+
const focusableSelector = [
|
|
218
|
+
"a[href]",
|
|
219
|
+
"area[href]",
|
|
220
|
+
"input:not([disabled])",
|
|
221
|
+
"select:not([disabled])",
|
|
222
|
+
"textarea:not([disabled])",
|
|
223
|
+
"button:not([disabled])",
|
|
224
|
+
"iframe",
|
|
225
|
+
"object",
|
|
226
|
+
"embed",
|
|
227
|
+
"[contenteditable]",
|
|
228
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
229
|
+
].join(",");
|
|
230
|
+
function disableFocusable(root) {
|
|
231
|
+
root.querySelectorAll(focusableSelector).forEach((el) => {
|
|
232
|
+
if (el.hasAttribute("data-original-tabindex")) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const orig = el.getAttribute("tabindex");
|
|
236
|
+
el.setAttribute("data-original-tabindex", orig === null ? "none" : orig);
|
|
237
|
+
el.setAttribute("tabindex", "-1");
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function restoreFocusable(root) {
|
|
241
|
+
root.querySelectorAll("[data-original-tabindex]").forEach((el) => {
|
|
242
|
+
const orig = el.getAttribute("data-original-tabindex");
|
|
243
|
+
if (orig === "none") {
|
|
244
|
+
el.removeAttribute("tabindex");
|
|
245
|
+
} else {
|
|
246
|
+
el.setAttribute("tabindex", orig);
|
|
247
|
+
}
|
|
248
|
+
el.removeAttribute("data-original-tabindex");
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function HandleInterestHoverOrFocus(el, source) {
|
|
253
|
+
if (!el.isConnected) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const target = GetInterestForTarget(el);
|
|
257
|
+
if (!target) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
let data = el[dataField];
|
|
261
|
+
if (!data) {
|
|
262
|
+
el[dataField] = {
|
|
263
|
+
state: InterestState.NoInterest,
|
|
264
|
+
gainedTimer: null,
|
|
265
|
+
lostTimer: null,
|
|
266
|
+
clearGainedTask() {
|
|
267
|
+
clearTimeout(this.gainedTimer);
|
|
268
|
+
},
|
|
269
|
+
clearLostTask() {
|
|
270
|
+
clearTimeout(this.lostTimer);
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
data = el[dataField];
|
|
274
|
+
}
|
|
275
|
+
const upstreamInvoker = GetInterestInvoker(el);
|
|
276
|
+
|
|
277
|
+
// Hover or focus
|
|
278
|
+
if (source === Source.Hover || source === Source.Focus) {
|
|
279
|
+
data.clearLostTask && data.clearLostTask();
|
|
280
|
+
if (upstreamInvoker) {
|
|
281
|
+
upstreamInvoker[dataField].clearLostTask();
|
|
282
|
+
if (
|
|
283
|
+
upstreamInvoker[dataField].state === InterestState.PartialInterest
|
|
284
|
+
) {
|
|
285
|
+
applyState(upstreamInvoker, InterestState.FullInterest);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const needsPartialInterest =
|
|
289
|
+
source === Source.Focus &&
|
|
290
|
+
target.matches(":has(" + focusableSelector + ")");
|
|
291
|
+
ScheduleInterestGainedTask(
|
|
292
|
+
el,
|
|
293
|
+
needsPartialInterest
|
|
294
|
+
? InterestState.PartialInterest
|
|
295
|
+
: InterestState.FullInterest
|
|
296
|
+
);
|
|
297
|
+
} else {
|
|
298
|
+
// Dehover or blur
|
|
299
|
+
data.clearGainedTask && data.clearGainedTask();
|
|
300
|
+
if (data.state !== InterestState.NoInterest) {
|
|
301
|
+
ScheduleInterestLostTask(el);
|
|
302
|
+
}
|
|
303
|
+
if (upstreamInvoker) {
|
|
304
|
+
upstreamInvoker[dataField].clearGainedTask();
|
|
305
|
+
if (source === Source.Blur || !el.matches(":hover")) {
|
|
306
|
+
ScheduleInterestLostTask(upstreamInvoker);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Attach listeners
|
|
313
|
+
function addEventHandlers() {
|
|
314
|
+
document.body.addEventListener("mouseover", (e) =>
|
|
315
|
+
HandleInterestHoverOrFocus(e.target, Source.Hover)
|
|
316
|
+
);
|
|
317
|
+
document.body.addEventListener("mouseout", (e) =>
|
|
318
|
+
HandleInterestHoverOrFocus(e.target, Source.DeHover)
|
|
319
|
+
);
|
|
320
|
+
document.body.addEventListener("focusin", (e) =>
|
|
321
|
+
HandleInterestHoverOrFocus(e.target, Source.Focus)
|
|
322
|
+
);
|
|
323
|
+
document.body.addEventListener("focusout", (e) =>
|
|
324
|
+
HandleInterestHoverOrFocus(e.target, Source.Blur)
|
|
325
|
+
);
|
|
326
|
+
document.body.addEventListener("keydown", (e) => {
|
|
327
|
+
let data = e.target[dataField];
|
|
328
|
+
if (!data) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (data.state !== InterestState.PartialInterest) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (
|
|
335
|
+
e.key === "ArrowUp" &&
|
|
336
|
+
e.altKey &&
|
|
337
|
+
data.state === InterestState.PartialInterest
|
|
338
|
+
) {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
applyState(e.target, InterestState.FullInterest);
|
|
341
|
+
}
|
|
342
|
+
if (e.key === "Escape" && data.state !== InterestState.NoInterest) {
|
|
343
|
+
e.preventDefault();
|
|
344
|
+
clearState(e.target);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// CSS registration
|
|
350
|
+
function registerCustomProperties() {
|
|
351
|
+
const style = document.createElement("style");
|
|
352
|
+
style.textContent = `@property ${showDelayProp} {syntax: "<time>"; inherits: false; initial-value: 0.5s;}
|
|
353
|
+
@property ${hideDelayProp} {syntax: "<time>"; inherits: false; initial-value: 0.5s;}`;
|
|
354
|
+
document.head.appendChild(style);
|
|
355
|
+
document[dataField] = { globalPropsStyle: style };
|
|
356
|
+
}
|
|
357
|
+
function injectStyles() {
|
|
358
|
+
const style = document.createElement("style");
|
|
359
|
+
style.textContent = `.target-of-partial-interest::after { content: "{Press Alt+UpArrow to activate}"; display: block; color: darkgrey; font-size: 0.8em;}`;
|
|
360
|
+
document.head.appendChild(style);
|
|
361
|
+
document[dataField].globalHintStyle = style;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Initialize
|
|
365
|
+
function init() {
|
|
366
|
+
registerCustomProperties();
|
|
367
|
+
injectStyles();
|
|
368
|
+
addEventHandlers();
|
|
369
|
+
console.log(
|
|
370
|
+
`interestfor polyfill installed (native: ${nativeSupported}).`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (document.readyState === "complete") {
|
|
374
|
+
init();
|
|
375
|
+
} else {
|
|
376
|
+
window.addEventListener("load", init);
|
|
377
|
+
}
|
|
378
|
+
})();
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
!function(){let t="interest",e="loseinterest",r="--interest-show-delay",a="--interest-hide-delay",n="__interestForData",i="__interestForTargetData";if(window.interestForPolyfillInstalled)return;window.interestForPolyfillInstalled=!0;let s=HTMLButtonElement.prototype.hasOwnProperty("interestForElement");if(s&&!window.interestForUsePolyfillAlways)return;if(s){let l=t=>{t.isTrusted&&(t.preventDefault(),t.stopImmediatePropagation())};document.body.addEventListener(t,l,{capture:!0}),document.body.addEventListener(e,l,{capture:!0})}let o={NoInterest:"none",PartialInterest:"partial",FullInterest:"full"},d={Hover:"hover",DeHover:"dehover",Focus:"focus",Blur:"blur"};function u(t,e,r){if(!t||!e||!t.isConnected||p(t)!==e||r===o.NoInterest&&f(e)!==t)return!1;if(r!==o.NoInterest){let a=f(e);if(a){if(a===t)return a[n].clearLostTask(),!1;if(!u(a,e,o.NoInterest)||!t.isConnected||p(t)!==e)return!1}return h(t,r)}return v(t),!0}function c(t){let e=1e3*b(t,a);isFinite(e)&&!(e<0)&&(t[n].clearLostTask(),t[n].lostTimer=setTimeout(()=>{u(t,p(t),o.NoInterest)},e))}function f(t){let e=t[i]?.invoker||null;return e&&e[n]?.state!==o.NoInterest?e:null}function p(t){let e=t.getAttribute("interestfor");return document.getElementById(e)}function b(t,e){let r=getComputedStyle(t).getPropertyValue(e).trim(),a=r.match(/^([\d.]+)s$/);return a?parseFloat(a[1]):parseFloat(r)||0}function h(e,r){let a=e[n],s=p(e);switch(r){case o.PartialInterest:if(a.state===o.NoInterest){if(!s.dispatchEvent(new Event(t)))return;try{s.showPopover()}catch{}}a.state=o.PartialInterest,s[i]||(s[i]={}),s[i].invoker=e,e.classList.add("has-partial-interest","has-interest"),s.classList.add("target-of-partial-interest","target-of-interest"),function t(e){e.querySelectorAll(y).forEach(t=>{if(t.hasAttribute("data-original-tabindex"))return;let e=t.getAttribute("tabindex");t.setAttribute("data-original-tabindex",null===e?"none":e),t.setAttribute("tabindex","-1")})}(s);break;case o.FullInterest:if(a.state!==o.PartialInterest){if(!s.dispatchEvent(new Event(t)))return;try{s.showPopover()}catch{}}a.state=o.FullInterest,s[i]||(s[i]={}),s[i].invoker=e,e.classList.remove("has-partial-interest"),e.classList.add("has-interest"),s.classList.remove("target-of-partial-interest"),s.classList.add("target-of-interest"),g(s)}return!0}function v(t){let r=t[n];if(clearTimeout(r.gainedTimer),clearTimeout(r.lostTimer),r.state!==o.NoInterest){let a=p(t);if(!a.dispatchEvent(new Event(e)))return;try{a.hidePopover()}catch{}a[i]=null,t.classList.remove("has-partial-interest","has-interest"),a.classList.remove("target-of-partial-interest","target-of-interest"),g(a),r.state=o.NoInterest}}let y='a[href],area[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),iframe,object,embed,[contenteditable],[tabindex]:not([tabindex="-1"])';function g(t){t.querySelectorAll("[data-original-tabindex]").forEach(t=>{let e=t.getAttribute("data-original-tabindex");"none"===e?t.removeAttribute("tabindex"):t.setAttribute("tabindex",e),t.removeAttribute("data-original-tabindex")})}function m(t,e){if(!t.isConnected)return;let a=p(t);if(!a)return;let i=t[n];i||(t[n]={state:o.NoInterest,gainedTimer:null,lostTimer:null,clearGainedTask(){clearTimeout(this.gainedTimer)},clearLostTask(){clearTimeout(this.lostTimer)}},i=t[n]);let s=f(t);if(e===d.Hover||e===d.Focus){i.clearLostTask&&i.clearLostTask(),s&&(s[n].clearLostTask(),s[n].state===o.PartialInterest&&h(s,o.FullInterest));let l=e===d.Focus&&a.matches(":has("+y+")");!function t(e,a){let i=1e3*b(e,r);isFinite(i)&&!(i<0)&&(e[n].clearGainedTask(),e[n].gainedTimer=setTimeout(()=>{u(e,p(e),a)},i))}(t,l?o.PartialInterest:o.FullInterest)}else i.clearGainedTask&&i.clearGainedTask(),i.state!==o.NoInterest&&c(t),s&&(s[n].clearGainedTask(),e!==d.Blur&&t.matches(":hover")||c(s))}function I(){!function t(){let e=document.createElement("style");e.textContent=`@property ${r} {syntax: "<time>"; inherits: false; initial-value: 0.5s;}
|
|
2
|
+
@property ${a} {syntax: "<time>"; inherits: false; initial-value: 0.5s;}`,document.head.appendChild(e),document[n]={globalPropsStyle:e}}(),function t(){let e=document.createElement("style");e.textContent='.target-of-partial-interest::after { content: "{Press Alt+UpArrow to activate}"; display: block; color: darkgrey; font-size: 0.8em;}',document.head.appendChild(e),document[n].globalHintStyle=e}(),document.body.addEventListener("mouseover",t=>m(t.target,d.Hover)),document.body.addEventListener("mouseout",t=>m(t.target,d.DeHover)),document.body.addEventListener("focusin",t=>m(t.target,d.Focus)),document.body.addEventListener("focusout",t=>m(t.target,d.Blur)),document.body.addEventListener("keydown",t=>{let e=t.target[n];e&&e.state===o.PartialInterest&&("ArrowUp"===t.key&&t.altKey&&e.state===o.PartialInterest&&(t.preventDefault(),h(t.target,o.FullInterest)),"Escape"===t.key&&e.state!==o.NoInterest&&(t.preventDefault(),v(t.target)))}),console.log(`interestfor polyfill installed (native: ${s}).`)}"complete"===document.readyState?I():window.addEventListener("load",I)}();
|
package/test/test.html
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
|
|
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>
|
|
28
|
+
|
|
29
|
+
<div id=results class="hide">
|
|
30
|
+
<h3>Results:</h3>
|
|
31
|
+
<h3 id="summary"></h3>
|
|
32
|
+
<table id=log></table>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
.pass {
|
|
37
|
+
background-color: lightgreen;
|
|
38
|
+
}
|
|
39
|
+
.fail {
|
|
40
|
+
background-color: red;
|
|
41
|
+
}
|
|
42
|
+
html,body {
|
|
43
|
+
width: 100%;
|
|
44
|
+
}
|
|
45
|
+
.test-element {
|
|
46
|
+
width:300px;
|
|
47
|
+
height:100px;
|
|
48
|
+
margin: 10px 0;
|
|
49
|
+
border: 1px solid red;
|
|
50
|
+
}
|
|
51
|
+
.test-element:not([popover]) {
|
|
52
|
+
display:block;
|
|
53
|
+
}
|
|
54
|
+
.hide {
|
|
55
|
+
visibility: hidden;
|
|
56
|
+
}
|
|
57
|
+
body.testing :not(.test_content,.test_content *) {
|
|
58
|
+
display:none;
|
|
59
|
+
}
|
|
60
|
+
:focus-visible {
|
|
61
|
+
outline: 3px solid red;
|
|
62
|
+
}
|
|
63
|
+
#focus_warning {
|
|
64
|
+
inset:auto;
|
|
65
|
+
top:50px;
|
|
66
|
+
left:50px;
|
|
67
|
+
width: 600px;
|
|
68
|
+
height: 400px;
|
|
69
|
+
border: 2px solid red;
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
72
|
+
|
|
73
|
+
<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;
|
|
82
|
+
}
|
|
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)';
|
|
93
|
+
}
|
|
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;
|
|
108
|
+
}
|
|
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);
|
|
118
|
+
}
|
|
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;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
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);
|
|
140
|
+
}
|
|
141
|
+
|
|
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;
|
|
146
|
+
}
|
|
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;
|
|
150
|
+
}
|
|
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
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (val) {
|
|
163
|
+
assert_true(true,description,logSuccess);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
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));
|
|
203
|
+
switch (type) {
|
|
204
|
+
case 'hover':
|
|
205
|
+
const mouseLeft = new Promise(resolve => element.addEventListener('mouseleave',resolve, {once:true}));
|
|
206
|
+
await Promise.race([timeout,mouseLeft]);
|
|
207
|
+
break;
|
|
208
|
+
case 'focus':
|
|
209
|
+
const focusLeft = new Promise(resolve => element.addEventListener('blur',resolve, {once:true}));
|
|
210
|
+
await Promise.race([timeout,focusLeft]);
|
|
211
|
+
break;
|
|
212
|
+
default:
|
|
213
|
+
notreached('invalid type');
|
|
214
|
+
}
|
|
215
|
+
done = gotTimeout;
|
|
216
|
+
if (!done) {
|
|
217
|
+
element.innerText = goBackText;
|
|
218
|
+
await timeout;
|
|
219
|
+
}
|
|
220
|
+
} while (!done);
|
|
221
|
+
controller.abort();
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve,0));
|
|
223
|
+
return {midEvents, midTargetOpen, endEvents: [...events], endTargetOpen: target.matches(':popover-open')};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function testOne(invoker, target, type, description) {
|
|
227
|
+
assert_true(true,'---' + description,true);
|
|
228
|
+
assert_false(target.matches(':popover-open'),'popover should start closed',true);
|
|
229
|
+
invoker.style.setProperty('--interest-show-delay',hoverTimeMilliseconds + 'ms');
|
|
230
|
+
invoker.style.setProperty('--interest-hide-delay',hoverTimeMilliseconds + 'ms');
|
|
231
|
+
invoker.classList.remove('hide');
|
|
232
|
+
|
|
233
|
+
// Show interest in the invoker
|
|
234
|
+
let {midEvents, midTargetOpen, endEvents, endTargetOpen} = await waitForHoverOrFocus(type,invoker,target,hoverTimeMilliseconds);
|
|
235
|
+
const startString = type=='hover' ? 'hovering' : 'focusing';
|
|
236
|
+
assert_false(midTargetOpen,`popover should *not* be immediately invoked by ${startString}`,true);
|
|
237
|
+
assert_array_equals(midEvents,[],`No events should be immediately fired by ${startString}`,true);
|
|
238
|
+
assert_true(endTargetOpen,`popover should invoked after delay after ${startString}`,true);
|
|
239
|
+
assert_array_equals(endEvents,['interest'],`The interest event should be fired after ${startString}`,true);
|
|
240
|
+
invoker.classList.add('hide');
|
|
241
|
+
unrelated.classList.remove('hide');
|
|
242
|
+
|
|
243
|
+
// Lose interest in the invoker
|
|
244
|
+
({midEvents, midTargetOpen, endEvents, endTargetOpen} = await waitForHoverOrFocus(type,unrelated,target,hoverTimeMilliseconds));
|
|
245
|
+
const endString = type=='hover' ? 'de-hovering' : 'blurring';
|
|
246
|
+
assert_true(midTargetOpen,`popover should *not* be immediately hidden by ${endString}`,true);
|
|
247
|
+
assert_array_equals(midEvents,[],`No events should be immediately fired by ${endString}`,true);
|
|
248
|
+
assert_false(endTargetOpen,`popover should hidden after delay after ${endString}`,true);
|
|
249
|
+
assert_array_equals(endEvents,['loseinterest'],`The loseinterest event should be fired after ${endString}`,true);
|
|
250
|
+
invoker.classList.add('hide');
|
|
251
|
+
unrelated.classList.add('hide');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function showFocusWarning(type) {
|
|
255
|
+
const popover = document.getElementById('focus_warning');
|
|
256
|
+
const typeStr = document.getElementById('type_str');
|
|
257
|
+
switch (type) {
|
|
258
|
+
case 'hover':
|
|
259
|
+
typeStr.innerText = "THE MOUSE to activate elements";
|
|
260
|
+
break;
|
|
261
|
+
case 'focus':
|
|
262
|
+
typeStr.innerText = "THE KEYBOARD to activate elements (and move the mouse away)";
|
|
263
|
+
break;
|
|
264
|
+
default:
|
|
265
|
+
notreached('invalid type');
|
|
266
|
+
}
|
|
267
|
+
popover.showPopover();
|
|
268
|
+
await new Promise(resolve => setTimeout(resolve,3000));
|
|
269
|
+
popover.hidePopover();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function testHarness() {
|
|
273
|
+
document.body.classList.add('testing');
|
|
274
|
+
|
|
275
|
+
for(type of ['hover','focus']) {
|
|
276
|
+
await showFocusWarning(type);
|
|
277
|
+
await testOne(already_present,already_present_target,type,'Pre-existing elements');
|
|
278
|
+
|
|
279
|
+
// Dynamically add elements:
|
|
280
|
+
const targets = document.getElementById('targets');
|
|
281
|
+
const dynamic_invoker = document.createElement('a');
|
|
282
|
+
dynamic_invoker.href = 'foo';
|
|
283
|
+
dynamic_invoker.setAttribute('interestfor','dynamic_target');
|
|
284
|
+
dynamic_invoker.classList='hide test-element';
|
|
285
|
+
const dynamic_target = document.createElement('div');
|
|
286
|
+
dynamic_target.popover = 'auto';
|
|
287
|
+
dynamic_target.id = 'dynamic_target';
|
|
288
|
+
dynamic_target.classList = 'hide';
|
|
289
|
+
targets.append(dynamic_invoker,dynamic_target);
|
|
290
|
+
await testOne(dynamic_invoker,dynamic_target,type,'Dynamically-added elements');
|
|
291
|
+
dynamic_invoker.remove();
|
|
292
|
+
dynamic_target.remove();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Test complete
|
|
296
|
+
targets.style.display = 'none';
|
|
297
|
+
const summary = document.getElementById('summary');
|
|
298
|
+
summary.innerText = any_failures ? 'FAILURE' : 'PASS';
|
|
299
|
+
summary.classList = any_failures ? 'fail' : 'pass';
|
|
300
|
+
document.body.classList.remove('testing');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
</script>
|