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 ADDED
@@ -0,0 +1,7 @@
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
+
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 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.
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 fo the `interestfor` API. When interesting
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-show-delay: 400ms;
73
- --interest-show-delay: 400ms;
74
- interest-hide-delay: 200ms;
75
- --interest-hide-delay: 200ms;
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]:has-interest),
84
- [interestfor].has-interest {
98
+ :is([interestfor]:interest-source),
99
+ [interestfor].interest-source {
85
100
  /* invoker styles */
86
101
  }
87
- :is([popover]:target-of-interest),
88
- [popover].target-of-interest {
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. `:has-interest`).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "interestfor",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "A polyfill for the `interestfor` API",
5
5
  "keywords": "interestfor, interesttarget, polyfill, hover-triggering",
6
6
  "homepage": "https://github.com/mfreed7/interestfor#readme",
@@ -11,19 +11,21 @@
11
11
  const attributeName = "interestfor";
12
12
  const interestEventName = "interest";
13
13
  const loseInterestEventName = "loseinterest";
14
- const showDelayProp = "--interest-show-delay";
15
- const hideDelayProp = "--interest-hide-delay";
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 = HTMLButtonElement.prototype.hasOwnProperty(
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,{capture:true});
39
- document.body.addEventListener(loseInterestEventName,cancel,{capture:true});
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 (!invoker.isConnected || GetInterestForTarget(invoker) !== target) {
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, showDelayProp) * 1000;
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, hideDelayProp) * 1000;
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 raw = getComputedStyle(el).getPropertyValue(prop).trim();
136
- const m = raw.match(/^([\d.]+)s$/);
137
- if (m) {
138
- return parseFloat(m[1]);
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
- return parseFloat(raw) || 0;
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.PartialInterest) {
171
- if (!target.dispatchEvent(new Event(interestEventName))) {
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
- 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);
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
- if (!target.dispatchEvent(new Event(loseInterestEventName))) {
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
- 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);
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
- function disableFocusable(root) {
231
- root.querySelectorAll(focusableSelector).forEach((el) => {
232
- if (el.hasAttribute("data-original-tabindex")) {
233
- return;
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
- const orig = el.getAttribute("tabindex");
236
- el.setAttribute("data-original-tabindex", orig === null ? "none" : orig);
237
- el.setAttribute("tabindex", "-1");
238
- });
310
+ }
311
+ return true;
239
312
  }
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);
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
- el.removeAttribute("data-original-tabindex");
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
- 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
- }
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
- const needsPartialInterest =
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
- let data = e.target[dataField];
328
- if (!data) {
329
- return;
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
- if (
335
- e.key === "ArrowUp" &&
336
- e.altKey &&
337
- data.state === InterestState.PartialInterest
338
- ) {
339
- e.preventDefault();
340
- applyState(e.target, InterestState.FullInterest);
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
- if (e.key === "Escape" && data.state !== InterestState.NoInterest) {
343
- e.preventDefault();
344
- clearState(e.target);
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 ${showDelayProp} {syntax: "<time>"; inherits: false; initial-value: 0.5s;}
353
- @property ${hideDelayProp} {syntax: "<time>"; inherits: false; initial-value: 0.5s;}`;
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();
@@ -1,2 +1,8 @@
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)}();
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)}();
@@ -1,6 +1,22 @@
1
1
  <!DOCTYPE html>
2
2
 
3
- <script src="interestfor.min.js" async></script>
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
- invoker.style.setProperty('--interest-show-delay',hoverTimeMilliseconds + 'ms');
230
- invoker.style.setProperty('--interest-hide-delay',hoverTimeMilliseconds + 'ms');
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