interestfor 1.0.8 → 1.0.10

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
@@ -27,14 +27,84 @@ can be done by hovering the element with the mouse, or focusing it with the
27
27
  keyboard. If the target is a popover, it will automatically be shown and hidden
28
28
  upon receiving and losing interest.
29
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
+
30
52
  Again, this is a quick summary. For the complete details, please [read the
31
- 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.
32
101
 
33
102
  ## Touchscreen support
34
103
 
35
104
  This polyfill provides limited touchscreen support. On a touchscreen,
36
105
  long-pressing a `<button>` element that has an `interestfor` attribute will show
37
- 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.
38
108
 
39
109
  However, long-pressing a link (`<a>` element) is **not** supported. This is
40
110
  because on today's browsers, long-pressing a link element brings up a useful
@@ -44,7 +114,8 @@ be surpressed completely. There is currently no way to provide access to the
44
114
  long-press context menu and *also* provide a way to show interest. Since the
45
115
  context menu contains items that users often use, eliminating access to the
46
116
  context menu is not what most people want. So this polyfill will not support
47
- 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).
48
119
 
49
120
  This is one of the primary motivations for Chromium championing this as a
50
121
  native API in all browsers: to support all users, including those using
@@ -108,6 +179,30 @@ native feature:
108
179
  class names. This is to avoid the entire selector being invalidated if the
109
180
  browser does not recognize the native pseudo class (e.g. `:interest-source`).
110
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
+
111
206
 
112
207
  ## Tests
113
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "interestfor",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
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",
@@ -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
@@ -77,7 +92,12 @@
77
92
  // Enum-like state and source
78
93
  const InterestState = {
79
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.
80
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",
81
101
  };
82
102
  const Source = {
83
103
  Hover: "hover",
@@ -96,7 +116,7 @@
96
116
  }
97
117
 
98
118
  // Gain or lose interest
99
- function GainOrLoseInterest(invoker, target, newState) {
119
+ function GainOrLoseInterest(invoker, target, newState, options = {}) {
100
120
  if (!invoker || !target) {
101
121
  return false;
102
122
  }
@@ -111,9 +131,21 @@
111
131
 
112
132
  // If gaining, handle existing invoker
113
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
+ }
114
143
  const existing = GetInterestInvoker(target);
115
144
  if (existing) {
116
145
  if (existing === invoker) {
146
+ if (newState === InterestState.ExplicitInterest) {
147
+ existing[dataField].state = InterestState.ExplicitInterest;
148
+ }
117
149
  existing[dataField].clearLostTask();
118
150
  return false;
119
151
  } else {
@@ -130,73 +162,182 @@
130
162
  }
131
163
  }
132
164
  // finally apply interest
133
- return applyState(invoker, newState);
165
+ return applyState(invoker, target, newState);
134
166
  }
135
167
 
136
168
  // losing interest
137
- clearState(invoker);
138
- return true;
169
+ return clearState(invoker, options);
139
170
  }
140
171
 
141
172
  // Schedule tasks
