@internetarchive/collection-browser 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.editorconfig +29 -29
  2. package/.github/workflows/ci.yml +27 -27
  3. package/.github/workflows/gh-pages-main.yml +39 -39
  4. package/.github/workflows/npm-publish.yml +39 -39
  5. package/.github/workflows/pr-preview.yml +38 -38
  6. package/.husky/pre-commit +4 -4
  7. package/.prettierignore +1 -1
  8. package/LICENSE +661 -661
  9. package/README.md +83 -83
  10. package/dist/src/collection-browser.js +683 -683
  11. package/dist/src/collection-browser.js.map +1 -1
  12. package/dist/src/collection-facets/more-facets-content.js +118 -118
  13. package/dist/src/collection-facets/more-facets-content.js.map +1 -1
  14. package/dist/src/collection-facets.js +265 -266
  15. package/dist/src/collection-facets.js.map +1 -1
  16. package/dist/src/data-source/collection-browser-data-source.js.map +1 -1
  17. package/dist/src/data-source/collection-browser-query-state.js.map +1 -1
  18. package/dist/src/data-source/models.js.map +1 -1
  19. package/dist/src/tiles/base-tile-component.js.map +1 -1
  20. package/dist/src/tiles/grid/account-tile.js +36 -36
  21. package/dist/src/tiles/grid/account-tile.js.map +1 -1
  22. package/dist/src/tiles/grid/collection-tile.js +77 -77
  23. package/dist/src/tiles/grid/collection-tile.js.map +1 -1
  24. package/dist/src/tiles/grid/item-tile.js +137 -137
  25. package/dist/src/tiles/grid/item-tile.js.map +1 -1
  26. package/dist/src/tiles/hover/hover-pane-controller.d.ts +9 -1
  27. package/dist/src/tiles/hover/hover-pane-controller.js +105 -37
  28. package/dist/src/tiles/hover/hover-pane-controller.js.map +1 -1
  29. package/dist/src/tiles/hover/tile-hover-pane.d.ts +1 -0
  30. package/dist/src/tiles/hover/tile-hover-pane.js +115 -112
  31. package/dist/src/tiles/hover/tile-hover-pane.js.map +1 -1
  32. package/dist/src/tiles/list/tile-list-compact.js +99 -99
  33. package/dist/src/tiles/list/tile-list-compact.js.map +1 -1
  34. package/dist/src/tiles/list/tile-list.js +297 -297
  35. package/dist/src/tiles/list/tile-list.js.map +1 -1
  36. package/dist/src/tiles/tile-dispatcher.d.ts +4 -1
  37. package/dist/src/tiles/tile-dispatcher.js +231 -204
  38. package/dist/src/tiles/tile-dispatcher.js.map +1 -1
  39. package/dist/src/utils/format-date.js.map +1 -1
  40. package/dist/test/collection-browser.test.js +189 -189
  41. package/dist/test/collection-browser.test.js.map +1 -1
  42. package/dist/test/tiles/grid/item-tile.test.js +77 -77
  43. package/dist/test/tiles/grid/item-tile.test.js.map +1 -1
  44. package/dist/test/tiles/hover/hover-pane-controller.test.js +68 -21
  45. package/dist/test/tiles/hover/hover-pane-controller.test.js.map +1 -1
  46. package/dist/test/tiles/list/tile-list-compact.test.js +70 -70
  47. package/dist/test/tiles/list/tile-list-compact.test.js.map +1 -1
  48. package/dist/test/tiles/list/tile-list.test.js +126 -126
  49. package/dist/test/tiles/list/tile-list.test.js.map +1 -1
  50. package/dist/test/tiles/tile-dispatcher.test.js +130 -52
  51. package/dist/test/tiles/tile-dispatcher.test.js.map +1 -1
  52. package/dist/test/utils/format-date.test.js.map +1 -1
  53. package/eslint.config.mjs +53 -53
  54. package/index.html +24 -24
  55. package/local.archive.org.cert +86 -86
  56. package/local.archive.org.key +27 -27
  57. package/package.json +118 -117
  58. package/renovate.json +6 -6
  59. package/src/collection-browser.ts +2829 -2829
  60. package/src/collection-facets/more-facets-content.ts +639 -639
  61. package/src/collection-facets.ts +994 -995
  62. package/src/data-source/collection-browser-data-source.ts +1401 -1401
  63. package/src/data-source/collection-browser-query-state.ts +65 -65
  64. package/src/data-source/models.ts +43 -43
  65. package/src/tiles/base-tile-component.ts +65 -65
  66. package/src/tiles/grid/account-tile.ts +113 -113
  67. package/src/tiles/grid/collection-tile.ts +163 -163
  68. package/src/tiles/grid/item-tile.ts +340 -340
  69. package/src/tiles/hover/hover-pane-controller.ts +613 -517
  70. package/src/tiles/hover/tile-hover-pane.ts +184 -180
  71. package/src/tiles/list/tile-list-compact.ts +239 -239
  72. package/src/tiles/list/tile-list.ts +700 -700
  73. package/src/tiles/tile-dispatcher.ts +517 -490
  74. package/src/utils/format-date.ts +62 -62
  75. package/test/collection-browser.test.ts +2403 -2403
  76. package/test/tiles/grid/item-tile.test.ts +520 -520
  77. package/test/tiles/hover/hover-pane-controller.test.ts +418 -353
  78. package/test/tiles/list/tile-list-compact.test.ts +282 -282
  79. package/test/tiles/list/tile-list.test.ts +552 -552
  80. package/test/tiles/tile-dispatcher.test.ts +283 -187
  81. package/test/utils/format-date.test.ts +89 -89
  82. package/tsconfig.json +20 -20
  83. package/web-dev-server.config.mjs +30 -30
  84. package/web-test-runner.config.mjs +41 -41
