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 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)}();
@@ -0,0 +1,6 @@
1
+ <!DOCTYPE html>
2
+
3
+ <script src="interestfor.min.js" async></script>
4
+
5
+ <a interestfor=foo href=bar>Link - hover me, don't click me</a>
6
+ <div popover id=foo>Popover</div>
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>