142
173
  function ScheduleInterestGainedTask(invoker, newState) {
174
+ const target = GetInterestForTarget(invoker);
175
+ if (!target) {
176
+ return;
177
+ }
143
178
  const delay = getDelaySeconds(invoker, interestDelayStartProp) * 1000;
144
- if (!isFinite(delay) || delay < 0) {
179
+ if (!isSchedulableDelay(delay)) {
145
180
  return;
146
181
  }
147
182
  invoker[dataField].clearGainedTask();
148
183
  invoker[dataField].gainedTimer = setTimeout(() => {
149
- GainOrLoseInterest(invoker, GetInterestForTarget(invoker), newState);
184
+ GainOrLoseInterest(invoker, target, newState);
150
185
  }, delay);
151
186
  }
152
187
 
153
188
  function ScheduleInterestLostTask(invoker) {
154
189
  const delay = getDelaySeconds(invoker, interestDelayEndProp) * 1000;
155
- if (!isFinite(delay) || delay < 0) {
190
+ if (!isSchedulableDelay(delay)) {
156
191
  return;
157
192
  }
158
- invoker[dataField].clearLostTask();
159
- invoker[dataField].lostTimer = setTimeout(() => {
160
- GainOrLoseInterest(
161
- invoker,
162
- GetInterestForTarget(invoker),
163
- InterestState.NoInterest
164
- );
193
+ const data = invoker[dataField];
194
+ data.clearLostTask();
195
+ const target = data.target;
196
+ data.lostTimer = setTimeout(() => {
197
+ GainOrLoseInterest(invoker, target, InterestState.NoInterest);
165
198
  }, delay);
166
199
  }
167
200
 
168
201
  // Helpers
169
202
  function GetInterestInvoker(target) {
170
203
  const inv = target[targetDataField]?.invoker || null;
171
- return inv && inv[dataField]?.state !== InterestState.NoInterest
172
- ? inv
173
- : 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;
174
217
  }
218
+
219
+ // Returns the target of a valid interest invoker, or null.
175
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) {
176
250
  const id = el.getAttribute(attributeName);
177
- 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;
178
267
  }
179
268
 
180
- function onPopoverToggle(e) {
181
- const popover = e.target;
182
- if (e.newState === 'closed') {
183
- const invoker = popover[targetDataField]?.invoker;
184
- if (invoker) {
185
- 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;
186
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;
187
288
  }
188
289
  }
189
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
+
190
326
  function parseTimeValue(val) {
191
327
  const s = String(val).trim();
192
- const m_s = s.match(/^([\d.]+)s$/);
328
+ const m_s = s.match(/^([\d.]+(?:e[+-]?\d+)?)s$/i);
193
329
  if (m_s) {
194
330
  return parseFloat(m_s[1]);
195
331
  }
196
- const m_ms = s.match(/^([\d.]+)ms$/);
332
+ const m_ms = s.match(/^([\d.]+(?:e[+-]?\d+)?)ms$/i);
197
333
  if (m_ms) {
198
334
  return parseFloat(m_ms[1]) / 1000;
199
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
+ }
200
341
  return parseFloat(s) || 0;
201
342
  }
202
343
 
@@ -232,32 +373,44 @@
232
373
  }
233
374
  }
234
375
 
235
- // If we got here, the effective value is 'normal'. Return the default.
236
- 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;
237
378
  }
238
379
 
239
380
  // Actual state transitions
240
- function applyState(invoker, newState) {
381
+ function applyState(invoker, target, newState) {
241
382
  const data = invoker[dataField];
242
- const target = GetInterestForTarget(invoker);
243
383
  switch (newState) {
244
384
  case InterestState.FullInterest:
385
+ case InterestState.ExplicitInterest:
245
386
  if (data.state !== InterestState.NoInterest) {
246
387
  throw new Error("Invalid state");
247
388
  }
248
- const shouldContinue = target.dispatchEvent(new InterestEvent(interestEventName, { source: invoker }));
249
- if (!shouldContinue) return;
250
- try {
251
- target.showPopover({ source: invoker });
252
- } catch {}
253
- 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();
254
399
  if (!target[targetDataField]) {
255
400
  target[targetDataField] = {};
256
401
  }
257
402
  target[targetDataField].invoker = invoker;
258
403
  if (target.hasAttribute('popover')) {
259
- target[targetDataField].toggleListener = onPopoverToggle;
260
- 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
+ }
261
414
  }
262
415
  invokersWithInterest.add(invoker);
263
416
  invoker.classList.add("interest-source");
@@ -281,33 +434,88 @@
281
434
  return true;
282
435
  }
283
436
 
284
- function clearState(invoker, force = false) {
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 } = {}) {
285
441
  const data = invoker[dataField];
286
442
  clearTimeout(data.gainedTimer);
287
443
  clearTimeout(data.lostTimer);
288
- if (data.state !== InterestState.NoInterest) {
289
- const target = GetInterestForTarget(invoker);
290
- const shouldContinue = target.dispatchEvent(new InterestEvent(loseInterestEventName, { source: invoker }));
291
- if (!force && !shouldContinue) return;
292
- try {
293
- target.hidePopover();
294
- } catch {}
295
- if (target[targetDataField]?.toggleListener) {
296
- target.removeEventListener('toggle', target[targetDataField].toggleListener);
297
- }
298
- target[targetDataField] = null;
299
- invokersWithInterest.delete(invoker);
300
- invoker.classList.remove("interest-source");
301
- target.classList.remove("interest-target");
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) {
472
+ const data = invoker[dataField];
473
+ if (!data) {
474
+ return;
475
+ }
476
+ clearTimeout(data.gainedTimer);
477
+ clearTimeout(data.lostTimer);
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
+ }
302
490
  if (!isPlainHint(target)) {
303
491
  invoker.setAttribute("aria-expanded", "false");
304
492
  }
305
- if (data.anchorName) {
306
- invoker.style.anchorName = "";
493
+ }
494
+ if (data.anchorName) {
495
+ invoker.style.anchorName = "";
496
+ if (target) {
307
497
  target.style.positionAnchor = "";
308
- data.anchorName = null;
309
498
  }
310
- 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;
311
519
  }
312
520
  }
313
521
 
@@ -370,9 +578,11 @@
370
578
  if (el[dataField]) return;
371
579
  el[dataField] = {
372
580
  state: InterestState.NoInterest,
581
+ target: null,
373
582
  gainedTimer: null,
374
583
  lostTimer: null,
375
584
  longPressTimer: null,
585
+ suppressNextFocusInterest: false,
376
586
  clearGainedTask() {
377
587
  clearTimeout(this.gainedTimer);
378
588
  },
@@ -386,6 +596,49 @@
386
596
  }
387
597
  }