@@ -1,517 +1,613 @@
1
- import type { SortParam } from '@internetarchive/search-service';
2
- import {
3
- html,
4
- HTMLTemplateResult,
5
- nothing,
6
- ReactiveController,
7
- ReactiveControllerHost,
8
- } from 'lit';
9
- import type { TileModel } from '../../models';
10
- import type { CollectionTitles } from '../../data-source/models';
11
-
12
- type HoverPaneState = 'hidden' | 'shown' | 'fading-out';
13
-
14
- export interface HoverPaneProperties {
15
- model?: TileModel;
16
- baseNavigationUrl?: string;
17
- baseImageUrl?: string;
18
- loggedIn: boolean;
19
- suppressBlurring: boolean;
20
- sortParam: SortParam | null;
21
- collectionTitles?: CollectionTitles;
22
- }
23
-
24
- export interface HoverPaneControllerOptions {
25
- offsetX?: number;
26
- offsetY?: number;
27
- enableLongPress?: boolean;
28
- showDelay?: number;
29
- hideDelay?: number;
30
- longPressDelay?: number;
31
- mobileBreakpoint?: number;
32
- }
33
-
34
- /** A common interface for providing a hover pane element. */
35
- export interface HoverPaneProviderInterface {
36
- /** Returns the provider's currently rendered hover pane element. */
37
- getHoverPane(): HTMLElement | undefined;
38
- /** Returns properties that should be passed to the hover pane. */
39
- getHoverPaneProps(): HoverPaneProperties;
40
- }
41
-
42
- export interface ToggleHoverPaneOptions {
43
- coords: { x: number; y: number };
44
- enableTouchBackdrop?: boolean;
45
- }
46
-
47
- /**
48
- * An interface for interacting with hover pane controllers (e.g.,
49
- * to retrieve their current hover pane template).
50
- */
51
- export interface HoverPaneControllerInterface extends ReactiveController {
52
- /**
53
- * Returns the hover pane template to render based on this controller's
54
- * current state. The returned template may be `nothing` if the hover
55
- * pane should not currently be rendered.
56
- */
57
- getTemplate(): HTMLTemplateResult | typeof nothing;
58
-
59
- /**
60
- * Requests to manually toggle the state of the hover pane.
61
- * If the hover pane is already shown, it will begin fading out and then
62
- * subsequently be hidden and removed. If the hover pane is already fading
63
- * out or hidden, it will fade back in and be shown.
64
- */
65
- toggleHoverPane(options: ToggleHoverPaneOptions): void;
66
- }
67
-
68
- const clamp = (val: number, min = -Infinity, max = Infinity) =>
69
- Math.max(min, Math.min(val, max));
70
-
71
- export class HoverPaneController implements HoverPaneControllerInterface {
72
- /**
73
- * The hover pane element attached to this controller's host.
74
- */
75
- private hoverPane?: HTMLElement;
76
-
77
- /**
78
- * The properties to be passed to the hover pane element
79
- */
80
- private hoverPaneProps?: HoverPaneProperties;
81
-
82
- /**
83
- * The breakpoint (in pixels) below which the mobile interface should be used.
84
- */
85
- private mobileBreakpoint?: number = 600;
86
-
87
- /**
88
- * The number of horizontal pixels the hover pane should be offset from the
89
- * pointer position.
90
- */
91
- private offsetX: number = -10;
92
-
93
- /**
94
- * The number of vertical pixels the hover pane should be offset from the
95
- * pointer position.
96
- */
97
- private offsetY: number = 15;
98
-
99
- /**
100
- * The delay between the mouse idling within the host element and when the hover
101
- * pane should begin fading in (in milliseconds).
102
- */
103
- private showDelay: number = 300;
104
-
105
- /**
106
- * The delay between when the mouse leaves the host element and when the hover
107
- * pane should begin fading out (in milliseconds).
108
- */
109
- private hideDelay: number = 100;
110
-
111
- /**
112
- * The delay between when a touch event begins on the host element and when the
113
- * hover pane should begin fading in (in milliseconds).
114
- */
115
- private longPressDelay: number = 600;
116
-
117
- /**
118
- * Whether long press interactions should cause the hover pane to appear (when
119
- * below the mobile breakpoint).
120
- */
121
- private enableLongPress: boolean = false;
122
-
123
- /**
124
- * Used to control the current state of this provider's hover pane.
125
- * - `'hidden'` => The hover pane is not present at all.
126
- * - `'shown'` => The hover pane is either fading in or fully visible.
127
- * - `'fading-out'` => The hover pane is fading out and about to be removed.
128
- */
129
- private hoverPaneState: HoverPaneState = 'hidden';
130
-
131
- /** The timer ID for showing the hover pane */
132
- private showTimer?: number;
133
-
134
- /** The timer ID for hiding the hover pane */
135
- private hideTimer?: number;
136
-
137
- /** The timer ID for recognizing a long press event */
138
- private longPressTimer?: number;
139
-
140
- /**
141
- * Whether the touch backdrop should currently be rendered irrespective of other touch
142
- * interactions being enabled.
143
- */
144
- private forceTouchBackdrop: boolean = false;
145
-
146
- /** A record of the last mouse position on the host element, for positioning the hover pane */
147
- private lastPointerClientPos = { x: 0, y: 0 };
148
-
149
- constructor(
150
- /** The host element to which this controller should attach listeners */
151
- private readonly host: ReactiveControllerHost &
152
- HoverPaneProviderInterface &
153
- HTMLElement,
154
- /** Options for adjusting the hover pane behavior (offsets, delays, etc.) */
155
- options: HoverPaneControllerOptions = {},
156
- ) {
157
- this.mobileBreakpoint = options.mobileBreakpoint ?? this.mobileBreakpoint;
158
- this.offsetX = options.offsetX ?? this.offsetX;
159
- this.offsetY = options.offsetY ?? this.offsetY;
160
- this.showDelay = options.showDelay ?? this.showDelay;
161
- this.hideDelay = options.hideDelay ?? this.hideDelay;
162
- this.longPressDelay = options.longPressDelay ?? this.longPressDelay;
163
- this.enableLongPress = options.enableLongPress ?? this.enableLongPress;
164
-
165
- this.host.addController(this);
166
- }
167
-
168
- hostConnected(): void {
169
- this.attachListeners();
170
- }
171
-
172
- hostDisconnected(): void {
173
- this.detachListeners();
174
- }
175
-
176
- hostUpdated(): void {
177
- this.hoverPane = this.host.getHoverPane();
178
- this.hoverPaneProps = this.host.getHoverPaneProps();
179
- }
180
-
181
- /** @inheritdoc */
182
- getTemplate(): HTMLTemplateResult | typeof nothing {
183
- this.hoverPaneProps = this.host.getHoverPaneProps();
184
-
185
- return this.shouldRenderHoverPane
186
- ? html` ${this.touchBackdropTemplate}
187
- <tile-hover-pane
188
- popover
189
- .model=${this.hoverPaneProps?.model}
190
- .baseNavigationUrl=${this.hoverPaneProps?.baseNavigationUrl}
191
- .baseImageUrl=${this.hoverPaneProps?.baseImageUrl}
192
- .loggedIn=${this.hoverPaneProps?.loggedIn}
193
- .suppressBlurring=${this.hoverPaneProps?.suppressBlurring}
194
- .sortParam=${this.hoverPaneProps?.sortParam}
195
- .collectionTitles=${this.hoverPaneProps?.collectionTitles}
196
- .mobileBreakpoint=${this.mobileBreakpoint}
197
- .currentWidth=${window.innerWidth}
198
- ></tile-hover-pane>`
199
- : nothing;
200
- }
201
-
202
- /** @inheritdoc */
203
- toggleHoverPane(options: ToggleHoverPaneOptions): void {
204
- if (this.hoverPaneState === 'shown') {
205
- this.fadeOutHoverPane();
206
- this.forceTouchBackdrop = false;
207
- } else {
208
- this.lastPointerClientPos = options.coords;
209
- this.forceTouchBackdrop = options.enableTouchBackdrop ?? false;
210
- this.showHoverPane();
211
- }
212
- }
213
-
214
- /**
215
- * Produces a template for the invisible touch capture backdrop that
216
- * is used to cancel the hover pane on touch devices. We want any
217
- * touch interaction on the backdrop to remove the hover pane, and
218
- * we don't want to bubble up mouse events that would otherwise
219
- * affect the state of the hover pane (e.g., fading it back in).
220
- */
221
- private get touchBackdropTemplate(): HTMLTemplateResult | typeof nothing {
222
- return this.showTouchBackdrop
223
- ? html`<div
224
- id="touch-backdrop"
225
- @touchstart=${this.handleBackdropInteraction}
226
- @touchmove=${this.handleBackdropInteraction}
227
- @touchend=${this.handleBackdropInteraction}
228
- @touchcancel=${this.handleBackdropInteraction}
229
- @mouseenter=${(e: MouseEvent) => e.stopPropagation()}
230
- @mousemove=${(e: MouseEvent) => e.stopPropagation()}
231
- @mouseleave=${(e: MouseEvent) => e.stopPropagation()}
232
- ></div>`
233
- : nothing;
234
- }
235
-
236
- private get showTouchBackdrop(): boolean {
237
- return (
238
- (this.isTouchEnabled && this.enableLongPress) || this.forceTouchBackdrop
239
- );
240
- }
241
-
242
- /** Whether to use the mobile layout */
243
- private get isMobileView(): boolean {
244
- return !!this.mobileBreakpoint && window.innerWidth < this.mobileBreakpoint;
245
- }
246
-
247
- private get isHoverEnabled(): boolean {
248
- return window.matchMedia('(hover: hover)').matches;
249
- }
250
-
251
- private get isTouchEnabled(): boolean {
252
- return (
253
- 'ontouchstart' in window &&
254
- window.matchMedia('(any-pointer: coarse)').matches
255
- );
256
- }
257
-
258
- /** Whether this controller should currently render its hover pane. */
259
- private get shouldRenderHoverPane(): boolean {
260
- return this.hoverPaneState !== 'hidden';
261
- }
262
-
263
- /**
264
- * Returns the desired top/left offsets (in pixels) for this tile's hover pane.
265
- * The desired offsets balance positioning the hover pane under the primary pointer
266
- * while preventing it from flowing outside the viewport. The returned offsets are
267
- * relative to the viewport, intended to position the pane as a popover element.
268
- *
269
- * These offsets are only valid if the hover pane is already rendered with its
270
- * correct width and height. If the hover pane is not present, the returned offsets
271
- * will simply represent the current pointer position.
272
- */
273
- private get hoverPaneDesiredOffsets(): { top: number; left: number } {
274
- // Try to find offsets for the hover pane that:
275
- // (a) cause it to lie entirely within the viewport, and
276
- // (b) to the extent possible, minimize the distance between the
277
- // nearest corner of the hover pane and the mouse position
278
- // (with some additional offsets applied after the fact).
279
-
280
- let [left, top] = [
281
- this.lastPointerClientPos.x,
282
- this.lastPointerClientPos.y,
283
- ];
284
-
285
- // Flip the hover pane according to which quadrant of the viewport the mouse is in.
286
- // (Similar to how Wikipedia's link hover panes work)
287
- const flipHorizontal = this.lastPointerClientPos.x > window.innerWidth / 2;
288
- const flipVertical = this.lastPointerClientPos.y > window.innerHeight / 2;
289
-
290
- const hoverPaneRect = this.hoverPane?.getBoundingClientRect();
291
- if (hoverPaneRect) {
292
- // If we need to flip the hover pane, do so by subtracting its width/height from left/top
293
- if (flipHorizontal) {
294
- left -= hoverPaneRect.width;
295
- }
296
- if (flipVertical) {
297
- top -= hoverPaneRect.height;
298
- }
299
-
300
- // Apply desired offsets from the mouse position
301
- left += (flipHorizontal ? -1 : 1) * this.offsetX;
302
- top += (flipVertical ? -1 : 1) * this.offsetY;
303
-
304
- // On mobile view, shunt the hover pane to avoid overflowing the viewport
305
- if (this.isMobileView) {
306
- left = clamp(left, 20, window.innerWidth - hoverPaneRect.width - 20);
307
- top = clamp(top, 20, window.innerHeight - hoverPaneRect.height - 20);
308
- }
309
- }
310
-
311
- left += window.scrollX;
312
- top += window.scrollY;
313
-
314
- return { left, top };
315
- }
316
-
317
- /**
318
- * Adds to the host element all the listeners necessary to make the
319
- * hover pane functional.
320
- */
321
- private attachListeners(): void {
322
- if (this.isHoverEnabled) {
323
- this.host.addEventListener('mouseenter', this.handleMouseEnter);
324
- this.host.addEventListener('mousemove', this.handleMouseMove);
325
- this.host.addEventListener('mouseleave', this.handleMouseLeave);
326
- }
327
-
328
- if (this.isTouchEnabled && this.enableLongPress) {
329
- this.host.addEventListener('touchstart', this.handleTouchStart);
330
- this.host.addEventListener('touchmove', this.handleLongPressCancel);
331
- this.host.addEventListener('touchend', this.handleLongPressCancel);
332
- this.host.addEventListener('touchcancel', this.handleLongPressCancel);
333
- this.host.addEventListener('contextmenu', this.handleContextMenu);
334
- }
335
- }
336
-
337
- /**
338
- * Removes all the hover pane listeners from the host element.
339
- */
340
- private detachListeners(): void {
341
- this.host.removeEventListener('mouseenter', this.handleMouseEnter);
342
- this.host.removeEventListener('mousemove', this.handleMouseMove);
343
- this.host.removeEventListener('mouseleave', this.handleMouseLeave);
344
- this.host.removeEventListener('touchstart', this.handleTouchStart);
345
- this.host.removeEventListener('touchmove', this.handleLongPressCancel);
346
- this.host.removeEventListener('touchend', this.handleLongPressCancel);
347
- this.host.removeEventListener('touchcancel', this.handleLongPressCancel);
348
- this.host.removeEventListener('contextmenu', this.handleContextMenu);
349
- }
350
-
351
- /**
352
- * Handler for the mouseenter event on the host element.
353
- */
354
- // NB: Arrow function so 'this' remains bound to the controller
355
- private handleMouseEnter = (e: MouseEvent): void => {
356
- // Delegate to the mousemove handler, as they are currently processed identically
357
- this.handleMouseMove(e);
358
- };
359
-
360
- /**
361
- * Handler for the mousemove event on the host element.
362
- * Aborts any pending hide/fade-out for the hover pane, and restarts the
363
- * timer to show it.
364
- */
365
- // NB: Arrow function so 'this' remains bound to the controller
366
- private handleMouseMove = (e: MouseEvent): void => {
367
- // The mouse is within the tile, so abort any pending removal of the hover pane
368
- clearTimeout(this.hideTimer);
369
-
370
- // If the hover pane is currently fading out, just make it fade back in where it is
371
- if (this.hoverPaneState === 'fading-out') {
372
- this.hoverPaneState = 'shown';
373
- this.hoverPane?.classList.add('fade-in');
374
- }
375
-
376
- // Restart the timer to show the hover pane anytime the mouse moves within the tile
377
- if (this.hoverPaneState === 'hidden') {
378
- this.restartShowHoverPaneTimer();
379
- this.lastPointerClientPos = { x: e.clientX, y: e.clientY };
380
- }
381
- };
382
-
383
- /**
384
- * Handler for the mouseleave event on the host element.
385
- * Hides the hover pane if present, and aborts the timer for showing it.
386
- */
387
- // NB: Arrow function so 'this' remains bound to the controller
388
- private handleMouseLeave = (): void => {
389
- // Abort any timer to show the hover pane, as the mouse has left the tile
390
- clearTimeout(this.showTimer);
391
-
392
- // Hide the hover pane if it's already been shown
393
- clearTimeout(this.hideTimer);
394
- if (this.hoverPaneState !== 'hidden') {
395
- this.hideTimer = window.setTimeout(() => {
396
- this.fadeOutHoverPane();
397
- }, this.hideDelay);
398
- }
399
- };
400
-
401
- /**
402
- * Handler for the touchstart event on the host element.
403
- * Begins the timer for recognizing a long press event.
404
- */
405
- // NB: Arrow function so 'this' remains bound to the controller
406
- private handleTouchStart = (e: TouchEvent): void => {
407
- clearTimeout(this.longPressTimer);
408
-
409
- if (e.touches.length === 1) {
410
- this.longPressTimer = window.setTimeout(() => {
411
- if (this.hoverPaneState === 'hidden') {
412
- this.showHoverPane();
413
- }
414
- }, this.longPressDelay);
415
-
416
- this.lastPointerClientPos = {
417
- x: e.touches[0].clientX,
418
- y: e.touches[0].clientY,
419
- };
420
- }
421
- };
422
-
423
- /**
424
- * Handler for events that should cancel a pending long press event
425
- * (touchmove, touchend, touchcancel). Aborts the timer for recognizing
426
- * a long press.
427
- */
428
- // NB: Arrow function so 'this' remains bound to the controller
429
- private handleLongPressCancel = (): void => {
430
- clearTimeout(this.longPressTimer);
431
- };
432
-
433
- /**
434
- * Handler for the contextmenu event, which should be suppressed during
435
- * mobile long-press events on the host element.
436
- */
437
- // NB: Arrow function so 'this' remains bound to the controller
438
- private handleContextMenu = (e: Event): void => {
439
- e.preventDefault();
440
- };
441
-
442
- /**
443
- * Immediately causes the hover pane to begin fading out, if it is present.
444
- */
445
- // NB: Arrow function so 'this' remains bound to the controller
446
- private handleBackdropInteraction = (e: Event): void => {
447
- if (this.hoverPaneState !== 'hidden') {
448
- this.fadeOutHoverPane();
449
- }
450
- e.stopPropagation();
451
- };
452
-
453
- /**
454
- * Aborts and restarts the timer for showing the hover pane.
455
- */
456
- private restartShowHoverPaneTimer(): void {
457
- clearTimeout(this.showTimer);
458
- this.showTimer = window.setTimeout(() => {
459
- this.showHoverPane();
460
- }, this.showDelay);
461
- }
462
-
463
- /**
464
- * Causes this tile's hover pane to be rendered, positioned, and made visible.
465
- */
466
- private async showHoverPane(): Promise<void> {
467
- this.hoverPaneState = 'shown';
468
- this.host.requestUpdate();
469
-
470
- // Wait for the state update to render the hover pane
471
- await this.host.updateComplete;
472
-
473
- // Ensure the hover pane element is still in the document before showing,
474
- // as it might have been removed by the previous update.
475
- if (!this.hoverPane?.isConnected) return;
476
-
477
- this.hoverPane?.showPopover?.();
478
- await new Promise(resolve => {
479
- // Pane sizes aren't accurate until next frame
480
- requestAnimationFrame(resolve);
481
- });
482
-
483
- // Apply the correct positioning to the hover pane
484
- this.repositionHoverPane();
485
-
486
- // The hover pane is initially not visible (to avoid it shifting around
487
- // while being positioned). Since it now has the correct positioning, we
488
- // can make it visible and begin its fade-in animation.
489
- this.hoverPane?.classList.add('visible', 'fade-in');
490
- }
491
-
492
- /**
493
- * Causes this tile's hover pane to begin fading out and starts
494
- * the timer for it to be removed.
495
- */
496
- private fadeOutHoverPane(): void {
497
- this.hoverPaneState = 'fading-out';
498
- this.hoverPane?.classList.remove('fade-in');
499
-
500
- clearTimeout(this.hideTimer);
501
- this.hideTimer = window.setTimeout(() => {
502
- this.hoverPaneState = 'hidden';
503
- this.host.requestUpdate();
504
- }, 100);
505
- }
506
-
507
- /**
508
- * Positions the hover pane with the correct offsets.
509
- */
510
- private repositionHoverPane(): void {
511
- if (!this.hoverPane) return;
512
-
513
- const { top, left } = this.hoverPaneDesiredOffsets;
514
- this.hoverPane.style.top = `${top}px`;
515
- this.hoverPane.style.left = `${left}px`;
516
- }
517
- }
1
+ import type { SortParam } from '@internetarchive/search-service';
2
+ import {
3
+ html,
4
+ HTMLTemplateResult,
5
+ nothing,
6
+ ReactiveController,
7
+ ReactiveControllerHost,
8
+ } from 'lit';
9
+ import type { TileModel } from '../../models';
10
+ import type { CollectionTitles } from '../../data-source/models';
11
+ import { msg } from '@lit/localize';
12
+
13
+ type HoverPaneState = 'hidden' | 'shown' | 'fading-out';
14
+
15
+ // the anchor point of the hover pane
16
+ // can be either the mouse cursor or near the host element
17
+ // in the case of mouse navigation, we want it to follow the cursor
18
+ // in the case of keyboard navigation, we want it to appear near the host element
19
+ type HoverPanePositionAnchor = 'host' | 'cursor';
20
+
21
+ export interface HoverPaneProperties {
22
+ model?: TileModel;
23
+ baseNavigationUrl?: string;
24
+ baseImageUrl?: string;
25
+ loggedIn: boolean;
26
+ suppressBlurring: boolean;
27
+ sortParam: SortParam | null;
28
+ collectionTitles?: CollectionTitles;
29
+ }
30
+
31
+ export interface HoverPaneControllerOptions {
32
+ offsetX?: number;
33
+ offsetY?: number;
34
+ enableLongPress?: boolean;
35
+ showDelay?: number;
36
+ hideDelay?: number;
37
+ longPressDelay?: number;
38
+ mobileBreakpoint?: number;
39
+ }
40
+
41
+ /** A common interface for providing a hover pane element. */
42
+ export interface HoverPaneProviderInterface {
43
+ /** Returns the provider's currently rendered hover pane element. */
44
+ getHoverPane(): HTMLElement | undefined;
45
+ /** Returns properties that should be passed to the hover pane. */
46
+ getHoverPaneProps(): HoverPaneProperties;
47
+ /** When user has keyboard navigated out of more info, we want the host to get focus */
48
+ acquireFocus(): void;
49
+ /** When user has keyboard navigated out of more info, we want the host to lose focus */
50
+ releaseFocus(): void;
51
+ }
52
+
53
+ export interface ToggleHoverPaneOptions {
54
+ coords: { x: number; y: number };
55
+ enableTouchBackdrop?: boolean;
56
+ }
57
+
58
+ /**
59
+ * An interface for interacting with hover pane controllers (e.g.,
60
+ * to retrieve their current hover pane template).
61
+ */
62
+ export interface HoverPaneControllerInterface extends ReactiveController {
63
+ /**
64
+ * Returns the hover pane template to render based on this controller's
65
+ * current state. The returned template may be `nothing` if the hover
66
+ * pane should not currently be rendered.
67
+ */
68
+ getTemplate(): HTMLTemplateResult | typeof nothing;
69
+
70
+ /**
71
+ * Requests to manually toggle the state of the hover pane.
72
+ * If the hover pane is already shown, it will begin fading out and then
73
+ * subsequently be hidden and removed. If the hover pane is already fading
74
+ * out or hidden, it will fade back in and be shown.
75
+ */
76
+ toggleHoverPane(options: ToggleHoverPaneOptions): void;
77
+ }
78
+
79
+ const clamp = (val: number, min = -Infinity, max = Infinity) =>
80
+ Math.max(min, Math.min(val, max));
81
+
82
+ export class HoverPaneController implements HoverPaneControllerInterface {
83
+ /**
84
+ * The hover pane element attached to this controller's host.
85
+ */
86
+ private hoverPane?: HTMLElement;
87
+
88
+ /**
89
+ * The properties to be passed to the hover pane element
90
+ */
91
+ private hoverPaneProps?: HoverPaneProperties;
92
+
93
+ /**
94
+ * The breakpoint (in pixels) below which the mobile interface should be used.
95
+ */
96
+ private mobileBreakpoint?: number = 600;
97
+
98
+ /**
99
+ * The number of horizontal pixels the hover pane should be offset from the
100
+ * pointer position.
101
+ */
102
+ private offsetX: number = -10;
103
+
104
+ /**
105
+ * The number of vertical pixels the hover pane should be offset from the
106
+ * pointer position.
107
+ */
108
+ private offsetY: number = 15;
109
+
110
+ /**
111
+ * The delay between the mouse idling within the host element and when the hover
112
+ * pane should begin fading in (in milliseconds).
113
+ */
114
+ private showDelay: number = 300;
115
+
116
+ /**
117
+ * The delay between when the mouse leaves the host element and when the hover
118
+ * pane should begin fading out (in milliseconds).
119
+ */
120
+ private hideDelay: number = 100;
121
+
122
+ /**
123
+ * The delay between when a touch event begins on the host element and when the
124
+ * hover pane should begin fading in (in milliseconds).
125
+ */
126
+ private longPressDelay: number = 600;
127
+
128
+ /**
129
+ * Whether long press interactions should cause the hover pane to appear (when
130
+ * below the mobile breakpoint).
131
+ */
132
+ private enableLongPress: boolean = false;
133
+
134
+ /**
135
+ * Used to control the current state of this provider's hover pane.
136
+ * - `'hidden'` => The hover pane is not present at all.
137
+ * - `'shown'` => The hover pane is either fading in or fully visible.
138
+ * - `'fading-out'` => The hover pane is fading out and about to be removed.
139
+ */
140
+ private hoverPaneState: HoverPaneState = 'hidden';
141
+
142
+ /** The timer ID for showing the hover pane */
143
+ private showTimer?: number;
144
+
145
+ /** The timer ID for hiding the hover pane */
146
+ private hideTimer?: number;
147
+
148
+ /** The timer ID for recognizing a long press event */
149
+ private longPressTimer?: number;
150
+
151
+ /**
152
+ * Whether the touch backdrop should currently be rendered irrespective of other touch
153
+ * interactions being enabled.
154
+ */
155
+ private forceTouchBackdrop: boolean = false;
156
+
157
+ /** A record of the last mouse position on the host element, for positioning the hover pane */
158
+ private lastPointerClientPos = { x: 0, y: 0 };
159
+
160
+ constructor(
161
+ /** The host element to which this controller should attach listeners */
162
+ private readonly host: ReactiveControllerHost &
163
+ HoverPaneProviderInterface &
164
+ HTMLElement,
165
+ /** Options for adjusting the hover pane behavior (offsets, delays, etc.) */
166
+ options: HoverPaneControllerOptions = {},
167
+ ) {
168
+ this.mobileBreakpoint = options.mobileBreakpoint ?? this.mobileBreakpoint;
169
+ this.offsetX = options.offsetX ?? this.offsetX;
170
+ this.offsetY = options.offsetY ?? this.offsetY;
171
+ this.showDelay = options.showDelay ?? this.showDelay;
172
+ this.hideDelay = options.hideDelay ?? this.hideDelay;
173
+ this.longPressDelay = options.longPressDelay ?? this.longPressDelay;
174
+ this.enableLongPress = options.enableLongPress ?? this.enableLongPress;
175
+
176
+ this.host.addController(this);
177
+ }
178
+
179
+ hostConnected(): void {
180
+ this.attachListeners();
181
+ }
182
+
183
+ hostDisconnected(): void {
184
+ this.detachListeners();
185
+ }
186
+
187
+ hostUpdated(): void {
188
+ this.hoverPane = this.host.getHoverPane();
189
+ this.hoverPaneProps = this.host.getHoverPaneProps();
190
+ }
191
+
192
+ /** @inheritdoc */
193
+ getTemplate(): HTMLTemplateResult | typeof nothing {
194
+ this.hoverPaneProps = this.host.getHoverPaneProps();
195
+
196
+ return this.shouldRenderHoverPane
197
+ ? html`
198
+ ${this.touchBackdropTemplate}
199
+ <tile-hover-pane
200
+ popover
201
+ tabindex="-1"
202
+ aria-describedby="tile-hover-pane-aria-description"
203
+ .model=${this.hoverPaneProps?.model}
204
+ .baseNavigationUrl=${this.hoverPaneProps?.baseNavigationUrl}
205
+ .baseImageUrl=${this.hoverPaneProps?.baseImageUrl}
206
+ .loggedIn=${this.hoverPaneProps?.loggedIn}
207
+ .suppressBlurring=${this.hoverPaneProps?.suppressBlurring}
208
+ .sortParam=${this.hoverPaneProps?.sortParam}
209
+ .collectionTitles=${this.hoverPaneProps?.collectionTitles}
210
+ .mobileBreakpoint=${this.mobileBreakpoint}
211
+ .currentWidth=${window.innerWidth}
212
+ ></tile-hover-pane>
213
+ <div id="tile-hover-pane-aria-description" class="sr-only">
214
+ ${msg('Press Up Arrow to exit item detail preview')}
215
+ </div>
216
+ `
217
+ : nothing;
218
+ }
219
+
220
+ /** @inheritdoc */
221
+ toggleHoverPane(options: ToggleHoverPaneOptions): void {
222
+ if (this.hoverPaneState === 'shown') {
223
+ this.fadeOutHoverPane();
224
+ this.forceTouchBackdrop = false;
225
+ } else {
226
+ this.lastPointerClientPos = options.coords;
227
+ this.forceTouchBackdrop = options.enableTouchBackdrop ?? false;
228
+ this.showHoverPane();
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Produces a template for the invisible touch capture backdrop that
234
+ * is used to cancel the hover pane on touch devices. We want any
235
+ * touch interaction on the backdrop to remove the hover pane, and
236
+ * we don't want to bubble up mouse events that would otherwise
237
+ * affect the state of the hover pane (e.g., fading it back in).
238
+ */
239
+ private get touchBackdropTemplate(): HTMLTemplateResult | typeof nothing {
240
+ return this.showTouchBackdrop
241
+ ? html`<div
242
+ id="touch-backdrop"
243
+ @touchstart=${this.handleBackdropInteraction}
244
+ @touchmove=${this.handleBackdropInteraction}
245
+ @touchend=${this.handleBackdropInteraction}
246
+ @touchcancel=${this.handleBackdropInteraction}
247
+ @mouseenter=${(e: MouseEvent) => e.stopPropagation()}
248
+ @mousemove=${(e: MouseEvent) => e.stopPropagation()}
249
+ @mouseleave=${(e: MouseEvent) => e.stopPropagation()}
250
+ ></div>`
251
+ : nothing;
252
+ }
253
+
254
+ private get showTouchBackdrop(): boolean {
255
+ return (
256
+ (this.isTouchEnabled && this.enableLongPress) || this.forceTouchBackdrop
257
+ );
258
+ }
259
+
260
+ /** Whether to use the mobile layout */
261
+ private get isMobileView(): boolean {
262
+ return !!this.mobileBreakpoint && window.innerWidth < this.mobileBreakpoint;
263
+ }
264
+
265
+ private get isHoverEnabled(): boolean {
266
+ return window.matchMedia('(hover: hover)').matches;
267
+ }
268
+
269
+ private get isTouchEnabled(): boolean {
270
+ return (
271
+ 'ontouchstart' in window &&
272
+ window.matchMedia('(any-pointer: coarse)').matches
273
+ );
274
+ }
275
+
276
+ /** Whether this controller should currently render its hover pane. */
277
+ private get shouldRenderHoverPane(): boolean {
278
+ return this.hoverPaneState !== 'hidden';
279
+ }
280
+
281
+ /**
282
+ * Returns the desired top/left offsets (in pixels) for this tile's hover pane.
283
+ * The desired offsets balance positioning the hover pane under the primary pointer
284
+ * while preventing it from flowing outside the viewport. The returned offsets are
285
+ * relative to the viewport, intended to position the pane as a popover element.
286
+ *
287
+ * These offsets are only valid if the hover pane is already rendered with its
288
+ * correct width and height. If the hover pane is not present, the returned offsets
289
+ * will simply represent the current pointer position.
290
+ */
291
+ private makePaneDesiredOffsets(anchor: HoverPanePositionAnchor): {
292
+ top: number;
293
+ left: number;
294
+ } {
295
+ // Try to find offsets for the hover pane that:
296
+ // (a) cause it to lie entirely within the viewport, and
297
+ // (b) to the extent possible, minimize the distance between the
298
+ // nearest corner of the hover pane and the mouse/host element position
299
+ // (with some additional offsets applied after the fact).
300
+
301
+ let [left, top] = [0, 0];
302
+ switch (anchor) {
303
+ case 'host':
304
+ const hostRect = this.host.getBoundingClientRect();
305
+ // slight inset from host top left corner
306
+ left = hostRect.left + 20;
307
+ top = hostRect.top + 30;
308
+ break;
309
+ case 'cursor':
310
+ left = this.lastPointerClientPos.x;
311
+ top = this.lastPointerClientPos.y;
312
+ break;
313
+ }
314
+
315
+ // Flip the hover pane according to which quadrant of the viewport the coordinates are in.
316
+ // (Similar to how Wikipedia's link hover panes work)
317
+ const flipHorizontal = left > window.innerWidth / 2;
318
+ const flipVertical = top > window.innerHeight / 2;
319
+
320
+ const hoverPaneRect = this.hoverPane?.getBoundingClientRect();
321
+ if (hoverPaneRect) {
322
+ // If we need to flip the hover pane, do so by subtracting its width/height from left/top
323
+ if (flipHorizontal) {
324
+ left -= hoverPaneRect.width;
325
+ }
326
+ if (flipVertical) {
327
+ top -= hoverPaneRect.height;
328
+ }
329
+
330
+ // Apply desired offsets from the target position
331
+ left += (flipHorizontal ? -1 : 1) * this.offsetX;
332
+ top += (flipVertical ? -1 : 1) * this.offsetY;
333
+
334
+ // On mobile view, shunt the hover pane to avoid overflowing the viewport
335
+ if (this.isMobileView) {
336
+ left = clamp(left, 20, window.innerWidth - hoverPaneRect.width - 20);
337
+ top = clamp(top, 20, window.innerHeight - hoverPaneRect.height - 20);
338
+ }
339
+ }
340
+
341
+ left += window.scrollX;
342
+ top += window.scrollY;
343
+
344
+ return { left, top };
345
+ }
346
+
347
+ /**
348
+ * Adds to the host element all the listeners necessary to make the
349
+ * hover pane functional.
350
+ */
351
+ private attachListeners(): void {
352
+ // keyboard navigation listeners
353
+ this.host.addEventListener('focus', this.handleFocus);
354
+ this.host.addEventListener('blur', this.handleBlur);
355
+ this.host.addEventListener('keyup', this.handleKeyUp);
356
+ this.host.addEventListener('keydown', this.handleKeyDown);
357
+
358
+ if (this.isHoverEnabled) {
359
+ this.host.addEventListener('mouseenter', this.handleMouseEnter);
360
+ this.host.addEventListener('mousemove', this.handleMouseMove);
361
+ this.host.addEventListener('mouseleave', this.handleMouseLeave);
362
+ }
363
+
364
+ if (this.isTouchEnabled && this.enableLongPress) {
365
+ this.host.addEventListener('touchstart', this.handleTouchStart);
366
+ this.host.addEventListener('touchmove', this.handleLongPressCancel);
367
+ this.host.addEventListener('touchend', this.handleLongPressCancel);
368
+ this.host.addEventListener('touchcancel', this.handleLongPressCancel);
369
+ this.host.addEventListener('contextmenu', this.handleContextMenu);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Removes all the hover pane listeners from the host element.
375
+ */
376
+ private detachListeners(): void {
377
+ this.host.removeEventListener('mouseenter', this.handleMouseEnter);
378
+ this.host.removeEventListener('mousemove', this.handleMouseMove);
379
+ this.host.removeEventListener('mouseleave', this.handleMouseLeave);
380
+ this.host.removeEventListener('touchstart', this.handleTouchStart);
381
+ this.host.removeEventListener('touchmove', this.handleLongPressCancel);
382
+ this.host.removeEventListener('touchend', this.handleLongPressCancel);
383
+ this.host.removeEventListener('touchcancel', this.handleLongPressCancel);
384
+ this.host.removeEventListener('contextmenu', this.handleContextMenu);
385
+
386
+ // keyboard navigation listeners
387
+ this.host.removeEventListener('focus', this.handleFocus);
388
+ this.host.removeEventListener('blur', this.handleBlur);
389
+ this.host.removeEventListener('keyup', this.handleKeyUp);
390
+ this.host.removeEventListener('keydown', this.handleKeyDown);
391
+ }
392
+
393
+ private handleFocus = (): void => {
394
+ if (this.hoverPaneState === 'hidden') {
395
+ this.showHoverPane({
396
+ anchor: 'host',
397
+ });
398
+ }
399
+ };
400
+
401
+ private handleBlur = (): void => {
402
+ if (this.hoverPaneState !== 'hidden') {
403
+ this.fadeOutHoverPane();
404
+ }
405
+ };
406
+
407
+ private handleKeyDown = (e: KeyboardEvent): void => {
408
+ if (
409
+ (e.key === 'ArrowDown' || e.key === 'ArrowUp') &&
410
+ this.hoverPaneState !== 'hidden'
411
+ ) {
412
+ e.preventDefault();
413
+ }
414
+ };
415
+
416
+ private handleKeyUp = (e: KeyboardEvent): void => {
417
+ if (this.hoverPaneState === 'hidden' || !this.hoverPane) return;
418
+ if (e.key === 'ArrowDown') {
419
+ this.hoverPane.tabIndex = 1;
420
+ this.hoverPane.focus();
421
+ }
422
+
423
+ const isArrowUp = e.key === 'ArrowUp';
424
+ const isEscape = e.key === 'Escape' || e.key === 'Esc';
425
+
426
+ if (isEscape) {
427
+ this.fadeOutHoverPane();
428
+ }
429
+ if (isArrowUp || isEscape) {
430
+ this.hoverPane.tabIndex = -1;
431
+ this.host.acquireFocus();
432
+ }
433
+ };
434
+
435
+ /**
436
+ * Handler for the mouseenter event on the host element.
437
+ */
438
+ // NB: Arrow function so 'this' remains bound to the controller
439
+ private handleMouseEnter = (e: MouseEvent): void => {
440
+ // Delegate to the mousemove handler, as they are currently processed identically
441
+ this.handleMouseMove(e);
442
+ };
443
+
444
+ /**
445
+ * Handler for the mousemove event on the host element.
446
+ * Aborts any pending hide/fade-out for the hover pane, and restarts the
447
+ * timer to show it.
448
+ */
449
+ // NB: Arrow function so 'this' remains bound to the controller
450
+ private handleMouseMove = (e: MouseEvent): void => {
451
+ // The mouse is within the tile, so abort any pending removal of the hover pane
452
+ clearTimeout(this.hideTimer);
453
+
454
+ // If the hover pane is currently fading out, just make it fade back in where it is
455
+ if (this.hoverPaneState === 'fading-out') {
456
+ this.hoverPaneState = 'shown';
457
+ this.hoverPane?.classList.add('fade-in');
458
+ }
459
+
460
+ // Restart the timer to show the hover pane anytime the mouse moves within the tile
461
+ if (this.hoverPaneState === 'hidden') {
462
+ this.restartShowHoverPaneTimer();
463
+ this.lastPointerClientPos = { x: e.clientX, y: e.clientY };
464
+ }
465
+ };
466
+
467
+ /**
468
+ * Handler for the mouseleave event on the host element.
469
+ * Hides the hover pane if present, and aborts the timer for showing it.
470
+ */
471
+ // NB: Arrow function so 'this' remains bound to the controller
472
+ private handleMouseLeave = (): void => {
473
+ this.host.releaseFocus();
474
+
475
+ // Abort any timer to show the hover pane, as the mouse has left the tile
476
+ clearTimeout(this.showTimer);
477
+
478
+ // Hide the hover pane if it's already been shown
479
+ if (this.hoverPaneState !== 'hidden') {
480
+ this.hideTimer = window.setTimeout(() => {
481
+ this.fadeOutHoverPane();
482
+ }, this.hideDelay);
483
+ }
484
+ };
485
+
486
+ /**
487
+ * Handler for the touchstart event on the host element.
488
+ * Begins the timer for recognizing a long press event.
489
+ */
490
+ // NB: Arrow function so 'this' remains bound to the controller
491
+ private handleTouchStart = (e: TouchEvent): void => {
492
+ clearTimeout(this.longPressTimer);
493
+
494
+ if (e.touches.length === 1) {
495
+ this.longPressTimer = window.setTimeout(() => {
496
+ if (this.hoverPaneState === 'hidden') {
497
+ this.showHoverPane();
498
+ }
499
+ }, this.longPressDelay);
500
+
501
+ this.lastPointerClientPos = {
502
+ x: e.touches[0].clientX,
503
+ y: e.touches[0].clientY,
504
+ };
505
+ }
506
+ };
507
+
508
+ /**
509
+ * Handler for events that should cancel a pending long press event
510
+ * (touchmove, touchend, touchcancel). Aborts the timer for recognizing
511
+ * a long press.
512
+ */
513
+ // NB: Arrow function so 'this' remains bound to the controller
514
+ private handleLongPressCancel = (): void => {
515
+ clearTimeout(this.longPressTimer);
516
+ };
517
+
518
+ /**
519
+ * Handler for the contextmenu event, which should be suppressed during
520
+ * mobile long-press events on the host element.
521
+ */
522
+ // NB: Arrow function so 'this' remains bound to the controller
523
+ private handleContextMenu = (e: Event): void => {
524
+ e.preventDefault();
525
+ };
526
+
527
+ /**
528
+ * Immediately causes the hover pane to begin fading out, if it is present.
529
+ */
530
+ // NB: Arrow function so 'this' remains bound to the controller
531
+ private handleBackdropInteraction = (e: Event): void => {
532
+ if (this.hoverPaneState !== 'hidden') {
533
+ this.fadeOutHoverPane();
534
+ }
535
+ e.stopPropagation();
536
+ };
537
+
538
+ /**
539
+ * Aborts and restarts the timer for showing the hover pane.
540
+ */
541
+ private restartShowHoverPaneTimer(): void {
542
+ clearTimeout(this.showTimer);
543
+ this.showTimer = window.setTimeout(() => {
544
+ this.host.acquireFocus();
545
+ this.showHoverPane();
546
+ }, this.showDelay);
547
+ }
548
+
549
+ /**
550
+ * Causes this tile's hover pane to be rendered, positioned, and made visible.
551
+ */
552
+ private async showHoverPane(
553
+ options: {
554
+ anchor: HoverPanePositionAnchor;
555
+ } = {
556
+ anchor: 'cursor',
557
+ },
558
+ ): Promise<void> {
559
+ this.hoverPaneState = 'shown';
560
+ this.host.requestUpdate();
561
+
562
+ // Wait for the state update to render the hover pane
563
+ await this.host.updateComplete;
564
+
565
+ // Ensure the hover pane element is still in the document before showing,
566
+ // as it might have been removed by the previous update.
567
+ if (!this.hoverPane?.isConnected) return;
568
+
569
+ this.hoverPane?.showPopover?.();
570
+ await new Promise(resolve => {
571
+ // Pane sizes aren't accurate until next frame
572
+ requestAnimationFrame(resolve);
573
+ });
574
+
575
+ // Apply the correct positioning to the hover pane
576
+ this.repositionHoverPane(options.anchor);
577
+
578
+ // The hover pane is initially not visible (to avoid it shifting around
579
+ // while being positioned). Since it now has the correct positioning, we
580
+ // can make it visible and begin its fade-in animation.
581
+ this.hoverPane?.classList.add('visible', 'fade-in');
582
+ }
583
+
584
+ /**
585
+ * Causes this tile's hover pane to begin fading out and starts
586
+ * the timer for it to be removed.
587
+ */
588
+ private fadeOutHoverPane(): void {
589
+ this.hoverPaneState = 'fading-out';
590
+ this.hoverPane?.classList.remove('fade-in');
591
+
592
+ clearTimeout(this.hideTimer);
593
+ this.hideTimer = window.setTimeout(() => {
594
+ this.hoverPaneState = 'hidden';
595
+ if (this.hoverPane) {
596
+ this.hoverPane.tabIndex = -1;
597
+ }
598
+ this.host.requestUpdate();
599
+ }, 100);
600
+ }
601
+
602
+ /**
603
+ * Positions the hover pane with the correct offsets.
604
+ */
605
+ private repositionHoverPane(anchor: HoverPanePositionAnchor): void {
606
+ if (!this.hoverPane) return;
607
+
608
+ const { top, left } = this.makePaneDesiredOffsets(anchor);
609
+
610
+ this.hoverPane.style.top = `${top}px`;
611
+ this.hoverPane.style.left = `${left}px`;
612
+ }
613
+ }