interestfor 1.0.3 → 1.0.5
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/COPYRIGHT +7 -0
- package/README.md +37 -29
- package/build.sh +20 -0
- package/package.json +1 -1
- package/src/interestfor.js +221 -133
- package/src/interestfor.min.js +8 -2
- package/test/basic_test.html +17 -1
- package/test/test.html +30 -4
package/COPYRIGHT
ADDED
package/README.md
CHANGED
|
@@ -29,18 +29,21 @@ upon receiving and losing interest.
|
|
|
29
29
|
Again, this is a quick summary. For the complete details, please [read the
|
|
30
30
|
explainer](https://open-ui.org/components/interest-invokers.explainer).
|
|
31
31
|
|
|
32
|
-
## Touchscreen
|
|
33
|
-
|
|
34
|
-
This polyfill
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
32
|
+
## Touchscreen support
|
|
33
|
+
|
|
34
|
+
This polyfill provides limited touchscreen support. On a touchscreen,
|
|
35
|
+
long-pressing a `<button>` element that has an `interestfor` attribute will show
|
|
36
|
+
interest in the target.
|
|
37
|
+
|
|
38
|
+
However, long-pressing a link (`<a>` element) is **not** supported. This is
|
|
39
|
+
because on today's browsers, long-pressing a link element brings up a useful
|
|
40
|
+
context menu that contains items like "Share" and "Open in new tab". In order
|
|
41
|
+
for the polyfill to support interest on links, that context menu would need to
|
|
42
|
+
be surpressed completely. There is currently no way to provide access to the
|
|
43
|
+
long-press context menu and *also* provide a way to show interest. Since the
|
|
44
|
+
context menu contains items that users often use, eliminating access to the
|
|
45
|
+
context menu is not what most people want. So this polyfill will not support
|
|
46
|
+
long-press on links.
|
|
44
47
|
|
|
45
48
|
This is one of the primary motivations for Chromium championing this as a
|
|
46
49
|
native API in all browsers: to support all users, including those using
|
|
@@ -54,7 +57,7 @@ browsers:
|
|
|
54
57
|
|
|
55
58
|
## Implementation
|
|
56
59
|
The polyfill adds event listeners to document.body that monitor for hover
|
|
57
|
-
or keyboard events relevant
|
|
60
|
+
or keyboard events relevant for the `interestfor` API. When interesting
|
|
58
61
|
events happen to elements with the `interestfor` attribute or elements that
|
|
59
62
|
are the targets of those elements, it fires `interest` and `loseinterest`
|
|
60
63
|
events on the target of the `interestfor` attribute. If the
|
|
@@ -69,10 +72,22 @@ native feature:
|
|
|
69
72
|
|
|
70
73
|
```css
|
|
71
74
|
[interestfor] {
|
|
72
|
-
interest-
|
|
73
|
-
--interest-
|
|
74
|
-
interest-
|
|
75
|
-
--interest-
|
|
75
|
+
interest-delay-start: 400ms;
|
|
76
|
+
--interest-delay-start: 400ms;
|
|
77
|
+
interest-delay-end: 200ms;
|
|
78
|
+
--interest-delay-end: 200ms;
|
|
79
|
+
}
|
|
80
|
+
/* Or, use the shorthand: */
|
|
81
|
+
[interestfor] {
|
|
82
|
+
interest-delay: 400ms 200ms;
|
|
83
|
+
--interest-delay: 400ms 200ms;
|
|
84
|
+
}
|
|
85
|
+
/* The keyword `normal` can also be used to specify the default delay: */
|
|
86
|
+
[interestfor].no-show-delay {
|
|
87
|
+
interest-delay-start: 0s;
|
|
88
|
+
--interest-delay-start: 0s;
|
|
89
|
+
interest-delay-end: normal;
|
|
90
|
+
--interest-delay-end: normal;
|
|
76
91
|
}
|
|
77
92
|
```
|
|
78
93
|
|
|
@@ -80,19 +95,19 @@ native feature:
|
|
|
80
95
|
implemented as regular class names, so again add both to your stylesheet:
|
|
81
96
|
|
|
82
97
|
```css
|
|
83
|
-
:is([interestfor]:
|
|
84
|
-
[interestfor].
|
|
98
|
+
:is([interestfor]:interest-source),
|
|
99
|
+
[interestfor].interest-source {
|
|
85
100
|
/* invoker styles */
|
|
86
101
|
}
|
|
87
|
-
:is([popover]:target
|
|
88
|
-
[popover].target
|
|
102
|
+
:is([popover]:interest-target),
|
|
103
|
+
[popover].interest-target {
|
|
89
104
|
/* target popover styles */
|
|
90
105
|
}
|
|
91
106
|
```
|
|
92
107
|
|
|
93
108
|
Note the `:is()` wrapped around the "normal" selector for the real pseudo
|
|
94
109
|
class names. This is to avoid the entire selector being invalidated if the
|
|
95
|
-
browser does not recognize the native pseudo class (e.g. `:
|
|
110
|
+
browser does not recognize the native pseudo class (e.g. `:interest-source`).
|
|
96
111
|
|
|
97
112
|
|
|
98
113
|
## Tests
|
|
@@ -104,13 +119,6 @@ polyfill (optionally minified) or the native feature. You can run tests directly
|
|
|
104
119
|
from this repo,
|
|
105
120
|
[here](https://mfreed7.github.io/interestfor/test/test.html).
|
|
106
121
|
|
|
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
122
|
## Improvements / Bugs
|
|
115
123
|
|
|
116
124
|
If you find issues with the polyfill, feel free to file them [here](https://github.com/mfreed7/interestfor/issues).
|
package/build.sh
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Exit on any error
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
BASE_DIR=$(pwd)
|
|
7
|
+
SRC_DIR="${BASE_DIR}/src"
|
|
8
|
+
BUILD_DIR="${BASE_DIR}/dist"
|
|
9
|
+
SOURCE_FILE="${SRC_DIR}/interestfor.js"
|
|
10
|
+
MINIFIED_FILE="${BUILD_DIR}/interestfor.min.js"
|
|
11
|
+
OUTPUT_FILE="${SRC_DIR}/interestfor.min.js"
|
|
12
|
+
|
|
13
|
+
# Minify the combined file
|
|
14
|
+
terser "${SOURCE_FILE}" --compress --mangle --output "${MINIFIED_FILE}"
|
|
15
|
+
|
|
16
|
+
cat COPYRIGHT "${MINIFIED_FILE}" > "${OUTPUT_FILE}"
|
|
17
|
+
|
|
18
|
+
rm "${MINIFIED_FILE}"
|
|
19
|
+
|
|
20
|
+
echo "--- Minified output to ${OUTPUT_FILE} ---"
|
package/package.json
CHANGED
package/src/interestfor.js
CHANGED
|
@@ -11,19 +11,21 @@
|
|
|
11
11
|
const attributeName = "interestfor";
|
|
12
12
|
const interestEventName = "interest";
|
|
13
13
|
const loseInterestEventName = "loseinterest";
|
|
14
|
-
const
|
|
15
|
-
const
|
|
14
|
+
const interestDelayStartProp = "--interest-delay-start";
|
|
15
|
+
const interestDelayEndProp = "--interest-delay-end";
|
|
16
|
+
const interestDelayProp = "--interest-delay";
|
|
16
17
|
const dataField = "__interestForData";
|
|
17
18
|
const targetDataField = "__interestForTargetData";
|
|
19
|
+
const invokersWithInterest = new Set();
|
|
20
|
+
let touchInProgress = false;
|
|
18
21
|
|
|
19
22
|
// Feature detection
|
|
20
23
|
if (window.interestForPolyfillInstalled) {
|
|
21
24
|
return;
|
|
22
25
|
}
|
|
23
26
|
window.interestForPolyfillInstalled = true;
|
|
24
|
-
const nativeSupported =
|
|
25
|
-
"interestForElement"
|
|
26
|
-
);
|
|
27
|
+
const nativeSupported =
|
|
28
|
+
HTMLButtonElement.prototype.hasOwnProperty("interestForElement");
|
|
27
29
|
if (nativeSupported && !window.interestForUsePolyfillAlways) {
|
|
28
30
|
return;
|
|
29
31
|
}
|
|
@@ -34,15 +36,18 @@
|
|
|
34
36
|
e.preventDefault();
|
|
35
37
|
e.stopImmediatePropagation();
|
|
36
38
|
}
|
|
37
|
-
}
|
|
38
|
-
document.body.addEventListener(interestEventName,cancel,{
|
|
39
|
-
|
|
39
|
+
};
|
|
40
|
+
document.body.addEventListener(interestEventName, cancel, {
|
|
41
|
+
capture: true,
|
|
42
|
+
});
|
|
43
|
+
document.body.addEventListener(loseInterestEventName, cancel, {
|
|
44
|
+
capture: true,
|
|
45
|
+
});
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
// Enum-like state and source
|
|
43
49
|
const InterestState = {
|
|
44
50
|
NoInterest: "none",
|
|
45
|
-
PartialInterest: "partial",
|
|
46
51
|
FullInterest: "full",
|
|
47
52
|
};
|
|
48
53
|
const Source = {
|
|
@@ -50,6 +55,7 @@
|
|
|
50
55
|
DeHover: "dehover",
|
|
51
56
|
Focus: "focus",
|
|
52
57
|
Blur: "blur",
|
|
58
|
+
Touch: "touch",
|
|
53
59
|
};
|
|
54
60
|
|
|
55
61
|
// Gain or lose interest
|
|
@@ -78,7 +84,10 @@
|
|
|
78
84
|
return false;
|
|
79
85
|
}
|
|
80
86
|
// re-check preconditions
|
|
81
|
-
if (
|
|
87
|
+
if (
|
|
88
|
+
!invoker.isConnected ||
|
|
89
|
+
GetInterestForTarget(invoker) !== target
|
|
90
|
+
) {
|
|
82
91
|
return false;
|
|
83
92
|
}
|
|
84
93
|
}
|
|
@@ -94,7 +103,7 @@
|
|
|
94
103
|
|
|
95
104
|
// Schedule tasks
|
|
96
105
|
function ScheduleInterestGainedTask(invoker, newState) {
|
|
97
|
-
const delay = getDelaySeconds(invoker,
|
|
106
|
+
const delay = getDelaySeconds(invoker, interestDelayStartProp) * 1000;
|
|
98
107
|
if (!isFinite(delay) || delay < 0) {
|
|
99
108
|
return;
|
|
100
109
|
}
|
|
@@ -105,7 +114,7 @@
|
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
function ScheduleInterestLostTask(invoker) {
|
|
108
|
-
const delay = getDelaySeconds(invoker,
|
|
117
|
+
const delay = getDelaySeconds(invoker, interestDelayEndProp) * 1000;
|
|
109
118
|
if (!isFinite(delay) || delay < 0) {
|
|
110
119
|
return;
|
|
111
120
|
}
|
|
@@ -131,13 +140,63 @@
|
|
|
131
140
|
return document.getElementById(id);
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
function onPopoverToggle(e) {
|
|
144
|
+
const popover = e.target;
|
|
145
|
+
if (e.newState === 'closed') {
|
|
146
|
+
const invoker = popover[targetDataField]?.invoker;
|
|
147
|
+
if (invoker) {
|
|
148
|
+
GainOrLoseInterest(invoker, popover, InterestState.NoInterest);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseTimeValue(val) {
|
|
154
|
+
const s = String(val).trim();
|
|
155
|
+
const m_s = s.match(/^([\d.]+)s$/);
|
|
156
|
+
if (m_s) {
|
|
157
|
+
return parseFloat(m_s[1]);
|
|
158
|
+
}
|
|
159
|
+
const m_ms = s.match(/^([\d.]+)ms$/);
|
|
160
|
+
if (m_ms) {
|
|
161
|
+
return parseFloat(m_ms[1]) / 1000;
|
|
162
|
+
}
|
|
163
|
+
return parseFloat(s) || 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
function getDelaySeconds(el, prop) {
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
167
|
+
const style = getComputedStyle(el);
|
|
168
|
+
const longhandValue = style.getPropertyValue(prop).trim();
|
|
169
|
+
|
|
170
|
+
// Longhand has priority. If it's not 'normal', use it.
|
|
171
|
+
if (longhandValue.toLowerCase() !== 'normal') {
|
|
172
|
+
return parseTimeValue(longhandValue);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Longhand is 'normal', so check shorthand. This isn't exactly │
|
|
176
|
+
// correct, since the longhand might have been explicitly set to │
|
|
177
|
+
// 'normal'.
|
|
178
|
+
const shorthand = style.getPropertyValue(interestDelayProp).trim();
|
|
179
|
+
if (shorthand && shorthand.toLowerCase() !== 'normal') {
|
|
180
|
+
const parts = shorthand.split(/\s+/).filter((s) => s.length > 0);
|
|
181
|
+
if (parts.length > 0) {
|
|
182
|
+
const firstValue = parts[0];
|
|
183
|
+
const secondValue = parts.length > 1 ? parts[1] : firstValue;
|
|
184
|
+
let valueFromShorthand;
|
|
185
|
+
if (prop === interestDelayStartProp) {
|
|
186
|
+
valueFromShorthand = firstValue;
|
|
187
|
+
} else { // prop === interestDelayEndProp
|
|
188
|
+
valueFromShorthand = secondValue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// If the value from the shorthand is not 'normal', use it.
|
|
192
|
+
if (valueFromShorthand.toLowerCase() !== 'normal') {
|
|
193
|
+
return parseTimeValue(valueFromShorthand);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
139
196
|
}
|
|
140
|
-
|
|
197
|
+
|
|
198
|
+
// If we got here, the effective value is 'normal'. Return the default.
|
|
199
|
+
return prop === interestDelayStartProp ? 0.5 : 0.25;
|
|
141
200
|
}
|
|
142
201
|
|
|
143
202
|
// Actual state transitions
|
|
@@ -145,47 +204,38 @@
|
|
|
145
204
|
const data = invoker[dataField];
|
|
146
205
|
const target = GetInterestForTarget(invoker);
|
|
147
206
|
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
207
|
case InterestState.FullInterest:
|
|
170
|
-
if (data.state !== InterestState.
|
|
171
|
-
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
try {
|
|
175
|
-
target.showPopover();
|
|
176
|
-
} catch {}
|
|
208
|
+
if (data.state !== InterestState.NoInterest) {
|
|
209
|
+
throw new Error("Invalid state");
|
|
177
210
|
}
|
|
211
|
+
target.dispatchEvent(new Event(interestEventName));
|
|
212
|
+
try {
|
|
213
|
+
target.showPopover({ source: invoker });
|
|
214
|
+
} catch {}
|
|
178
215
|
data.state = InterestState.FullInterest;
|
|
179
216
|
if (!target[targetDataField]) {
|
|
180
217
|
target[targetDataField] = {};
|
|
181
218
|
}
|
|
182
219
|
target[targetDataField].invoker = invoker;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
220
|
+
if (target.hasAttribute('popover')) {
|
|
221
|
+
target[targetDataField].toggleListener = onPopoverToggle;
|
|
222
|
+
target.addEventListener('toggle', onPopoverToggle);
|
|
223
|
+
}
|
|
224
|
+
invokersWithInterest.add(invoker);
|
|
225
|
+
invoker.classList.add("interest-source");
|
|
226
|
+
target.classList.add("interest-target");
|
|
227
|
+
if (!isPlainHint(target)) {
|
|
228
|
+
invoker.setAttribute("aria-expanded", "true");
|
|
229
|
+
}
|
|
230
|
+
const anchorName = `--interest-anchor-${Math.random()
|
|
231
|
+
.toString(36)
|
|
232
|
+
.substring(2)}`;
|
|
233
|
+
invoker.style.anchorName = anchorName;
|
|
234
|
+
target.style.positionAnchor = anchorName;
|
|
235
|
+
data.anchorName = anchorName;
|
|
188
236
|
break;
|
|
237
|
+
default:
|
|
238
|
+
throw new Error("Invalid state");
|
|
189
239
|
}
|
|
190
240
|
return true;
|
|
191
241
|
}
|
|
@@ -196,19 +246,25 @@
|
|
|
196
246
|
clearTimeout(data.lostTimer);
|
|
197
247
|
if (data.state !== InterestState.NoInterest) {
|
|
198
248
|
const target = GetInterestForTarget(invoker);
|
|
199
|
-
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
249
|
+
target.dispatchEvent(new Event(loseInterestEventName));
|
|
202
250
|
try {
|
|
203
251
|
target.hidePopover();
|
|
204
252
|
} catch {}
|
|
253
|
+
if (target[targetDataField]?.toggleListener) {
|
|
254
|
+
target.removeEventListener('toggle', target[targetDataField].toggleListener);
|
|
255
|
+
}
|
|
205
256
|
target[targetDataField] = null;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
257
|
+
invokersWithInterest.delete(invoker);
|
|
258
|
+
invoker.classList.remove("interest-source");
|
|
259
|
+
target.classList.remove("interest-target");
|
|
260
|
+
if (!isPlainHint(target)) {
|
|
261
|
+
invoker.setAttribute("aria-expanded", "false");
|
|
262
|
+
}
|
|
263
|
+
if (data.anchorName) {
|
|
264
|
+
invoker.style.anchorName = "";
|
|
265
|
+
target.style.positionAnchor = "";
|
|
266
|
+
data.anchorName = null;
|
|
267
|
+
}
|
|
212
268
|
data.state = InterestState.NoInterest;
|
|
213
269
|
}
|
|
214
270
|
}
|
|
@@ -227,51 +283,93 @@
|
|
|
227
283
|
"[contenteditable]",
|
|
228
284
|
'[tabindex]:not([tabindex="-1"])',
|
|
229
285
|
].join(",");
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
286
|
+
|
|
287
|
+
// Accessibility utilities
|
|
288
|
+
function isPlainHint(target) {
|
|
289
|
+
if (target.getAttribute("popover")?.toLowerCase() !== "hint") {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
// Anything focusable is not plain.
|
|
293
|
+
if (target.querySelector(focusableSelector)) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
// Common structural/semantic elements that are not focusable.
|
|
297
|
+
const structuralSelector =
|
|
298
|
+
"h1,h2,h3,h4,h5,h6,ul,ol,li,table,nav,header,footer,main,aside,article,section,form,blockquote,details,summary,dialog";
|
|
299
|
+
if (target.querySelector(structuralSelector)) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
// Check for ARIA roles.
|
|
303
|
+
const elementsWithRoles = target.querySelectorAll("[role]");
|
|
304
|
+
for (const el of elementsWithRoles) {
|
|
305
|
+
const role = el.getAttribute("role").toLowerCase();
|
|
306
|
+
// Allowed roles are those that don't add semantics for a screen reader to explore.
|
|
307
|
+
if (!["presentation", "none", "generic", "image"].includes(role)) {
|
|
308
|
+
return false;
|
|
234
309
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
el.setAttribute("tabindex", "-1");
|
|
238
|
-
});
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
239
312
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
313
|
+
|
|
314
|
+
function setupAccessibility(invoker, target) {
|
|
315
|
+
if (isPlainHint(target)) {
|
|
316
|
+
invoker.setAttribute("aria-describedby", target.id);
|
|
317
|
+
} else {
|
|
318
|
+
// Rich hint
|
|
319
|
+
invoker.setAttribute("aria-details", target.id);
|
|
320
|
+
invoker.setAttribute("aria-expanded", "false");
|
|
321
|
+
if (!target.hasAttribute("role")) {
|
|
322
|
+
target.setAttribute("role", "tooltip");
|
|
247
323
|
}
|
|
248
|
-
|
|
249
|
-
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function initializeDataField(el) {
|
|
328
|
+
if (el[dataField]) return;
|
|
329
|
+
el[dataField] = {
|
|
330
|
+
state: InterestState.NoInterest,
|
|
331
|
+
gainedTimer: null,
|
|
332
|
+
lostTimer: null,
|
|
333
|
+
longPressTimer: null,
|
|
334
|
+
clearGainedTask() {
|
|
335
|
+
clearTimeout(this.gainedTimer);
|
|
336
|
+
},
|
|
337
|
+
clearLostTask() {
|
|
338
|
+
clearTimeout(this.lostTimer);
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
const target = GetInterestForTarget(el);
|
|
342
|
+
if (target) {
|
|
343
|
+
setupAccessibility(el, target);
|
|
344
|
+
}
|
|
250
345
|
}
|
|
251
346
|
|
|
252
347
|
function HandleInterestHoverOrFocus(el, source) {
|
|
348
|
+
if (touchInProgress) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
253
351
|
if (!el.isConnected) {
|
|
254
352
|
return;
|
|
255
353
|
}
|
|
256
354
|
const target = GetInterestForTarget(el);
|
|
257
355
|
if (!target) {
|
|
356
|
+
const containingTarget = el.closest(".interest-target");
|
|
357
|
+
if (containingTarget) {
|
|
358
|
+
const upstreamInvoker = GetInterestInvoker(containingTarget);
|
|
359
|
+
if (upstreamInvoker) {
|
|
360
|
+
if (source === Source.Hover || source === Source.Focus) {
|
|
361
|
+
upstreamInvoker[dataField].clearLostTask();
|
|
362
|
+
} else {
|
|
363
|
+
if (source === Source.Blur || !el.matches(":hover")) {
|
|
364
|
+
ScheduleInterestLostTask(upstreamInvoker);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
258
369
|
return;
|
|
259
370
|
}
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
}
|
|
371
|
+
initializeDataField(el);
|
|
372
|
+
const data = el[dataField];
|
|
275
373
|
const upstreamInvoker = GetInterestInvoker(el);
|
|
276
374
|
|
|
277
375
|
// Hover or focus
|
|
@@ -279,21 +377,8 @@
|
|
|
279
377
|
data.clearLostTask && data.clearLostTask();
|
|
280
378
|
if (upstreamInvoker) {
|
|
281
379
|
upstreamInvoker[dataField].clearLostTask();
|
|
282
|
-
if (
|
|
283
|
-
upstreamInvoker[dataField].state === InterestState.PartialInterest
|
|
284
|
-
) {
|
|
285
|
-
applyState(upstreamInvoker, InterestState.FullInterest);
|
|
286
|
-
}
|
|
287
380
|
}
|
|
288
|
-
|
|
289
|
-
source === Source.Focus &&
|
|
290
|
-
target.matches(":has(" + focusableSelector + ")");
|
|
291
|
-
ScheduleInterestGainedTask(
|
|
292
|
-
el,
|
|
293
|
-
needsPartialInterest
|
|
294
|
-
? InterestState.PartialInterest
|
|
295
|
-
: InterestState.FullInterest
|
|
296
|
-
);
|
|
381
|
+
ScheduleInterestGainedTask(el, InterestState.FullInterest);
|
|
297
382
|
} else {
|
|
298
383
|
// Dehover or blur
|
|
299
384
|
data.clearGainedTask && data.clearGainedTask();
|
|
@@ -324,51 +409,54 @@
|
|
|
324
409
|
HandleInterestHoverOrFocus(e.target, Source.Blur)
|
|
325
410
|
);
|
|
326
411
|
document.body.addEventListener("keydown", (e) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (data.state !== InterestState.PartialInterest) {
|
|
332
|
-
return;
|
|
412
|
+
if (e.key === "Escape") {
|
|
413
|
+
invokersWithInterest.forEach((invoker) => {
|
|
414
|
+
clearState(invoker);
|
|
415
|
+
});
|
|
333
416
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
417
|
+
});
|
|
418
|
+
// Touch support
|
|
419
|
+
const longPressTime = 500;
|
|
420
|
+
document.body.addEventListener("touchstart", (e) => {
|
|
421
|
+
touchInProgress = true;
|
|
422
|
+
const invoker = e.target.closest("button[interestfor]");
|
|
423
|
+
if (invoker) {
|
|
424
|
+
initializeDataField(invoker);
|
|
425
|
+
invoker[dataField].longPressTimer = setTimeout(() => {
|
|
426
|
+
GainOrLoseInterest(invoker, GetInterestForTarget(invoker), InterestState.FullInterest);
|
|
427
|
+
invoker[dataField].longPressTimer = null;
|
|
428
|
+
}, longPressTime);
|
|
341
429
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
430
|
+
});
|
|
431
|
+
const cancelLongPress = (e) => {
|
|
432
|
+
const invoker = e.target.closest("button[interestfor]");
|
|
433
|
+
if (invoker && invoker[dataField]?.longPressTimer) {
|
|
434
|
+
clearTimeout(invoker[dataField].longPressTimer);
|
|
435
|
+
invoker[dataField].longPressTimer = null;
|
|
345
436
|
}
|
|
437
|
+
};
|
|
438
|
+
document.body.addEventListener("touchend", (e) => {
|
|
439
|
+
cancelLongPress(e);
|
|
440
|
+
touchInProgress = false;
|
|
346
441
|
});
|
|
442
|
+
document.body.addEventListener("touchmove", cancelLongPress);
|
|
347
443
|
}
|
|
348
444
|
|
|
349
445
|
// CSS registration
|
|
350
446
|
function registerCustomProperties() {
|
|
351
447
|
const style = document.createElement("style");
|
|
352
|
-
style.textContent = `@property ${
|
|
353
|
-
@property ${
|
|
448
|
+
style.textContent = `@property ${interestDelayStartProp} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}
|
|
449
|
+
@property ${interestDelayEndProp} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}
|
|
450
|
+
@property ${interestDelayProp} {syntax: "[ normal | <time> ]{1,2}"; inherits: false; initial-value: normal;}`;
|
|
354
451
|
document.head.appendChild(style);
|
|
355
452
|
document[dataField] = { globalPropsStyle: style };
|
|
356
453
|
}
|
|
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
454
|
|
|
364
455
|
// Initialize
|
|
365
456
|
function init() {
|
|
366
457
|
registerCustomProperties();
|
|
367
|
-
injectStyles();
|
|
368
458
|
addEventHandlers();
|
|
369
|
-
console.log(
|
|
370
|
-
`interestfor polyfill installed (native: ${nativeSupported}).`
|
|
371
|
-
);
|
|
459
|
+
console.log(`interestfor polyfill installed (native: ${nativeSupported}).`);
|
|
372
460
|
}
|
|
373
461
|
if (document.readyState === "complete") {
|
|
374
462
|
init();
|
package/src/interestfor.min.js
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
+
|
|
8
|
+
!function(){const e="interest",t="loseinterest",n="--interest-delay-start",r="--interest-delay-end",o="--interest-delay",i="__interestForData",s="__interestForTargetData",a=new Set;let l=!1;if(window.interestForPolyfillInstalled)return;window.interestForPolyfillInstalled=!0;const c=HTMLButtonElement.prototype.hasOwnProperty("interestForElement");if(c&&!window.interestForUsePolyfillAlways)return;if(c){const n=e=>{e.isTrusted&&(e.preventDefault(),e.stopImmediatePropagation())};document.body.addEventListener(e,n,{capture:!0}),document.body.addEventListener(t,n,{capture:!0})}const d="none",u="full",m="hover",f="dehover",h="focus",g="blur";function y(t,n,r){if(!t||!n)return!1;if(!t.isConnected||b(t)!==n||r===d&&v(n)!==t)return!1;if(r!==d){const o=v(n);if(o){if(o===t)return o[i].clearLostTask(),!1;if(!y(o,n,d))return!1;if(!t.isConnected||b(t)!==n)return!1}return function(t,n){const r=t[i],o=b(t);if(n!==u)throw new Error("Invalid state");{if(r.state!==d)throw new Error("Invalid state");o.dispatchEvent(new Event(e));try{o.showPopover({source:t})}catch{}r.state=u,o[s]||(o[s]={}),o[s].invoker=t,o.hasAttribute("popover")&&(o[s].toggleListener=T,o.addEventListener("toggle",T)),a.add(t),t.classList.add("interest-source"),o.classList.add("interest-target"),P(o)||t.setAttribute("aria-expanded","true");const n=`--interest-anchor-${Math.random().toString(36).substring(2)}`;t.style.anchorName=n,o.style.positionAnchor=n,r.anchorName=n}return!0}(t,r)}return E(t),!0}function p(e){const t=1e3*w(e,r);!isFinite(t)||t<0||(e[i].clearLostTask(),e[i].lostTimer=setTimeout(()=>{y(e,b(e),d)},t))}function v(e){const t=e[s]?.invoker||null;return t&&t[i]?.state!==d?t:null}function b(e){const t=e.getAttribute("interestfor");return document.getElementById(t)}function T(e){const t=e.target;if("closed"===e.newState){const e=t[s]?.invoker;e&&y(e,t,d)}}function L(e){const t=String(e).trim(),n=t.match(/^([\d.]+)s$/);if(n)return parseFloat(n[1]);const r=t.match(/^([\d.]+)ms$/);return r?parseFloat(r[1])/1e3:parseFloat(t)||0}function w(e,t){const r=getComputedStyle(e),i=r.getPropertyValue(t).trim();if("normal"!==i.toLowerCase())return L(i);const s=r.getPropertyValue(o).trim();if(s&&"normal"!==s.toLowerCase()){const e=s.split(/\s+/).filter(e=>e.length>0);if(e.length>0){const r=e[0],o=e.length>1?e[1]:r;let i;if(i=t===n?r:o,"normal"!==i.toLowerCase())return L(i)}}return t===n?.5:.25}function E(e){const n=e[i];if(clearTimeout(n.gainedTimer),clearTimeout(n.lostTimer),n.state!==d){const r=b(e);r.dispatchEvent(new Event(t));try{r.hidePopover()}catch{}r[s]?.toggleListener&&r.removeEventListener("toggle",r[s].toggleListener),r[s]=null,a.delete(e),e.classList.remove("interest-source"),r.classList.remove("interest-target"),P(r)||e.setAttribute("aria-expanded","false"),n.anchorName&&(e.style.anchorName="",r.style.positionAnchor="",n.anchorName=null),n.state=d}}const k=["a[href]","area[href]","input:not([disabled])","select:not([disabled])","textarea:not([disabled])","button:not([disabled])","iframe","object","embed","[contenteditable]",'[tabindex]:not([tabindex="-1"])'].join(",");function P(e){if("hint"!==e.getAttribute("popover")?.toLowerCase())return!1;if(e.querySelector(k))return!1;if(e.querySelector("h1,h2,h3,h4,h5,h6,ul,ol,li,table,nav,header,footer,main,aside,article,section,form,blockquote,details,summary,dialog"))return!1;const t=e.querySelectorAll("[role]");for(const e of t){const t=e.getAttribute("role").toLowerCase();if(!["presentation","none","generic","image"].includes(t))return!1}return!0}function A(e){if(e[i])return;e[i]={state:d,gainedTimer:null,lostTimer:null,longPressTimer:null,clearGainedTask(){clearTimeout(this.gainedTimer)},clearLostTask(){clearTimeout(this.lostTimer)}};const t=b(e);t&&function(e,t){P(t)?e.setAttribute("aria-describedby",t.id):(e.setAttribute("aria-details",t.id),e.setAttribute("aria-expanded","false"),t.hasAttribute("role")||t.setAttribute("role","tooltip"))}(e,t)}function C(e,t){if(l)return;if(!e.isConnected)return;if(!b(e)){const n=e.closest(".interest-target");if(n){const r=v(n);r&&(t===m||t===h?r[i].clearLostTask():t!==g&&e.matches(":hover")||p(r))}return}A(e);const r=e[i],o=v(e);t===m||t===h?(r.clearLostTask&&r.clearLostTask(),o&&o[i].clearLostTask(),function(e,t){const r=1e3*w(e,n);!isFinite(r)||r<0||(e[i].clearGainedTask(),e[i].gainedTimer=setTimeout(()=>{y(e,b(e),t)},r))}(e,u)):(r.clearGainedTask&&r.clearGainedTask(),r.state!==d&&p(e),o&&(o[i].clearGainedTask(),t!==g&&e.matches(":hover")||p(o)))}function F(){!function(){const e=document.createElement("style");e.textContent=`@property ${n} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}\n @property ${r} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}\n @property ${o} {syntax: "[ normal | <time> ]{1,2}"; inherits: false; initial-value: normal;}`,document.head.appendChild(e),document[i]={globalPropsStyle:e}}(),function(){document.body.addEventListener("mouseover",e=>C(e.target,m)),document.body.addEventListener("mouseout",e=>C(e.target,f)),document.body.addEventListener("focusin",e=>C(e.target,h)),document.body.addEventListener("focusout",e=>C(e.target,g)),document.body.addEventListener("keydown",e=>{"Escape"===e.key&&a.forEach(e=>{E(e)})}),document.body.addEventListener("touchstart",e=>{l=!0;const t=e.target.closest("button[interestfor]");t&&(A(t),t[i].longPressTimer=setTimeout(()=>{y(t,b(t),u),t[i].longPressTimer=null},500))});const e=e=>{const t=e.target.closest("button[interestfor]");t&&t[i]?.longPressTimer&&(clearTimeout(t[i].longPressTimer),t[i].longPressTimer=null)};document.body.addEventListener("touchend",t=>{e(t),l=!1}),document.body.addEventListener("touchmove",e)}(),console.log(`interestfor polyfill installed (native: ${c}).`)}"complete"===document.readyState?F():window.addEventListener("load",F)}();
|
package/test/basic_test.html
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
|
|
3
|
-
<script src="interestfor.
|
|
3
|
+
<script src="../src/interestfor.js" async></script>
|
|
4
4
|
|
|
5
5
|
<a interestfor=foo href=bar>Link - hover me, don't click me</a>
|
|
6
|
+
<button interestfor=foo>Button - hover me, don't click me</a>
|
|
6
7
|
<div popover id=foo>Popover</div>
|
|
8
|
+
|
|
9
|
+
<style>
|
|
10
|
+
[interestfor] {
|
|
11
|
+
interest-delay: 600ms 300ms;
|
|
12
|
+
--interest-delay: 600ms 300ms;
|
|
13
|
+
}
|
|
14
|
+
:is([interestfor]:interest-source),
|
|
15
|
+
[interestfor].interest-source {
|
|
16
|
+
background-color: lightgreen;
|
|
17
|
+
}
|
|
18
|
+
:is([popover]:interest-target),
|
|
19
|
+
[popover].interest-target {
|
|
20
|
+
background-color: lightblue;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
package/test/test.html
CHANGED
|
@@ -199,7 +199,8 @@ async function waitForHoverOrFocus(type,element,target,timeMilliseconds) {
|
|
|
199
199
|
const timeout = new Promise(resolve => setTimeout(() => {
|
|
200
200
|
gotTimeout=true;
|
|
201
201
|
resolve();
|
|
202
|
-
},timeMilliseconds));
|
|
202
|
+
},timeMilliseconds*1.5));
|
|
203
|
+
// Make sure the user doesn't leave too early:
|
|
203
204
|
switch (type) {
|
|
204
205
|
case 'hover':
|
|
205
206
|
const mouseLeft = new Promise(resolve => element.addEventListener('mouseleave',resolve, {once:true}));
|
|
@@ -223,11 +224,22 @@ async function waitForHoverOrFocus(type,element,target,timeMilliseconds) {
|
|
|
223
224
|
return {midEvents, midTargetOpen, endEvents: [...events], endTargetOpen: target.matches(':popover-open')};
|
|
224
225
|
}
|
|
225
226
|
|
|
226
|
-
async function testOne(invoker, target, type, description) {
|
|
227
|
+
async function testOne(invoker, target, type, description, useShorthand = false) {
|
|
227
228
|
assert_true(true,'---' + description,true);
|
|
228
229
|
assert_false(target.matches(':popover-open'),'popover should start closed',true);
|
|
229
|
-
|
|
230
|
-
|
|
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');
|
|
242
|
+
}
|
|
231
243
|
invoker.classList.remove('hide');
|
|
232
244
|
|
|
233
245
|
// Show interest in the invoker
|
|
@@ -290,6 +302,20 @@ async function testHarness() {
|
|
|
290
302
|
await testOne(dynamic_invoker,dynamic_target,type,'Dynamically-added elements');
|
|
291
303
|
dynamic_invoker.remove();
|
|
292
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();
|
|
293
319
|
}
|
|
294
320
|
|
|
295
321
|
// Test complete
|