388
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
+
389
642
  function HandleInterestHoverOrFocus(el, source) {
390
643
  if (touchInProgress) {
391
644
  return;
@@ -394,52 +647,235 @@
394
647
  return;
395
648
  }
396
649
  const target = GetInterestForTarget(el);
397
- if (!target) {
398
- const containingTarget = el.closest(".interest-target");
399
- if (containingTarget) {
400
- const upstreamInvoker = GetInterestInvoker(containingTarget);
401
- if (upstreamInvoker) {
402
- if (source === Source.Hover || source === Source.Focus) {
403
- upstreamInvoker[dataField].clearLostTask();
404
- } else {
405
- if (source === Source.Blur || !el.matches(":hover")) {
406
- ScheduleInterestLostTask(upstreamInvoker);
407
- }
408
- }
409
- }
410
- }
411
- return;
650
+ if (target) {
651
+ initializeDataField(el);
412
652
  }
413
- initializeDataField(el);
414
653
  const data = el[dataField];
415
- const upstreamInvoker = GetInterestInvoker(el);
416
654
 
417
655
  // Hover or focus
418
656
  if (source === Source.Hover || source === Source.Focus) {
419
- data.clearLostTask && data.clearLostTask();
420
- 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)) {
421
665
  upstreamInvoker[dataField].clearLostTask();
422
666
  }
423
- ScheduleInterestGainedTask(el, InterestState.FullInterest);
667
+ if (
668
+ target &&
669
+ !suppressFocusInterest &&
670
+ data.state === InterestState.NoInterest
671
+ ) {
672
+ ScheduleInterestGainedTask(el, InterestState.FullInterest);
673
+ }
424
674
  } else {
425
675
  // Dehover or blur
426
- data.clearGainedTask && data.clearGainedTask();
427
- if (data.state !== InterestState.NoInterest) {
428
- 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
+ }
429
683
  }
430
- if (upstreamInvoker) {
431
- upstreamInvoker[dataField].clearGainedTask();
432
- if (source === Source.Blur || !el.matches(":hover")) {
684
+ if (source === Source.Blur || !el.matches(":hover")) {
685
+ for (const upstreamInvoker of allSourceInterestInvokers(el)) {
433
686
  ScheduleInterestLostTask(upstreamInvoker);
434
687
  }
435
688
  }
436
689
  }
437
690
  }
438
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
+
439
869
  // Attach listeners
440
870
  function addEventHandlers() {
441
871
  const handler = (e, source) => {
442
- 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)) {
443
879
  HandleInterestHoverOrFocus(el, source);
444
880
  }
445
881
  };
@@ -448,9 +884,16 @@
448
884
  document.body.addEventListener("focusin", (e) => handler(e, Source.Focus));
449
885
  document.body.addEventListener("focusout", (e) => handler(e, Source.Blur));
450
886
  document.body.addEventListener("keydown", (e) => {
451
- if (e.key === "Escape") {
452
- invokersWithInterest.forEach((invoker) => {
453
- clearState(invoker, true);
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
+ );
454
897
  });
455
898
  }
456
899
  });
@@ -462,7 +905,15 @@
462
905
  if (invoker) {
463
906
  initializeDataField(invoker);
464
907
  invoker[dataField].longPressTimer = setTimeout(() => {
465
- 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
+ }
466
917
  invoker[dataField].longPressTimer = null;
467
918
  }, longPressTime);
468
919
  }
@@ -484,17 +935,95 @@
484
935
  // CSS registration
