interestfor 1.0.7 → 1.0.9

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/README.md CHANGED
@@ -1,12 +1,11 @@
1
1
  # `interestfor` Polyfill
2
2
 
3
3
  A polyfill for the `interestfor` attribute. This is a new API for "interest
4
- interactions" that is in the process of being implemented in browsers. It will
5
- [likely ship soon in
6
- Chrome](https://groups.google.com/a/chromium.org/g/blink-dev/c/bX1G_yDt6W4?e=48417069).
7
- See the explainer for all of the details for the "real" feature:
4
+ interactions" that is in the process of being implemented in browsers. It [shipped](https://chromestatus.com/feature/4530756656562176)
5
+ in Chromium browsers (e.g. Chrome, Edge) in version 142 (October 2025).
6
+ See the MDN documentation for more details:
8
7
 
9
- https://open-ui.org/components/interest-invokers.explainer/
8
+ https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using_interest_invokers
10
9
 
11
10
  ## Usage
12
11
  To use this polyfill, simply load it:
@@ -28,14 +27,84 @@ can be done by hovering the element with the mouse, or focusing it with the
28
27
  keyboard. If the target is a popover, it will automatically be shown and hidden
29
28
  upon receiving and losing interest.
30
29
 
30
+ A few more details:
31
+
32
+ - Only `<button>` (when not disabled), `<a href>`, `<area href>`, and SVG
33
+ `<a href>` elements can be interest invokers. The `interestfor` attribute has
34
+ no effect on other elements.
35
+ - The `interest` and `loseinterest` events are cancelable. Calling
36
+ `preventDefault()` on `interest` prevents interest from being shown, and on
37
+ `loseinterest` keeps interest (and the popover) in place.
38
+ - Pressing Escape makes all interest invokers lose interest. These
39
+ `loseinterest` events are not cancelable.
40
+ - If the target popover was already open when interest was gained, it is not
41
+ hidden when interest is lost.
42
+ - If the target popover is hidden some other way (e.g. light dismiss), the
43
+ invoker loses interest.
44
+ - Popovers can be nested: hovering an invoker inside a popover that was shown
45
+ via interest keeps the outer popover open.
46
+ - The `interestForElement` property is available on `HTMLButtonElement`,
47
+ `HTMLAnchorElement`, `HTMLAreaElement`, and `SVGAElement`. It reflects the
48
+ `interestfor` attribute as an element reference.
49
+ - The optional `::interest-button` pseudo element provides a button that shows
50
+ interest immediately when clicked or tapped. See below.
51
+
31
52
  Again, this is a quick summary. For the complete details, please [read the
32
- explainer](https://open-ui.org/components/interest-invokers.explainer).
53
+ OpenUI explainer](https://open-ui.org/components/interest-invokers.explainer),
54
+ the [HTML spec PR](https://github.com/whatwg/html/pull/11006), and the CSSWG
55
+ specs for [the pseudo classes](https://drafts.csswg.org/selectors-5/#interest-pseudos)
56
+ and [CSS properties](https://drafts.csswg.org/css-ui-4/#interest).
57
+
58
+ ## The `::interest-button` pseudo element
59
+
60
+ An interest invoker can have an `::interest-button` pseudo element, which is
61
+ a small button rendered just after the invoker. Clicking (or tapping) it shows
62
+ interest immediately, without waiting for any delay. Interest shown this way is
63
+ not lost when the mouse leaves the invoker or the invoker loses focus. It is
64
+ lost when the user clicks outside the popover, presses Escape, or hovers and
65
+ then leaves the target popover. The pseudo element is only rendered if its
66
+ `content` property is set.
67
+
68
+ The polyfill can't add a real pseudo element, so it inserts a
69
+ `<span class="interest-button" aria-hidden="true">` element as the next sibling
70
+ of the invoker. Set the `--interest-button-content` custom property on the
71
+ invoker to the value you'd use for `content`, and use `+ .interest-button` to
72
+ style it. These must be separate rules, because a browser that doesn't
73
+ recognize `::interest-button` will drop the entire rule containing it:
74
+
75
+ ```css
76
+ /* Native */
77
+ [interestfor]::interest-button {
78
+ content: "?";
79
+ }
80
+ /* Polyfill */
81
+ [interestfor] {
82
+ --interest-button-content: "?";
83
+ }
84
+ [interestfor] + .interest-button {
85
+ /* Additional polyfill-only styles */
86
+ }
87
+ ```
88
+
89
+ Both the native pseudo element and the polyfill element get these default
90
+ styles, which can be overridden: `display: inline-block`,
91
+ `vertical-align: middle`, `min-inline-size: 24px`,
92
+ `min-block-size: max(24px, 1lh)`, `cursor: help`, a transparent background, and
93
+ a slightly tinted background on `:hover` and `:active`.
94
+
95
+ Because the interest button can be tapped, it provides a way for touchscreen
96
+ users to show interest in links (see below). Note that the polyfill implements
97
+ this as a `<span>` with a click handler on purpose - that makes it non-focusable,
98
+ which is correct, because the invoker itself will show interest if focused, so
99
+ there should not be two focus stops. See [this section of the explainer](https://open-ui.org/components/interest-invokers.explainer/#rich-hints-aka-hovercards-or-other:~:text=Importantly%2C%20this%20new%20pseudo%20element%20is%20not%20keyboard%20focusable)
100
+ for more details.
33
101
 
34
102
  ## Touchscreen support
35
103
 
36
104
  This polyfill provides limited touchscreen support. On a touchscreen,
37
105
  long-pressing a `<button>` element that has an `interestfor` attribute will show
38
- interest in the target.
106
+ interest in the target. As with the interest button, this interest is not lost
107
+ when the button loses hover or focus.
39
108
 
40
109
  However, long-pressing a link (`<a>` element) is **not** supported. This is
41
110
  because on today's browsers, long-pressing a link element brings up a useful
@@ -45,7 +114,8 @@ be surpressed completely. There is currently no way to provide access to the
45
114
  long-press context menu and *also* provide a way to show interest. Since the
46
115
  context menu contains items that users often use, eliminating access to the
47
116
  context menu is not what most people want. So this polyfill will not support
48
- long-press on links.
117
+ long-press on links. To let touchscreen users show interest in a link, give it
118
+ an interest button (see the `::interest-button` section above).
49
119
 
50
120
  This is one of the primary motivations for Chromium championing this as a
51
121
  native API in all browsers: to support all users, including those using
@@ -109,6 +179,30 @@ native feature:
109
179
  class names. This is to avoid the entire selector being invalidated if the
110
180
  browser does not recognize the native pseudo class (e.g. `:interest-source`).
111
181
 
182
+ - The `::interest-button` pseudo element is implemented as a real
183
+ `<span class="interest-button">` element, inserted after the invoker, and
184
+ configured with the `--interest-button-content` custom property (see above).
185
+ Because it is a real element:
186
+ - It inherits styles from the invoker's parent, not from the invoker.
187
+ - It can affect sibling selectors such as `+` and `:nth-child()`.
188
+ - Click listeners on the invoker don't see clicks on the interest button.
189
+ - It is not implicitly anchored to the invoker for anchor positioning.
190
+ - The polyfill only re-reads `--interest-button-content` when the DOM or the
191
+ `interestfor`, `id`, `href`, or `disabled` attributes change. Changes caused
192
+ only by styles (e.g. class changes, `:hover`, media queries) are not
193
+ detected.
194
+ - Interest buttons are not created for invokers inside shadow roots, or for
195
+ `<area>` or SVG `<a>` invokers.
196
+ - Clicking the interest button while the target popover is open causes the
197
+ popover to be light dismissed and then shown again, which fires an extra
198
+ pair of `loseinterest` and `interest` events.
199
+ - The polyfill does not run in browsers that natively support `interestfor`.
200
+ In browsers that support `interestfor` but not `::interest-button`, no
201
+ interest button is shown.
202
+ - The polyfill installs a `MutationObserver` on the document to keep interest
203
+ buttons up to date, and prepends a `<style>` element to `<head>` containing
204
+ the default styles for interest invokers and interest buttons.
205
+
112
206
 
113
207
  ## Tests
114
208
 
package/build.sh CHANGED
@@ -11,7 +11,7 @@ MINIFIED_FILE="${BUILD_DIR}/interestfor.min.js"
11
11
  OUTPUT_FILE="${SRC_DIR}/interestfor.min.js"
12
12
 
13
13
  # Minify the combined file
14
- terser "${SOURCE_FILE}" --compress --mangle --output "${MINIFIED_FILE}"
14
+ npx terser "${SOURCE_FILE}" --compress --mangle --output "${MINIFIED_FILE}"
15
15
 
16
16
  cat COPYRIGHT "${MINIFIED_FILE}" > "${OUTPUT_FILE}"
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "interestfor",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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,7 +11,5 @@
11
11
  },
12
12
  "author": "Mason Freed <mfreed@mfreed.com>",
13
13
  "license": "BSD-3-Clause",
14
- "main": "src/interestfor.min.js",
15
- "dependencies": {}
14
+ "main": "src/interestfor.min.js"
16
15
  }
17
-
@@ -14,9 +14,24 @@
14
14
  const interestDelayStartProp = "--interest-delay-start";
15
15
  const interestDelayEndProp = "--interest-delay-end";
16
16
  const interestDelayProp = "--interest-delay";
17
+ // Stands in for the `content` property of the `::interest-button` pseudo
18
+ // element. It is set on the invoker.
19
+ const interestButtonContentProp = "--interest-button-content";
20
+ // An unregistered (and therefore inherited) copy of the content value, which
21
+ // is set on the polyfilled interest button, so that its ::before pseudo
22
+ // element can render it.
23
+ const interestButtonInternalContentProp =
24
+ "--interest-button-polyfill-content";
25
+ const interestButtonClass = "interest-button";
17
26
  const dataField = "__interestForData";
18
27
  const targetDataField = "__interestForTargetData";
28
+ const buttonField = "__interestForButton";
29
+ const buttonInvokerField = "__interestForButtonInvoker";
30
+ const explicitTargetField = "__interestForExplicitTarget";
31
+ const xlinkNamespace = "http://www.w3.org/1999/xlink";
19
32
  const invokersWithInterest = new Set();
33
+ const invokersWithButtons = new Set();
34
+ const invokersMissingTarget = new Set();
20
35
  let touchInProgress = false;
21
36
 
22
37
  // Feature detection
@@ -44,11 +59,45 @@
44
59
  capture: true,
45
60
  });
46
61
  }
62
+
63
+ if(!window.InterestEvent) {
64
+ window.InterestEvent = class InterestEvent extends Event {
65
+ #source;
66
+ constructor(type, eventInit = {}) {
67
+ super(type, eventInit);
68
+ const { source } = eventInit;
69
+ if (source != null && !(source instanceof Element)) {
70
+ throw new TypeError(`source must be an element`);
71
+ }
72
+ this.#source = source;
73
+ }
74
+
75
+ get [Symbol.toStringTag]() {
76
+ return "InterestEvent";
77
+ }
78
+
79
+ get source() {
80
+ const source = this.#source;
81
+ if (!source) return null;
82
+ // Retarget `source` to the current event's root:
83
+ const sourceRoot = getRootNode(source);
84
+ if (sourceRoot !== getRootNode(this.target || document)) {
85
+ return sourceRoot.host;
86
+ }
87
+ return source;
88
+ }
89
+ }
90
+ }
47
91
 
48
92
  // Enum-like state and source
49
93
  const InterestState = {
50
94
  NoInterest: "none",
95
+ // Interest gained via hover or focus, after a delay. This is lost when
96
+ // the invoker is de-hovered or blurred.
51
97
  FullInterest: "full",
98
+ // Interest gained immediately via long-press or the `::interest-button`.
99
+ // This is not lost when the invoker itself is de-hovered or blurred.
100
+ ExplicitInterest: "explicit",
52
101
  };
53
102
  const Source = {
54
103
  Hover: "hover",
@@ -58,8 +107,16 @@
58
107
  Touch: "touch",
59
108
  };
60
109
 
110
+ function getRootNode(node) {
111
+ if (node && typeof node.getRootNode === "function") {
112
+ return node.getRootNode();
113
+ }
114
+ if (node && node.parentNode) return getRootNode(node.parentNode);
115
+ return node;
116
+ }
117
+
61
118
  // Gain or lose interest
62
- function GainOrLoseInterest(invoker, target, newState) {
119
+ function GainOrLoseInterest(invoker, target, newState, options = {}) {
63
120
  if (!invoker || !target) {
64
121
  return false;
65
122
  }
@@ -74,9 +131,21 @@
74
131
 
75
132
  // If gaining, handle existing invoker
76
133
  if (newState !== InterestState.NoInterest) {
134
+ initializeDataField(invoker);
135
+ if (
136
+ invoker[dataField].state !== InterestState.NoInterest &&
137
+ invoker[dataField].target !== target
138
+ ) {
139
+ // The `interestfor` attribute changed while this invoker had interest,
140
+ // and the mutation observer hasn't run yet.
141
+ resetInterestState(invoker);
142
+ }
77
143
  const existing = GetInterestInvoker(target);
78
144
  if (existing) {
79
145
  if (existing === invoker) {
146
+ if (newState === InterestState.ExplicitInterest) {
147
+ existing[dataField].state = InterestState.ExplicitInterest;
148
+ }
80
149
  existing[dataField].clearLostTask();
81
150
  return false;
82
151
  } else {
@@ -93,73 +162,182 @@
93
162
  }
94
163
  }
95
164
  // finally apply interest
96
- return applyState(invoker, newState);
165
+ return applyState(invoker, target, newState);
97
166
  }
98
167
 
99
168
  // losing interest
100
- clearState(invoker);
101
- return true;
169
+ return clearState(invoker, options);
102
170
  }
103
171
 
104
172
  // Schedule tasks
105
173
  function ScheduleInterestGainedTask(invoker, newState) {
174
+ const target = GetInterestForTarget(invoker);
175
+ if (!target) {
176
+ return;
177
+ }
106
178
  const delay = getDelaySeconds(invoker, interestDelayStartProp) * 1000;
107
- if (!isFinite(delay) || delay < 0) {
179
+ if (!isSchedulableDelay(delay)) {
108
180
  return;
109
181
  }
110
182
  invoker[dataField].clearGainedTask();
111
183
  invoker[dataField].gainedTimer = setTimeout(() => {
112
- GainOrLoseInterest(invoker, GetInterestForTarget(invoker), newState);
184
+ GainOrLoseInterest(invoker, target, newState);
113
185
  }, delay);
114
186
  }
115
187
 
116
188
  function ScheduleInterestLostTask(invoker) {
117
189
  const delay = getDelaySeconds(invoker, interestDelayEndProp) * 1000;
118
- if (!isFinite(delay) || delay < 0) {
190
+ if (!isSchedulableDelay(delay)) {
119
191
  return;
120
192
  }
121
- invoker[dataField].clearLostTask();
122
- invoker[dataField].lostTimer = setTimeout(() => {
123
- GainOrLoseInterest(
124
- invoker,
125
- GetInterestForTarget(invoker),
126
- InterestState.NoInterest
127
- );
193
+ const data = invoker[dataField];
194
+ data.clearLostTask();
195
+ const target = data.target;
196
+ data.lostTimer = setTimeout(() => {
197
+ GainOrLoseInterest(invoker, target, InterestState.NoInterest);
128
198
  }, delay);
129
199
  }
130
200
 
131
201
  // Helpers
132
202
  function GetInterestInvoker(target) {
133
203
  const inv = target[targetDataField]?.invoker || null;
134
- return inv && inv[dataField]?.state !== InterestState.NoInterest
135
- ? inv
136
- : null;
204
+ if (!inv) {
205
+ return null;
206
+ }
207
+ const data = inv[dataField];
208
+ if (
209
+ !data ||
210
+ data.state === InterestState.NoInterest ||
211
+ data.target !== target ||
212
+ GetInterestForTarget(inv) !== target
213
+ ) {
214
+ return null;
215
+ }
216
+ return inv;
137
217
  }
218
+
219
+ // Returns the target of a valid interest invoker, or null.
138
220
  function GetInterestForTarget(el) {
221
+ if (
222
+ !el.hasAttribute(attributeName) ||
223
+ !el.isConnected ||
224
+ !isValidInterestInvoker(el)
225
+ ) {
226
+ return null;
227
+ }
228
+ const target = getAttrAssociatedElement(el);
229
+ return target && target.isConnected ? target : null;
230
+ }
231
+
232
+ // Only some elements can be interest invokers. This mirrors the
233
+ // IsValidInterestInvoker() overrides in Chromium.
234
+ function isValidInterestInvoker(el) {
235
+ if (el instanceof HTMLButtonElement) {
236
+ return !el.matches(":disabled");
237
+ }
238
+ if (el instanceof HTMLAnchorElement || el instanceof HTMLAreaElement) {
239
+ return el.hasAttribute("href");
240
+ }
241
+ if (window.SVGAElement && el instanceof SVGAElement) {
242
+ return el.hasAttribute("href") || el.hasAttributeNS(xlinkNamespace, "href");
243
+ }
244
+ return false;
245
+ }
246
+
247
+ // Resolves the `interestfor` attribute (or an element set via the
248
+ // `interestForElement` property) within the invoker's tree scope.
249
+ function getAttrAssociatedElement(el) {
139
250
  const id = el.getAttribute(attributeName);
140
- return document.getElementById(id);
251
+ if (id === null) {
252
+ return null;
253
+ }
254
+ const explicitTarget = el[explicitTargetField];
255
+ if (explicitTarget && id === "") {
256
+ return isInShadowIncludingAncestorTree(explicitTarget, el)
257
+ ? explicitTarget
258
+ : null;
259
+ }
260
+ if (id === "") {
261
+ return null;
262
+ }
263
+ const root = getRootNode(el);
264
+ return typeof root.getElementById === "function"
265
+ ? root.getElementById(id)
266
+ : null;
141
267
  }
142
268
 
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);
269
+ // Returns true if `candidate` is in the same tree as `el`, or in the tree of
270
+ // one of `el`'s shadow hosts.
271
+ function isInShadowIncludingAncestorTree(candidate, el) {
272
+ const candidateRoot = getRootNode(candidate);
273
+ let root = getRootNode(el);
274
+ while (root) {
275
+ if (root === candidateRoot) {
276
+ return true;
149
277
  }
278
+ root = root instanceof ShadowRoot ? getRootNode(root.host) : null;
279
+ }
280
+ return false;
281
+ }
282
+
283
+ function isPopoverOpen(el) {
284
+ try {
285
+ return el.matches(":popover-open");
286
+ } catch {
287
+ return false;
150
288
  }
151
289
  }
152
290
 
291
+ // If the target popover is hidden by something other than the loss of
292
+ // interest (e.g. light dismiss), the invoker loses interest. This uses
293
+ // `beforetoggle`, since in Chromium, the loss of interest happens
294
+ // synchronously as the popover begins to hide.
295
+ function onPopoverBeforeToggle(e) {
296
+ if (e.newState !== "closed") {
297
+ return;
298
+ }
299
+ const popover = e.currentTarget;
300
+ const invoker = GetInterestInvoker(popover);
301
+ if (!invoker) {
302
+ return;
303
+ }
304
+ if (popover.contains(document.activeElement)) {
305
+ // Focus will be returned to the previously focused element after the
306
+ // popover hides. If that is the invoker, don't show interest again
307
+ // because of that focus.
308
+ const data = invoker[dataField];
309
+ data.suppressNextFocusInterest = true;
310
+ setTimeout(() => {
311
+ data.suppressNextFocusInterest = false;
312
+ }, 0);
313
+ }
314
+ // The popover is already hiding, so don't try to hide it again.
315
+ GainOrLoseInterest(invoker, popover, InterestState.NoInterest, {
316
+ closePopovers: false,
317
+ });
318
+ }
319
+
320
+ // Delays longer than setTimeout() supports (which would fire immediately)
321
+ // are treated as infinite, i.e. interest is never gained or lost.
322
+ function isSchedulableDelay(delayMs) {
323
+ return delayMs >= 0 && delayMs <= 2147483647;
324
+ }
325
+
153
326
  function parseTimeValue(val) {
154
327
  const s = String(val).trim();
155
- const m_s = s.match(/^([\d.]+)s$/);
328
+ const m_s = s.match(/^([\d.]+(?:e[+-]?\d+)?)s$/i);
156
329
  if (m_s) {
157
330
  return parseFloat(m_s[1]);
158
331
  }
159
- const m_ms = s.match(/^([\d.]+)ms$/);
332
+ const m_ms = s.match(/^([\d.]+(?:e[+-]?\d+)?)ms$/i);
160
333
  if (m_ms) {
161
334
  return parseFloat(m_ms[1]) / 1000;
162
335
  }
336
+ // Values such as `calc(infinity * 1s)` mean that interest is never
337
+ // gained or lost via hover or focus.
338
+ if (/\b(infinity|nan)\b/i.test(s)) {
339
+ return Infinity;
340
+ }
163
341
  return parseFloat(s) || 0;
164
342
  }
165
343
 
@@ -195,31 +373,44 @@
195
373
  }
196
374
  }
197
375
 
198
- // If we got here, the effective value is 'normal'. Return the default.
199
- return prop === interestDelayStartProp ? 0.5 : 0.25;
376
+ // If we got here, the effective value is 'normal'. Return the Chromium default.
377
+ return prop === interestDelayStartProp ? 0.25 : 0.15;
200
378
  }
201
379
 
202
380
  // Actual state transitions
203
- function applyState(invoker, newState) {
381
+ function applyState(invoker, target, newState) {
204
382
  const data = invoker[dataField];
205
- const target = GetInterestForTarget(invoker);
206
383
  switch (newState) {
207
384
  case InterestState.FullInterest:
385
+ case InterestState.ExplicitInterest:
208
386
  if (data.state !== InterestState.NoInterest) {
209
387
  throw new Error("Invalid state");
210
388
  }
211
- target.dispatchEvent(new Event(interestEventName));
212
- try {
213
- target.showPopover({ source: invoker });
214
- } catch {}
215
- data.state = InterestState.FullInterest;
389
+ const shouldContinue = target.dispatchEvent(
390
+ new InterestEvent(interestEventName, {
391
+ source: invoker,
392
+ cancelable: true,
393
+ })
394
+ );
395
+ if (!shouldContinue) return false;
396
+ data.state = newState;
397
+ data.target = target;
398
+ data.clearGainedTask();
216
399
  if (!target[targetDataField]) {
217
400
  target[targetDataField] = {};
218
401
  }
219
402
  target[targetDataField].invoker = invoker;
220
403
  if (target.hasAttribute('popover')) {
221
- target[targetDataField].toggleListener = onPopoverToggle;
222
- target.addEventListener('toggle', onPopoverToggle);
404
+ target.addEventListener('beforetoggle', onPopoverBeforeToggle);
405
+ // Only show the popover if it isn't already open. Only popovers that
406
+ // were shown here get hidden when interest is lost.
407
+ target[targetDataField].shownByInterest = false;
408
+ if (!isPopoverOpen(target)) {
409
+ try {
410
+ target.showPopover({ source: invoker });
411
+ } catch {}
412
+ target[targetDataField].shownByInterest = isPopoverOpen(target);
413
+ }
223
414
  }
224
415
  invokersWithInterest.add(invoker);
225
416
  invoker.classList.add("interest-source");
@@ -243,32 +434,88 @@
243
434
  return true;
244
435
  }
245
436
 
246
- function clearState(invoker) {
437
+ // Fires the `loseinterest` event, and if not cancelled, removes interest
438
+ // and hides the target popover (if it was shown via interest). If `force` is
439
+ // true, the event is not cancelable.
440
+ function clearState(invoker, { force = false, closePopovers = true } = {}) {
441
+ const data = invoker[dataField];
442
+ clearTimeout(data.gainedTimer);
443
+ clearTimeout(data.lostTimer);
444
+ if (data.state === InterestState.NoInterest) {
445
+ return true;
446
+ }
447
+ const target = data.target;
448
+ const shouldContinue = target.dispatchEvent(
449
+ new InterestEvent(loseInterestEventName, {
450
+ source: invoker,
451
+ cancelable: !force,
452
+ })
453
+ );
454
+ if (!shouldContinue) return false;
455
+ const targetData = target[targetDataField];
456
+ const shouldHide =
457
+ closePopovers &&
458
+ targetData?.invoker === invoker &&
459
+ targetData.shownByInterest;
460
+ resetInterestState(invoker);
461
+ if (shouldHide && isPopoverOpen(target)) {
462
+ hideTargetPopover(invoker, target);
463
+ }
464
+ return true;
465
+ }
466
+
467
+ // Removes interest without firing events, or hiding the target. This
468
+ // mirrors ChangeInterestState(kNoInterest) in Chromium, and is used directly
469
+ // when the invoker or target is removed, or the `interestfor` attribute
470
+ // changes.
471
+ function resetInterestState(invoker) {
247
472
  const data = invoker[dataField];
473
+ if (!data) {
474
+ return;
475
+ }
248
476
  clearTimeout(data.gainedTimer);
249
477
  clearTimeout(data.lostTimer);
250
- if (data.state !== InterestState.NoInterest) {
251
- const target = GetInterestForTarget(invoker);
252
- target.dispatchEvent(new Event(loseInterestEventName));
253
- try {
254
- target.hidePopover();
255
- } catch {}
256
- if (target[targetDataField]?.toggleListener) {
257
- target.removeEventListener('toggle', target[targetDataField].toggleListener);
258
- }
259
- target[targetDataField] = null;
260
- invokersWithInterest.delete(invoker);
261
- invoker.classList.remove("interest-source");
262
- target.classList.remove("interest-target");
478
+ if (data.state === InterestState.NoInterest) {
479
+ return;
480
+ }
481
+ const target = data.target;
482
+ invokersWithInterest.delete(invoker);
483
+ invoker.classList.remove("interest-source");
484
+ if (target) {
485
+ if (target[targetDataField]?.invoker === invoker) {
486
+ target.removeEventListener('beforetoggle', onPopoverBeforeToggle);
487
+ target[targetDataField] = null;
488
+ target.classList.remove("interest-target");
489
+ }
263
490
  if (!isPlainHint(target)) {
264
491
  invoker.setAttribute("aria-expanded", "false");
265
492
  }
266
- if (data.anchorName) {
267
- invoker.style.anchorName = "";
493
+ }
494
+ if (data.anchorName) {
495
+ invoker.style.anchorName = "";
496
+ if (target) {
268
497
  target.style.positionAnchor = "";
269
- data.anchorName = null;
270
498
  }
271
- data.state = InterestState.NoInterest;
499
+ data.anchorName = null;
500
+ }
501
+ data.state = InterestState.NoInterest;
502
+ data.target = null;
503
+ }
504
+
505
+ function hideTargetPopover(invoker, target) {
506
+ // If focus is within the popover, hiding it returns focus to the
507
+ // previously focused element. If that is the invoker, don't show interest
508
+ // again because of that focus.
509
+ const data = invoker[dataField];
510
+ const focusInside = target.contains(document.activeElement);
511
+ if (focusInside) {
512
+ data.suppressNextFocusInterest = true;
513
+ }
514
+ try {
515
+ target.hidePopover();
516
+ } catch {}
517
+ if (focusInside) {
518
+ data.suppressNextFocusInterest = false;
272
519
  }
273
520
  }
274
521
 
@@ -331,9 +578,11 @@
331
578
  if (el[dataField]) return;
332
579
  el[dataField] = {
333
580
  state: InterestState.NoInterest,
581
+ target: null,
334
582
  gainedTimer: null,
335
583
  lostTimer: null,
336
584
  longPressTimer: null,
585
+ suppressNextFocusInterest: false,
337
586
  clearGainedTask() {
338
587
  clearTimeout(this.gainedTimer);
339
588
  },
@@ -347,6 +596,49 @@
347
596
  }
348
597
  }
349
598
 
599
+ // Returns the flat tree parent of `el`. The polyfilled `::interest-button`
600
+ // is treated as a child of its invoker, for tree traversal purposes, as
601
+ // the real pseudo element would be. (It is treated as a sibling for layout
602
+ // tree traversal purposes only.)
603
+ function getParentForInterest(el) {
604
+ if (el[buttonInvokerField]) {
605
+ return el[buttonInvokerField];
606
+ }
607
+ if (el.assignedSlot) {
608
+ return el.assignedSlot;
609
+ }
610
+ if (el.parentElement) {
611
+ return el.parentElement;
612
+ }
613
+ const parent = el.parentNode;
614
+ return parent instanceof ShadowRoot ? parent.host : null;
615
+ }
616
+
617
+ // Returns all invokers that have interest in `el`, or in any of its
618
+ // ancestors, and (recursively) all invokers that have interest in those
619
+ // invokers or their ancestors. This mirrors AllSourceInterestInvokers() in
620
+ // Chromium, and allows nested interest popovers to stay open while the inner
621
+ // ones are hovered.
622
+ function allSourceInterestInvokers(el) {
623
+ const sources = new Set();
624
+ if (invokersWithInterest.size === 0) {
625
+ return sources;
626
+ }
627
+ const visit = (node) => {
628
+ const upstream = GetInterestInvoker(node);
629
+ if (upstream && !sources.has(upstream)) {
630
+ sources.add(upstream);
631
+ visit(upstream);
632
+ }
633
+ const parent = getParentForInterest(node);
634
+ if (parent && !sources.has(parent)) {
635
+ visit(parent);
636
+ }
637
+ };
638
+ visit(el);
639
+ return sources;
640
+ }
641
+
350
642
  function HandleInterestHoverOrFocus(el, source) {
351
643
  if (touchInProgress) {
352
644
  return;
@@ -355,52 +647,235 @@
355
647
  return;
356
648
  }
357
649
  const target = GetInterestForTarget(el);
358
- if (!target) {
359
- const containingTarget = el.closest(".interest-target");
360
- if (containingTarget) {
361
- const upstreamInvoker = GetInterestInvoker(containingTarget);
362
- if (upstreamInvoker) {
363
- if (source === Source.Hover || source === Source.Focus) {
364
- upstreamInvoker[dataField].clearLostTask();
365
- } else {
366
- if (source === Source.Blur || !el.matches(":hover")) {
367
- ScheduleInterestLostTask(upstreamInvoker);
368
- }
369
- }
370
- }
371
- }
372
- return;
650
+ if (target) {
651
+ initializeDataField(el);
373
652
  }
374
- initializeDataField(el);
375
653
  const data = el[dataField];
376
- const upstreamInvoker = GetInterestInvoker(el);
377
654
 
378
655
  // Hover or focus
379
656
  if (source === Source.Hover || source === Source.Focus) {
380
- data.clearLostTask && data.clearLostTask();
381
- if (upstreamInvoker) {
657
+ let suppressFocusInterest = false;
658
+ if (data) {
659
+ data.clearLostTask();
660
+ suppressFocusInterest =
661
+ source === Source.Focus && data.suppressNextFocusInterest;
662
+ data.suppressNextFocusInterest = false;
663
+ }
664
+ for (const upstreamInvoker of allSourceInterestInvokers(el)) {
382
665
  upstreamInvoker[dataField].clearLostTask();
383
666
  }
384
- ScheduleInterestGainedTask(el, InterestState.FullInterest);
667
+ if (
668
+ target &&
669
+ !suppressFocusInterest &&
670
+ data.state === InterestState.NoInterest
671
+ ) {
672
+ ScheduleInterestGainedTask(el, InterestState.FullInterest);
673
+ }
385
674
  } else {
386
675
  // Dehover or blur
387
- data.clearGainedTask && data.clearGainedTask();
388
- if (data.state !== InterestState.NoInterest) {
389
- ScheduleInterestLostTask(el);
676
+ if (data) {
677
+ data.clearGainedTask();
678
+ // Explicit interest is not lost by de-hovering or blurring the
679
+ // invoker itself.
680
+ if (data.state === InterestState.FullInterest) {
681
+ ScheduleInterestLostTask(el);
682
+ }
390
683
  }
391
- if (upstreamInvoker) {
392
- upstreamInvoker[dataField].clearGainedTask();
393
- if (source === Source.Blur || !el.matches(":hover")) {
684
+ if (source === Source.Blur || !el.matches(":hover")) {
685
+ for (const upstreamInvoker of allSourceInterestInvokers(el)) {
394
686
  ScheduleInterestLostTask(upstreamInvoker);
395
687
  }
396
688
  }
397
689
  }
398
690
  }
399
691
 
692
+ // `::interest-button` support. The pseudo element is polyfilled with a real
693
+ // element, inserted as the next sibling of the invoker. It is only created
694
+ // if the invoker has a `--interest-button-content` value other than `none`
695
+ // or `normal`, which stands in for the `content` property on the real pseudo
696
+ // element.
697
+ function wantsInterestButton(invoker) {
698
+ return (
699
+ invoker instanceof HTMLElement &&
700
+ invoker.localName !== "area" &&
701
+ !!invoker.parentNode &&
702
+ !!GetInterestForTarget(invoker)
703
+ );
704
+ }
705
+
706
+ function getInterestButtonContent(invoker) {
707
+ const value = getComputedStyle(invoker)
708
+ .getPropertyValue(interestButtonContentProp)
709
+ .trim();
710
+ if (!value || value === "none" || value === "normal") {
711
+ return null;
712
+ }
713
+ return value;
714
+ }
715
+
716
+ function onInterestButtonClick() {
717
+ // Activating the interest button shows interest immediately, without
718
+ // delays.
719
+ const invoker = this[buttonInvokerField];
720
+ if (invoker) {
721
+ GainOrLoseInterest(
722
+ invoker,
723
+ GetInterestForTarget(invoker),
724
+ InterestState.ExplicitInterest
725
+ );
726
+ }
727
+ }
728
+
729
+ function setInterestButton(invoker, content) {
730
+ let button = invoker[buttonField];
731
+ if (!content) {
732
+ if (button) {
733
+ button.remove();
734
+ invoker[buttonField] = null;
735
+ invokersWithButtons.delete(invoker);
736
+ }
737
+ return;
738
+ }
739
+ if (!button) {
740
+ button = invoker.ownerDocument.createElement("span");
741
+ button.className = interestButtonClass;
742
+ // The real pseudo element is not exposed to accessibility, and is not
743
+ // focusable.
744
+ button.setAttribute("aria-hidden", "true");
745
+ button[buttonInvokerField] = invoker;
746
+ button.addEventListener("click", onInterestButtonClick);
747
+ invoker[buttonField] = button;
748
+ invokersWithButtons.add(invoker);
749
+ }
750
+ if (
751
+ button.style.getPropertyValue(interestButtonInternalContentProp) !==
752
+ content
753
+ ) {
754
+ button.style.setProperty(interestButtonInternalContentProp, content);
755
+ }
756
+ if (invoker.nextSibling !== button) {
757
+ invoker.after(button);
758
+ }
759
+ }
760
+
761
+ function updateInterestButtons(invokers) {
762
+ // Read all styles first, and then modify the DOM, to avoid forcing a style
763
+ // recalc for each invoker.
764
+ const updates = [];
765
+ for (const invoker of invokers) {
766
+ let content = null;
767
+ if (invoker.isConnected && invoker.hasAttribute(attributeName)) {
768
+ if (wantsInterestButton(invoker)) {
769
+ invokersMissingTarget.delete(invoker);
770
+ content = getInterestButtonContent(invoker);
771
+ } else {
772
+ // The target might be added (or become valid) later.
773
+ invokersMissingTarget.add(invoker);
774
+ }
775
+ } else {
776
+ invokersMissingTarget.delete(invoker);
777
+ }
778
+ updates.push([invoker, content]);
779
+ }
780
+ for (const [invoker, content] of updates) {
781
+ setInterestButton(invoker, content);
782
+ }
783
+ }
784
+
785
+ function handleMutations(records) {
786
+ const candidates = new Set();
787
+ let recheckMissingTargets = false;
788
+ const addInvokers = (node) => {
789
+ if (node.nodeType !== Node.ELEMENT_NODE || node[buttonInvokerField]) {
790
+ return;
791
+ }
792
+ if (node.hasAttribute(attributeName)) {
793
+ candidates.add(node);
794
+ }
795
+ for (const el of node.querySelectorAll(`[${attributeName}]`)) {
796
+ candidates.add(el);
797
+ }
798
+ };
799
+ for (const record of records) {
800
+ if (record.type === "childList") {
801
+ for (const node of record.addedNodes) {
802
+ if (node.nodeType === Node.ELEMENT_NODE && !node[buttonInvokerField]) {
803
+ recheckMissingTargets = true;
804
+ addInvokers(node);
805
+ }
806
+ }
807
+ continue;
808
+ }
809
+ const el = record.target;
810
+ switch (record.attributeName) {
811
+ case attributeName:
812
+ if (record.oldValue !== null) {
813
+ // Changing the `interestfor` attribute removes interest, without
814
+ // firing events.
815
+ resetInterestState(el);
816
+ }
817
+ if (el.getAttribute(attributeName) !== "") {
818
+ el[explicitTargetField] = null;
819
+ }
820
+ candidates.add(el);
821
+ break;
822
+ case "id":
823
+ recheckMissingTargets = true;
824
+ break;
825
+ default:
826
+ // `href` or `disabled`, which affect invoker validity.
827
+ addInvokers(el);
828
+ break;
829
+ }
830
+ }
831
+ // Invokers or targets that were removed from the document lose interest,
832
+ // without firing events.
833
+ for (const invoker of [...invokersWithInterest]) {
834
+ const target = invoker[dataField].target;
835
+ if (!invoker.isConnected || !target || !target.isConnected) {
836
+ resetInterestState(invoker);
837
+ }
838
+ }
839
+ if (recheckMissingTargets) {
840
+ for (const invoker of invokersMissingTarget) {
841
+ candidates.add(invoker);
842
+ }
843
+ }
844
+ // Keep existing interest buttons next to their invokers, and remove them
845
+ // if their invoker is no longer valid. This avoids reading styles.
846
+ for (const invoker of [...invokersWithButtons]) {
847
+ if (candidates.has(invoker)) {
848
+ continue;
849
+ }
850
+ if (!wantsInterestButton(invoker)) {
851
+ setInterestButton(invoker, null);
852
+ } else if (invoker.nextSibling !== invoker[buttonField]) {
853
+ invoker.after(invoker[buttonField]);
854
+ }
855
+ }
856
+ updateInterestButtons(candidates);
857
+ }
858
+
859
+ function observeMutations() {
860
+ new MutationObserver(handleMutations).observe(document, {
861
+ childList: true,
862
+ subtree: true,
863
+ attributes: true,
864
+ attributeOldValue: true,
865
+ attributeFilter: [attributeName, "id", "href", "disabled"],
866
+ });
867
+ }
868
+
400
869
  // Attach listeners
401
870
  function addEventHandlers() {
402
871
  const handler = (e, source) => {
403
- for (let el = e.target; el; el = el.parentElement) {
872
+ // Use the composed path, so that invokers in open shadow roots work.
873
+ const path = e.composedPath();
874
+ const start = path.length ? path[0] : e.target;
875
+ if (!(start instanceof Element)) {
876
+ return;
877
+ }
878
+ for (let el = start; el; el = getParentForInterest(el)) {
404
879
  HandleInterestHoverOrFocus(el, source);
405
880
  }
406
881
  };
@@ -409,9 +884,16 @@
409
884
  document.body.addEventListener("focusin", (e) => handler(e, Source.Focus));
410
885
  document.body.addEventListener("focusout", (e) => handler(e, Source.Blur));
411
886
  document.body.addEventListener("keydown", (e) => {
412
- if (e.key === "Escape") {
413
- invokersWithInterest.forEach((invoker) => {
414
- clearState(invoker);
887
+ if (e.key === "Escape" && !e.defaultPrevented) {
888
+ // Lose interest in all invokers, most recent first. These
889
+ // `loseinterest` events are not cancelable.
890
+ [...invokersWithInterest].reverse().forEach((invoker) => {
891
+ GainOrLoseInterest(
892
+ invoker,
893
+ invoker[dataField].target,
894
+ InterestState.NoInterest,
895
+ { force: true }
896
+ );
415
897
  });
416
898
  }
417
899
  });
@@ -423,7 +905,15 @@
423
905
  if (invoker) {
424
906
  initializeDataField(invoker);
425
907
  invoker[dataField].longPressTimer = setTimeout(() => {
426
- GainOrLoseInterest(invoker, GetInterestForTarget(invoker), InterestState.FullInterest);
908
+ // Long-press shows interest immediately, and that interest is not
909
+ // lost by de-hovering or blurring the invoker.
910
+ if (invoker[dataField].state === InterestState.NoInterest) {
911
+ GainOrLoseInterest(
912
+ invoker,
913
+ GetInterestForTarget(invoker),
914
+ InterestState.ExplicitInterest
915
+ );
916
+ }
427
917
  invoker[dataField].longPressTimer = null;
428
918
  }, longPressTime);
429
919
  }
@@ -447,15 +937,90 @@
447
937
  const style = document.createElement("style");
448
938
  style.textContent = `@property ${interestDelayStartProp} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}
449
939
  @property ${interestDelayEndProp} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}
450
- @property ${interestDelayProp} {syntax: "[ normal | <time> ]{1,2}"; inherits: false; initial-value: normal;}`;
940
+ @property ${interestDelayProp} {syntax: "[ normal | <time> ]{1,2}"; inherits: false; initial-value: normal;}
941
+ @property ${interestButtonContentProp} {syntax: "*"; inherits: false;}`;
451
942
  document.head.appendChild(style);
452
- document[dataField] = { globalPropsStyle: style };
943
+ // Equivalents of the UA stylesheet rules for interest invokers. These are
944
+ // inserted first, and use :where(), so that author styles take precedence.
945
+ const button = `span.${interestButtonClass}[aria-hidden]`;
946
+ const uaStyle = document.createElement("style");
947
+ uaStyle.textContent = `:where(button[${attributeName}]) {
948
+ -webkit-user-select: none;
949
+ user-select: none;
950
+ }
951
+ :where(${button}) {
952
+ display: inline-block;
953
+ vertical-align: middle;
954
+ color: inherit;
955
+ background-color: transparent;
956
+ box-sizing: border-box;
957
+ min-inline-size: 24px;
958
+ min-block-size: 24px;
959
+ min-block-size: max(24px, 1lh);
960
+ align-content: center;
961
+ text-align: center;
962
+ -webkit-user-select: none;
963
+ user-select: none;
964
+ cursor: help;
965
+ }
966
+ :where(${button}:hover) {
967
+ background-color: color-mix(in oklab, currentColor 10%, transparent);
968
+ }
969
+ :where(${button}:active) {
970
+ background-color: color-mix(in oklab, currentColor 20%, transparent);
971
+ }
972
+ :where(${button})::before {
973
+ content: var(${interestButtonInternalContentProp});
974
+ }`;
975
+ document.head.prepend(uaStyle);
976
+ document[dataField] = { globalPropsStyle: style, uaStyle };
977
+ }
978
+
979
+ // The `interestForElement` IDL attribute, which reflects the `interestfor`
980
+ // content attribute as an element reference.
981
+ function installInterestForElement() {
982
+ const descriptor = {
983
+ configurable: true,
984
+ enumerable: true,
985
+ get() {
986
+ return getAttrAssociatedElement(this);
987
+ },
988
+ set(value) {
989
+ if (value === null || value === undefined) {
990
+ this[explicitTargetField] = null;
991
+ this.removeAttribute(attributeName);
992
+ } else if (value instanceof Element) {
993
+ this[explicitTargetField] = value;
994
+ this.setAttribute(attributeName, "");
995
+ } else {
996
+ throw new TypeError(
997
+ "Failed to set the 'interestForElement' property: The provided value is not of type 'Element'."
998
+ );
999
+ }
1000
+ },
1001
+ };
1002
+ [
1003
+ window.HTMLButtonElement,
1004
+ window.HTMLAnchorElement,
1005
+ window.HTMLAreaElement,
1006
+ window.SVGAElement,
1007
+ ].forEach((iface) => {
1008
+ if (
1009
+ iface &&
1010
+ !Object.prototype.hasOwnProperty.call(iface.prototype, "interestForElement")
1011
+ ) {
1012
+ Object.defineProperty(iface.prototype, "interestForElement", descriptor);
1013
+ }
1014
+ });
453
1015
  }
1016
+ installInterestForElement();
454
1017
 
455
1018
  // Initialize
456
1019
  function init() {
457
1020
  registerCustomProperties();
458
1021
  addEventHandlers();
1022
+ observeMutations();
1023
+ updateInterestButtons(document.querySelectorAll(`[${attributeName}]`));
459
1024
  console.log(`interestfor polyfill installed (native: ${nativeSupported}).`);
460
1025
  }
461
1026
  if (document.readyState === "complete") {
@@ -5,4 +5,4 @@
5
5
  // LICENSE file in the root directory of this source tree.
6
6
 
7
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 w(t),!0}function p(e){const t=1e3*E(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 E(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 w(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*E(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(){const e=(e,t)=>{for(let n=e.target;n;n=n.parentElement)C(n,t)};document.body.addEventListener("mouseover",t=>e(t,m)),document.body.addEventListener("mouseout",t=>e(t,f)),document.body.addEventListener("focusin",t=>e(t,h)),document.body.addEventListener("focusout",t=>e(t,g)),document.body.addEventListener("keydown",e=>{"Escape"===e.key&&a.forEach(e=>{w(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 t=e=>{const t=e.target.closest("button[interestfor]");t&&t[i]?.longPressTimer&&(clearTimeout(t[i].longPressTimer),t[i].longPressTimer=null)};document.body.addEventListener("touchend",e=>{t(e),l=!1}),document.body.addEventListener("touchmove",t)}(),console.log(`interestfor polyfill installed (native: ${c}).`)}"complete"===document.readyState?F():window.addEventListener("load",F)}();
8
+ !function(){const e="interestfor",t="interest",n="loseinterest",r="--interest-delay-start",o="--interest-delay-end",s="--interest-delay",i="--interest-button-content",a="--interest-button-polyfill-content",l="interest-button",c="__interestForData",u="__interestForTargetData",d="__interestForButton",f="__interestForButtonInvoker",m="__interestForExplicitTarget",p="http://www.w3.org/1999/xlink",h=new Set,b=new Set,y=new Set;let g=!1;if(window.interestForPolyfillInstalled)return;window.interestForPolyfillInstalled=!0;const w=HTMLButtonElement.prototype.hasOwnProperty("interestForElement");if(w&&!window.interestForUsePolyfillAlways)return;if(w){const e=e=>{e.isTrusted&&(e.preventDefault(),e.stopImmediatePropagation())};document.body.addEventListener(t,e,{capture:!0}),document.body.addEventListener(n,e,{capture:!0})}window.InterestEvent||(window.InterestEvent=class extends Event{#e;constructor(e,t={}){super(e,t);const{source:n}=t;if(null!=n&&!(n instanceof Element))throw new TypeError("source must be an element");this.#e=n}get[Symbol.toStringTag](){return"InterestEvent"}get source(){const e=this.#e;if(!e)return null;const t=S(e);return t!==S(this.target||document)?t.host:e}});const E="none",v="full",T="explicit",L="hover",A="dehover",x="focus",k="blur";function S(e){return e&&"function"==typeof e.getRootNode?e.getRootNode():e&&e.parentNode?S(e.parentNode):e}function N(e,r,o,s={}){if(!e||!r)return!1;if(!e.isConnected||I(e)!==r||o===E&&F(r)!==e)return!1;if(o!==E){q(e),e[c].state!==E&&e[c].target!==r&&H(e);const n=F(r);if(n){if(n===e)return o===T&&(n[c].state=T),n[c].clearLostTask(),!1;if(!N(n,r,E))return!1;if(!e.isConnected||I(e)!==r)return!1}return function(e,n,r){const o=e[c];switch(r){case v:case T:if(o.state!==E)throw new Error("Invalid state");if(!n.dispatchEvent(new InterestEvent(t,{source:e,cancelable:!0})))return!1;if(o.state=r,o.target=n,o.clearGainedTask(),n[u]||(n[u]={}),n[u].invoker=e,n.hasAttribute("popover")&&(n.addEventListener("beforetoggle",M),n[u].shownByInterest=!1,!$(n))){try{n.showPopover({source:e})}catch{}n[u].shownByInterest=$(n)}if(h.add(e),e.classList.add("interest-source"),n.classList.add("interest-target"),G(n)||e.setAttribute("aria-expanded","true"),"none"===getComputedStyle(e).anchorName&&"auto"===getComputedStyle(n).positionAnchor){const t=`--interest-anchor-${Math.random().toString(36).substring(2)}`;e.style.anchorName=t,n.style.positionAnchor=t,o.anchorName=t}break;default:throw new Error("Invalid state")}return!0}(e,r,o)}return function(e,{force:t=!1,closePopovers:r=!0}={}){const o=e[c];if(clearTimeout(o.gainedTimer),clearTimeout(o.lostTimer),o.state===E)return!0;const s=o.target,i=s.dispatchEvent(new InterestEvent(n,{source:e,cancelable:!t}));if(!i)return!1;const a=s[u],l=r&&a?.invoker===e&&a.shownByInterest;H(e),l&&$(s)&&function(e,t){const n=e[c],r=t.contains(document.activeElement);r&&(n.suppressNextFocusInterest=!0);try{t.hidePopover()}catch{}r&&(n.suppressNextFocusInterest=!1)}(e,s);return!0}(e,s)}function P(e){const t=1e3*V(e,o);if(!_(t))return;const n=e[c];n.clearLostTask();const r=n.target;n.lostTimer=setTimeout(()=>{N(e,r,E)},t)}function F(e){const t=e[u]?.invoker||null;if(!t)return null;const n=t[c];return n&&n.state!==E&&n.target===e&&I(t)===e?t:null}function I(t){if(!t.hasAttribute(e)||!t.isConnected||!function(e){if(e instanceof HTMLButtonElement)return!e.matches(":disabled");if(e instanceof HTMLAnchorElement||e instanceof HTMLAreaElement)return e.hasAttribute("href");if(window.SVGAElement&&e instanceof SVGAElement)return e.hasAttribute("href")||e.hasAttributeNS(p,"href");return!1}(t))return null;const n=C(t);return n&&n.isConnected?n:null}function C(t){const n=t.getAttribute(e);if(null===n)return null;const r=t[m];if(r&&""===n)return function(e,t){const n=S(e);let r=S(t);for(;r;){if(r===n)return!0;r=r instanceof ShadowRoot?S(r.host):null}return!1}(r,t)?r:null;if(""===n)return null;const o=S(t);return"function"==typeof o.getElementById?o.getElementById(n):null}function $(e){try{return e.matches(":popover-open")}catch{return!1}}function M(e){if("closed"!==e.newState)return;const t=e.currentTarget,n=F(t);if(n){if(t.contains(document.activeElement)){const e=n[c];e.suppressNextFocusInterest=!0,setTimeout(()=>{e.suppressNextFocusInterest=!1},0)}N(n,t,E,{closePopovers:!1})}}function _(e){return e>=0&&e<=2147483647}function B(e){const t=String(e).trim(),n=t.match(/^([\d.]+(?:e[+-]?\d+)?)s$/i);if(n)return parseFloat(n[1]);const r=t.match(/^([\d.]+(?:e[+-]?\d+)?)ms$/i);return r?parseFloat(r[1])/1e3:/\b(infinity|nan)\b/i.test(t)?1/0:parseFloat(t)||0}function V(e,t){const n=getComputedStyle(e),o=n.getPropertyValue(t).trim();if("normal"!==o.toLowerCase())return B(o);const i=n.getPropertyValue(s).trim();if(i&&"normal"!==i.toLowerCase()){const e=i.split(/\s+/).filter(e=>e.length>0);if(e.length>0){const n=e[0],o=e.length>1?e[1]:n;let s;if(s=t===r?n:o,"normal"!==s.toLowerCase())return B(s)}}return t===r?.25:.15}function H(e){const t=e[c];if(!t)return;if(clearTimeout(t.gainedTimer),clearTimeout(t.lostTimer),t.state===E)return;const n=t.target;h.delete(e),e.classList.remove("interest-source"),n&&(n[u]?.invoker===e&&(n.removeEventListener("beforetoggle",M),n[u]=null,n.classList.remove("interest-target")),G(n)||e.setAttribute("aria-expanded","false")),t.anchorName&&(e.style.anchorName="",n&&(n.style.positionAnchor=""),t.anchorName=null),t.state=E,t.target=null}const O=["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 G(e){if("hint"!==e.getAttribute("popover")?.toLowerCase())return!1;if(e.querySelector(O))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 q(e){if(e[c])return;e[c]={state:E,target:null,gainedTimer:null,lostTimer:null,longPressTimer:null,suppressNextFocusInterest:!1,clearGainedTask(){clearTimeout(this.gainedTimer)},clearLostTask(){clearTimeout(this.lostTimer)}};const t=I(e);t&&function(e,t){G(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 D(e){if(e[f])return e[f];if(e.assignedSlot)return e.assignedSlot;if(e.parentElement)return e.parentElement;const t=e.parentNode;return t instanceof ShadowRoot?t.host:null}function z(e){const t=new Set;if(0===h.size)return t;const n=e=>{const r=F(e);r&&!t.has(r)&&(t.add(r),n(r));const o=D(e);o&&!t.has(o)&&n(o)};return n(e),t}function j(e,t){if(g)return;if(!e.isConnected)return;const n=I(e);n&&q(e);const o=e[c];if(t===L||t===x){let s=!1;o&&(o.clearLostTask(),s=t===x&&o.suppressNextFocusInterest,o.suppressNextFocusInterest=!1);for(const t of z(e))t[c].clearLostTask();n&&!s&&o.state===E&&function(e,t){const n=I(e);if(!n)return;const o=1e3*V(e,r);_(o)&&(e[c].clearGainedTask(),e[c].gainedTimer=setTimeout(()=>{N(e,n,t)},o))}(e,v)}else if(o&&(o.clearGainedTask(),o.state===v&&P(e)),t===k||!e.matches(":hover"))for(const t of z(e))P(t)}function R(e){return e instanceof HTMLElement&&"area"!==e.localName&&!!e.parentNode&&!!I(e)}function U(e){const t=getComputedStyle(e).getPropertyValue(i).trim();return t&&"none"!==t&&"normal"!==t?t:null}function J(){const e=this[f];e&&N(e,I(e),T)}function K(e,t){let n=e[d];t?(n||(n=e.ownerDocument.createElement("span"),n.className=l,n.setAttribute("aria-hidden","true"),n[f]=e,n.addEventListener("click",J),e[d]=n,b.add(e)),n.style.getPropertyValue(a)!==t&&n.style.setProperty(a,t),e.nextSibling!==n&&e.after(n)):n&&(n.remove(),e[d]=null,b.delete(e))}function Q(t){const n=[];for(const r of t){let t=null;r.isConnected&&r.hasAttribute(e)?R(r)?(y.delete(r),t=U(r)):y.add(r):y.delete(r),n.push([r,t])}for(const[e,t]of n)K(e,t)}function W(t){const n=new Set;let r=!1;const o=t=>{if(t.nodeType===Node.ELEMENT_NODE&&!t[f]){t.hasAttribute(e)&&n.add(t);for(const r of t.querySelectorAll(`[${e}]`))n.add(r)}};for(const s of t){if("childList"===s.type){for(const e of s.addedNodes)e.nodeType!==Node.ELEMENT_NODE||e[f]||(r=!0,o(e));continue}const t=s.target;switch(s.attributeName){case e:null!==s.oldValue&&H(t),""!==t.getAttribute(e)&&(t[m]=null),n.add(t);break;case"id":r=!0;break;default:o(t)}}for(const e of[...h]){const t=e[c].target;e.isConnected&&t&&t.isConnected||H(e)}if(r)for(const e of y)n.add(e);for(const e of[...b])n.has(e)||(R(e)?e.nextSibling!==e[d]&&e.after(e[d]):K(e,null));Q(n)}function X(){!function(){const t=document.createElement("style");t.textContent=`@property ${r} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}\n @property ${o} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}\n @property ${s} {syntax: "[ normal | <time> ]{1,2}"; inherits: false; initial-value: normal;}\n @property ${i} {syntax: "*"; inherits: false;}`,document.head.appendChild(t);const n=`span.${l}[aria-hidden]`,u=document.createElement("style");u.textContent=`:where(button[${e}]) {\n -webkit-user-select: none;\n user-select: none;\n }\n :where(${n}) {\n display: inline-block;\n vertical-align: middle;\n color: inherit;\n background-color: transparent;\n box-sizing: border-box;\n min-inline-size: 24px;\n min-block-size: 24px;\n min-block-size: max(24px, 1lh);\n align-content: center;\n text-align: center;\n -webkit-user-select: none;\n user-select: none;\n cursor: help;\n }\n :where(${n}:hover) {\n background-color: color-mix(in oklab, currentColor 10%, transparent);\n }\n :where(${n}:active) {\n background-color: color-mix(in oklab, currentColor 20%, transparent);\n }\n :where(${n})::before {\n content: var(${a});\n }`,document.head.prepend(u),document[c]={globalPropsStyle:t,uaStyle:u}}(),function(){const e=(e,t)=>{const n=e.composedPath(),r=n.length?n[0]:e.target;if(r instanceof Element)for(let e=r;e;e=D(e))j(e,t)};document.body.addEventListener("mouseover",t=>e(t,L)),document.body.addEventListener("mouseout",t=>e(t,A)),document.body.addEventListener("focusin",t=>e(t,x)),document.body.addEventListener("focusout",t=>e(t,k)),document.body.addEventListener("keydown",e=>{"Escape"!==e.key||e.defaultPrevented||[...h].reverse().forEach(e=>{N(e,e[c].target,E,{force:!0})})}),document.body.addEventListener("touchstart",e=>{g=!0;const t=e.target.closest("button[interestfor]");t&&(q(t),t[c].longPressTimer=setTimeout(()=>{t[c].state===E&&N(t,I(t),T),t[c].longPressTimer=null},500))});const t=e=>{const t=e.target.closest("button[interestfor]");t&&t[c]?.longPressTimer&&(clearTimeout(t[c].longPressTimer),t[c].longPressTimer=null)};document.body.addEventListener("touchend",e=>{t(e),g=!1}),document.body.addEventListener("touchmove",t)}(),new MutationObserver(W).observe(document,{childList:!0,subtree:!0,attributes:!0,attributeOldValue:!0,attributeFilter:[e,"id","href","disabled"]}),Q(document.querySelectorAll(`[${e}]`)),console.log(`interestfor polyfill installed (native: ${w}).`)}!function(){const t={configurable:!0,enumerable:!0,get(){return C(this)},set(t){if(null==t)this[m]=null,this.removeAttribute(e);else{if(!(t instanceof Element))throw new TypeError("Failed to set the 'interestForElement' property: The provided value is not of type 'Element'.");this[m]=t,this.setAttribute(e,"")}}};[window.HTMLButtonElement,window.HTMLAnchorElement,window.HTMLAreaElement,window.SVGAElement].forEach(e=>{e&&!Object.prototype.hasOwnProperty.call(e.prototype,"interestForElement")&&Object.defineProperty(e.prototype,"interestForElement",t)})}(),"complete"===document.readyState?X():window.addEventListener("load",X)}();
@@ -4,8 +4,12 @@
4
4
 
5
5
  <a interestfor=foo href=bar>Link - hover me, don't click me</a>
6
6
  <button interestfor=foo>
7
- <span style="margin:10px;">Button - hover me, don't click me</span></a>
7
+ <span style="margin:10px;">Button - hover me, don't click me</span></button>
8
8
  <div popover id=foo>Popover</div>
9
+ <p>
10
+ <a interestfor=bar href=bar class=with-button>Link with an interest button - click the "?"</a>
11
+ </p>
12
+ <div popover id=bar>Popover shown by the interest button</div>
9
13
 
10
14
  <style>
11
15
  [interestfor] {
@@ -23,4 +27,12 @@
23
27
  [popover] {
24
28
  position-area: right center;
25
29
  }
30
+ /* Native ::interest-button */
31
+ .with-button::interest-button {
32
+ content: "?";
33
+ }
34
+ /* Polyfilled ::interest-button */
35
+ .with-button {
36
+ --interest-button-content: "?";
37
+ }
26
38
  </style>