485
936
  function registerCustomProperties() {
486
937
  const style = document.createElement("style");
938
+ // `@property` syntax can't express "one or two of `normal | <time>`", so
939
+ // the shorthand uses the universal syntax. It is still registered, so that
940
+ // it doesn't inherit.
487
941
  style.textContent = `@property ${interestDelayStartProp} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}
488
942
  @property ${interestDelayEndProp} {syntax: "normal | <time>"; inherits: false; initial-value: normal;}
489
- @property ${interestDelayProp} {syntax: "[ normal | <time> ]{1,2}"; inherits: false; initial-value: normal;}`;
943
+ @property ${interestDelayProp} {syntax: "*"; inherits: false;}
944
+ @property ${interestButtonContentProp} {syntax: "*"; inherits: false;}`;
490
945
  document.head.appendChild(style);
491
- document[dataField] = { globalPropsStyle: style };
946
+ // Equivalents of the UA stylesheet rules for interest invokers. These are
947
+ // inserted first, and use :where(), so that author styles take precedence.
948
+ const button = `span.${interestButtonClass}[aria-hidden]`;
949
+ const uaStyle = document.createElement("style");
950
+ uaStyle.textContent = `:where(button[${attributeName}]) {
951
+ -webkit-user-select: none;
952
+ user-select: none;
953
+ }
954
+ :where(${button}) {
955
+ display: inline-block;
956
+ vertical-align: middle;
957
+ color: inherit;
958
+ background-color: transparent;
959
+ box-sizing: border-box;
960
+ min-inline-size: 24px;
961
+ min-block-size: 24px;
962
+ min-block-size: max(24px, 1lh);
963
+ align-content: center;
964
+ text-align: center;
965
+ -webkit-user-select: none;
966
+ user-select: none;
967
+ cursor: help;
968
+ }
969
+ :where(${button}:hover) {
970
+ background-color: color-mix(in oklab, currentColor 10%, transparent);
971
+ }
972
+ :where(${button}:active) {
973
+ background-color: color-mix(in oklab, currentColor 20%, transparent);
974
+ }
975
+ :where(${button})::before {
976
+ content: var(${interestButtonInternalContentProp});
977
+ }`;
978
+ document.head.prepend(uaStyle);
979
+ document[dataField] = { globalPropsStyle: style, uaStyle };
980
+ }
981
+
982
+ // The `interestForElement` IDL attribute, which reflects the `interestfor`
983
+ // content attribute as an element reference.
984
+ function installInterestForElement() {
985
+ const descriptor = {
986
+ configurable: true,
987
+ enumerable: true,
988
+ get() {
989
+ return getAttrAssociatedElement(this);
990
+ },
991
+ set(value) {
992
+ if (value === null || value === undefined) {
993
+ this[explicitTargetField] = null;
994
+ this.removeAttribute(attributeName);
995
+ } else if (value instanceof Element) {
996
+ this[explicitTargetField] = value;
997
+ this.setAttribute(attributeName, "");
998
+ } else {
999
+ throw new TypeError(
1000
+ "Failed to set the 'interestForElement' property: The provided value is not of type 'Element'."
1001
+ );
1002
+ }
1003
+ },
1004
+ };
1005
+ [
1006
+ window.HTMLButtonElement,
1007
+ window.HTMLAnchorElement,
1008
+ window.HTMLAreaElement,
1009
+ window.SVGAElement,
1010
+ ].forEach((iface) => {
1011
+ if (
1012
+ iface &&
1013
+ !Object.prototype.hasOwnProperty.call(iface.prototype, "interestForElement")
1014
+ ) {
1015
+ Object.defineProperty(iface.prototype, "interestForElement", descriptor);
1016
+ }
1017
+ });
492
1018
  }
1019
+ installInterestForElement();
493
1020
 
494
1021
  // Initialize
495
1022
  function init() {
496
1023
  registerCustomProperties();
497
1024
  addEventHandlers();
1025
+ observeMutations();
1026
+ updateInterestButtons(document.querySelectorAll(`[${attributeName}]`));
498
1027
  console.log(`interestfor polyfill installed (native: ${nativeSupported}).`);
499
1028
  }
500
1029
  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",s="__interestForData",i="__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})}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=p(e);return t!==p(this.target||document)?t.host:e}});const u="none",d="full",m="hover",f="dehover",h="focus",g="blur";function p(e){return e&&"function"==typeof e.getRootNode?e.getRootNode():e&&e.parentNode?p(e.parentNode):e}function y(t,n,r){if(!t||!n)return!1;if(!t.isConnected||T(t)!==n||r===u&&b(n)!==t)return!1;if(r!==u){const o=b(n);if(o){if(o===t)return o[s].clearLostTask(),!1;if(!y(o,n,u))return!1;if(!t.isConnected||T(t)!==n)return!1}return function(t,n){const r=t[s],o=T(t);if(n!==d)throw new Error("Invalid state");if(r.state!==u)throw new Error("Invalid state");if(!o.dispatchEvent(new InterestEvent(e,{source:t})))return;try{o.showPopover({source:t})}catch{}if(r.state=d,o[i]||(o[i]={}),o[i].invoker=t,o.hasAttribute("popover")&&(o[i].toggleListener=L,o.addEventListener("toggle",L)),a.add(t),t.classList.add("interest-source"),o.classList.add("interest-target"),P(o)||t.setAttribute("aria-expanded","true"),"none"===getComputedStyle(t).anchorName&&"auto"===getComputedStyle(o).positionAnchor){const e=`--interest-anchor-${Math.random().toString(36).substring(2)}`;t.style.anchorName=e,o.style.positionAnchor=e,r.anchorName=e}return!0}(t,r)}return k(t),!0}function v(e){const t=1e3*w(e,r);!isFinite(t)||t<0||(e[s].clearLostTask(),e[s].lostTimer=setTimeout(()=>{y(e,T(e),u)},t))}function b(e){const t=e[i]?.invoker||null;return t&&t[s]?.state!==u?t:null}function T(e){const t=e.getAttribute("interestfor");return document.getElementById(t)}function L(e){const t=e.target;if("closed"===e.newState){const e=t[i]?.invoker;e&&y(e,t,u)}}function E(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),s=r.getPropertyValue(t).trim();if("normal"!==s.toLowerCase())return E(s);const i=r.getPropertyValue(o).trim();if(i&&"normal"!==i.toLowerCase()){const e=i.split(/\s+/).filter(e=>e.length>0);if(e.length>0){const r=e[0],o=e.length>1?e[1]:r;let s;if(s=t===n?r:o,"normal"!==s.toLowerCase())return E(s)}}return t===n?.5:.25}function k(e,n=!1){const r=e[s];if(clearTimeout(r.gainedTimer),clearTimeout(r.lostTimer),r.state!==u){const o=T(e),s=o.dispatchEvent(new InterestEvent(t,{source:e}));if(!n&&!s)return;try{o.hidePopover()}catch{}o[i]?.toggleListener&&o.removeEventListener("toggle",o[i].toggleListener),o[i]=null,a.delete(e),e.classList.remove("interest-source"),o.classList.remove("interest-target"),P(o)||e.setAttribute("aria-expanded","false"),r.anchorName&&(e.style.anchorName="",o.style.positionAnchor="",r.anchorName=null),r.state=u}}const A=["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(A))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 S(e){if(e[s])return;e[s]={state:u,gainedTimer:null,lostTimer:null,longPressTimer:null,clearGainedTask(){clearTimeout(this.gainedTimer)},clearLostTask(){clearTimeout(this.lostTimer)}};const t=T(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(!T(e)){const n=e.closest(".interest-target");if(n){const r=b(n);r&&(t===m||t===h?r[s].clearLostTask():t!==g&&e.matches(":hover")||v(r))}return}S(e);const r=e[s],o=b(e);t===m||t===h?(r.clearLostTask&&r.clearLostTask(),o&&o[s].clearLostTask(),function(e,t){const r=1e3*w(e,n);!isFinite(r)||r<0||(e[s].clearGainedTask(),e[s].gainedTimer=setTimeout(()=>{y(e,T(e),t)},r))}(e,d)):(r.clearGainedTask&&r.clearGainedTask(),r.state!==u&&v(e),o&&(o[s].clearGainedTask(),t!==g&&e.matches(":hover")||v(o)))}function x(){!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[s]={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=>{k(e,!0)})}),document.body.addEventListener("touchstart",e=>{l=!0;const t=e.target.closest("button[interestfor]");t&&(S(t),t[s].longPressTimer=setTimeout(()=>{y(t,T(t),d),t[s].longPressTimer=null},500))});const t=e=>{const t=e.target.closest("button[interestfor]");t&&t[s]?.longPressTimer&&(clearTimeout(t[s].longPressTimer),t[s].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?x():window.addEventListener("load",x)}();
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: "*"; inherits: false;}\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>