-
Notifications
You must be signed in to change notification settings - Fork 66.9k
Expand file tree
/
Copy pathLinkPreviewPopover.tsx
More file actions
573 lines (502 loc) · 20.3 KB
/
LinkPreviewPopover.tsx
File metadata and controls
573 lines (502 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
import { useEffect } from 'react'
import { HOVERCARDS_ENABLED } from '@/frame/lib/constants'
// We postpone the initial delay a bit in case the user didn't mean to
// hover over the link. Perhaps they just dragged the mouse over on their
// way to something else.
const DELAY_SHOW = 300
// The reason the hiding doesn't happens instantly is when the mouse is
// first hovering over the link, then over the popover itself and then
// back to the link. Because there's a slight cap between the popover
// and the link we want to introduce a slight delay so it doesn't flicker.
const DELAY_HIDE = 200
// A global that is used for a slow/delayed closing of the popovers.
// It can be global because there's only 1 popover DOM node that gets
// created the first time it's needed.
let popoverCloseTimer: number | null = null
let popoverStartTimer: number | null = null
// A global for remembering which target was originated the initial opening
// of the popover. It's important to know this when the onmouseover
// of the link is triggered again. If you hover over the popover and back
// to its link, we don't want to immediately open the popover.
// If it's the first time, i.e. a different link, then we want to add a
// slight initial delay.
let currentlyOpen: HTMLLinkElement | null = null
// Number of pixels from the top of the page that implies that we should
// display the popover *underneath* the link.
// The number is based on the height of popovers when they are quite high.
// We can't know the size of the popover on screen until after it's been
// inserted into the visible DOM. So before that, as a `div` element,
// its `offsetHeight` and `.getBoundingClientRect().height` are always 0.
// We *could* "change our mind" and wait till it's been inserted and then
// change according to the popover's true height. But this can cause a flicker.
const BOUNDING_TOP_MARGIN = 300
// used to identify the first focusable element in the hover card
const FIRST_LINK_ID = '_hc_first_focusable'
const TITLE_ID = '_hc_title'
type PageMetadata = {
product: string
title: string
intro: string
anchor?: string
cacheInfo?: string
}
function getOrCreatePopoverGlobal() {
let popoverGlobal = document.querySelector<HTMLDivElement>('div.Popover')
if (!popoverGlobal) {
const wrapper = document.createElement('div')
wrapper.setAttribute('data-testid', 'popover')
wrapper.classList.add('Popover', 'position-absolute')
wrapper.style.display = 'none'
wrapper.style.outline = 'none'
wrapper.style.zIndex = `100`
// Semantics for the hovercard so SR users are aware they're about to be
// focus trapped
wrapper.setAttribute('role', 'region')
wrapper.setAttribute('aria-modal', 'true')
wrapper.setAttribute('aria-label', 'user hovercard')
wrapper.setAttribute('aria-labelledby', TITLE_ID)
// this extra element and its event listener are used to help us direct
// where focus should go when entering a hover card; see `bottomBumper` for
// its counterpart
const topBumper = document.createElement('span')
topBumper.setAttribute('tabindex', '0')
topBumper.setAttribute('aria-hidden', 'true')
wrapper.appendChild(topBumper)
const inner = document.createElement('div')
// Note that this is lacking the 'Popover-message--bottom-left'
// or 'Popover-message--top-right`. These get set later when we
// know where the popover message should appear on the screen.
inner.classList.add(
...'Popover-message Popover-message--large p-3 Box color-shadow-large'.split(/\s+/g),
)
inner.style.width = `360px`
const product = document.createElement('h3')
product.classList.add('product')
product.classList.add('f6')
product.classList.add('color-fg-muted')
const headingLink = document.createElement('a')
headingLink.style.textDecoration = 'underline'
headingLink.href = ''
// the id is necessary since we're intercepting natural focus order,
// so when focus enters the topBumper, we'll manually move it to the link
headingLink.id = FIRST_LINK_ID
product.appendChild(headingLink)
inner.appendChild(product)
const title = document.createElement('h4')
title.classList.add('title')
title.classList.add('h5')
title.classList.add('my-1')
const titleLink = document.createElement('a')
titleLink.href = ''
titleLink.id = TITLE_ID
title.appendChild(titleLink)
inner.appendChild(title)
const intro = document.createElement('p')
intro.classList.add('intro')
intro.classList.add('f6')
intro.classList.add('color-fg-muted')
inner.appendChild(intro)
const anchor = document.createElement('p')
anchor.classList.add('anchor')
anchor.classList.add('hover-card-anchor')
anchor.classList.add('f6')
anchor.classList.add('color-fg-muted')
inner.appendChild(anchor)
wrapper.appendChild(inner)
// this extra element and its event listener are used to help us direct
// where focus should go when reaching the end of a hover card;
// see `topBumper` for its counterpart
const bottomBumper = document.createElement('span')
bottomBumper.setAttribute('aria-hidden', 'true')
bottomBumper.setAttribute('tabindex', '0')
wrapper.appendChild(bottomBumper)
document.body.appendChild(wrapper)
wrapper.addEventListener('mouseover', () => {
if (popoverCloseTimer) {
window.clearTimeout(popoverCloseTimer)
}
})
wrapper.addEventListener('mouseout', () => {
popoverCloseTimer = window.setTimeout(() => {
wrapper.style.display = 'none'
// If you started the popover by moving over the link, then
// moved the mouse out of the link and into the popover, then
// eventually you move out of the popover. Then, we want to
// reset.
currentlyOpen = null
}, DELAY_HIDE)
})
popoverGlobal = wrapper
// The top bumper simply moves focus into either:
// (a) the first focusable element in the hover card, or
// (b) if traversing in reverse, the last focusable element
topBumper.addEventListener('keyup', (event) => {
if (event.key === 'Tab' && event.shiftKey) titleLink.focus()
else if (event.key === 'Tab') headingLink.focus()
})
// The bottom bumper is more complex and handled via handleBottomBumper()
bottomBumper.addEventListener('keyup', (event) => {
handleBottomBumper(titleLink, headingLink, event)
})
bottomBumper.addEventListener('focus', () => {
handleBottomBumper(headingLink)
})
}
// When the bottom bumper receives focus, it could be via one of two events:
// (a) a keyboard event, or (b) a focus event. This function essentially
// "de-bounces" the resulting behavior.
function handleBottomBumper(
primaryFocus: HTMLAnchorElement,
loopAroundFocus?: HTMLAnchorElement,
event?: KeyboardEvent,
) {
// If we got here via keyboard events, we just need to determine if we
// should loops around to the top of the hover card or traverse in reverse
// the final part of the conditional essentially defaults the focus
if (event && event.key === 'Tab' && event.shiftKey) {
primaryFocus.focus()
} else if (event && event.key === 'Tab' && loopAroundFocus) {
loopAroundFocus.focus()
} else if (!event) {
primaryFocus.focus()
}
}
return popoverGlobal
}
function popoverWrap(element: HTMLLinkElement, filledCallback?: (popover: HTMLDivElement) => void) {
if (element.parentElement && element.parentElement.classList.contains('Popover')) {
return
}
let title = ''
let product = ''
let intro = ''
let anchor = ''
// Is it an in-page anchor link? If so, get the title, intro
// and product from within the DOM. But only if we can use the anchor
// destination to find a DOM node that has text.
if (
element.href.includes('#') &&
element.href.split('#')[1] &&
element.href.startsWith(`${window.location.href.split('#')[0]}#`)
) {
const domID = element.href.split('#')[1]
// The reason we're using `getElementById(...)` instead of
// `querySelector(#...)` is because `getElementById(...)` will not
// throw a DOMException if the ID starts with a number.
// For example, `document.getElementById('123-thing')` will work, but
// `document.querySelector('#123-thing')` will throw a DOMException.
const domElement = document.getElementById(domID)
if (domElement && domElement.textContent) {
anchor = domElement.textContent
// Headings will have the `#` character to the right which is to
// indicate that it's a "permalink". It becomes part of the heading's
// text as a DOM element. Strip that.
if (anchor.endsWith(' #')) {
anchor = anchor.slice(0, -2)
}
// Now we have to make up the product, intro, and title
const domTitle = document.querySelector('h1')
if (domTitle && domTitle.textContent) {
title = domTitle.textContent
intro = ''
product = ''
const domProduct = document.querySelector('._product-title')
if (domProduct && domProduct.textContent) {
product = domProduct.textContent
}
const domIntro = document.querySelector('._page-intro')
if (domIntro && domIntro.textContent) {
intro = domIntro.textContent
}
}
}
if (title) {
fillPopover(element, { product, title, intro, anchor }, filledCallback)
}
return
}
const { pathname } = new URL(element.href)
async function fetchAndFillPopover() {
const response = await fetch(`/api/article/meta?${new URLSearchParams({ pathname })}`, {
headers: {
'X-Request-Source': 'hovercards',
},
})
if (response.ok) {
const meta = (await response.json()) as PageMetadata
fillPopover(element, meta, filledCallback)
}
}
fetchAndFillPopover()
}
function fillPopover(
element: HTMLLinkElement,
info: PageMetadata,
callback?: (popover: HTMLDivElement) => void,
) {
const { product, title, intro, anchor } = info
const popover = getOrCreatePopoverGlobal()
const productHead = popover.querySelector('.product') as HTMLHeadingElement | null
if (productHead) {
const productHeadLink = productHead.querySelector('.product a') as HTMLLinkElement | null
if (product) {
if (productHeadLink) {
productHeadLink.textContent = product
const linkURL = new URL(element.href)
// All a.href attributes are always full absolute URLs, as a string.
// We assume that the "product landing page" is the first
// portion of all links.
const regex = /^\/(?<lang>\w{2}\/)?(?<version>[\w-]+@[\w-.]+\/)?(?<product>[\w-]+\/)?/
const match = regex.exec(linkURL.pathname)
if (match?.groups) {
const { lang, version, product: productPath } = match.groups
const productURL = [lang, version, productPath].map((n) => n || '').join('')
productHeadLink.href = `${linkURL.origin}/${productURL}`
}
productHead.style.display = 'block'
}
} else {
productHead.style.display = 'none'
}
}
const anchorElement = popover.querySelector('.anchor') as HTMLParagraphElement | null
if (anchorElement) {
if (anchor) {
anchorElement.textContent = anchor
anchorElement.style.display = 'block'
} else {
anchorElement.style.display = 'none'
}
}
if (popoverCloseTimer) {
window.clearTimeout(popoverCloseTimer)
}
const titleHead = popover.querySelector('.title')
if (titleHead) {
const titleHeadLink = titleHead.querySelector('a') as HTMLLinkElement | null
if (titleHeadLink) {
titleHeadLink.href = element.href
titleHeadLink.textContent = title
}
}
const paragraph: HTMLParagraphElement | null = popover.querySelector('p.intro')
if (paragraph) {
if (intro) {
paragraph.textContent = intro
paragraph.style.display = 'block'
} else {
paragraph.style.display = 'none'
}
}
const [top, left] = getOffset(element)
const [boundingTop] = getBoundingOffset(element)
const popoverMessageElement = popover.querySelector('.Popover-message') as HTMLDivElement
const below = boundingTop < BOUNDING_TOP_MARGIN
if (below) {
// The caret pointing upwards
popoverMessageElement.classList.remove('Popover-message--bottom-left')
popoverMessageElement.classList.add('Popover-message--top-left')
} else {
// Default
popoverMessageElement.classList.remove('Popover-message--top-left')
popoverMessageElement.classList.add('Popover-message--bottom-left')
}
// We can't know what the height of the popover element is when it's
// `display:none` so we guess offset to the offset and adjust it later.
popover.style.top = `${top}px`
popover.style.left = `${left}px`
popover.style.display = 'block'
if (below) {
// This moves the popover about the height of the <a> element down.
// You can't use element.getBoundingClientRect() because that could
// give a height that is twice that of a single line of text.
// For example:
//
// <p>Bla bla <a href="...">Link</a> ble and <a href="...">Other
// Link Text</a> yada yada</p>
//
// In this case the second `<a>` element will have a height that is
// twice of the first `<a>` because the second one spans two lines.
const approximateElementHeight = 33
popover.style.top = `${top + approximateElementHeight}px`
} else {
popover.style.top = `${top - popover.offsetHeight - 10}px`
}
popover.style.setProperty('top', popover.style.top, 'important')
if (callback) {
callback(popover)
}
}
// The top/left offset of an element is only relative to its parent.
// So if you have...
//
// <body>
// <div id="main">
// <div id="sub" style="position:relative">
// <a href="...">Link</a>
//
// The `<a>` element's offset is based on the `<div id="sub" style="position:relative">`
// and not the body as the user sees it relative to the viewport.
// So you have to traverse the offsets till you get to the root.
function getOffset(element: HTMLElement) {
let top = element.offsetTop
let left = element.offsetLeft
let offsetParent = element.offsetParent as HTMLElement | null
while (offsetParent) {
left += offsetParent.offsetLeft
top += offsetParent.offsetTop
offsetParent = offsetParent.offsetParent as HTMLElement | null
}
return [top, left]
}
function getBoundingOffset(element: HTMLElement) {
const { top, left } = element.getBoundingClientRect()
return [top, left]
}
function popoverShow(target: HTMLLinkElement, callback?: (popover: HTMLDivElement) => void) {
if (popoverStartTimer) {
window.clearTimeout(popoverStartTimer)
}
// The mouse has been moved over a link. If this is the "first time",
// we want to delay showing the popover because it could be that the
// *intention* of the user was not to hover over, but they might have
// just moved the mouse over the link by "accident", or in a hurry
// on their way to something else.
// However, if they hover over the link because the popover is already
// open, which happens when you hover over the popover and back again
// to the link, then we don't want any delay.
if (target === currentlyOpen) {
popoverWrap(target, callback)
} else {
popoverStartTimer = window.setTimeout(() => {
popoverWrap(target, callback)
currentlyOpen = target
}, DELAY_SHOW)
}
}
function popoverHide() {
// Important to use `window.setTimeout` instead of `setTimeout` so
// that TypeScript knows which kind of timeout we're talking about.
// If you use plain `setTimeout` TypeScript might think it's a
// Node eventloop kinda timer.
if (popoverStartTimer) {
window.clearTimeout(popoverStartTimer)
}
popoverCloseTimer = window.setTimeout(() => {
const popover = getOrCreatePopoverGlobal()
popover.style.display = 'none'
// Reset because we're closing the popover, so we have to start from afresh.
currentlyOpen = null
}, DELAY_HIDE)
}
let lastFocussedLink: HTMLLinkElement | null = null
export function LinkPreviewPopover() {
// This is to track if the user entirely tabs out of the window.
// For example if they go to the address bar.
useEffect(() => {
if (!HOVERCARDS_ENABLED) return
function windowBlur() {
popoverHide()
}
window.addEventListener('blur', windowBlur)
return () => {
window.removeEventListener('blur', windowBlur)
}
}, [])
useEffect(() => {
if (!HOVERCARDS_ENABLED) return
function showPopover(event: MouseEvent) {
const target = event.currentTarget as HTMLLinkElement
popoverShow(target)
// Just in case you *had* used the keyboard shortcut, but now
// hovered over something else, reset the last focussed link.
lastFocussedLink = null
}
function hidePopover() {
popoverHide()
}
function keyboardHandler(event: KeyboardEvent) {
if (event.key === 'ArrowUp' && event.altKey) {
event.preventDefault()
const target = event.currentTarget as HTMLLinkElement
popoverShow(target, (popover) => {
const productHeadingLink = popover.querySelector<HTMLParagraphElement>('.product a')
const first = document.getElementById(FIRST_LINK_ID)
if (productHeadingLink && first) {
first.focus()
lastFocussedLink = target
}
})
} else if (event.key === 'ArrowDown' && event.altKey) {
event.preventDefault()
popoverHide()
}
}
// Note, this is attached, as an event listener, to the `document`
// meaning an Escape event here could be for anything.
// But the `popoverHide` function is cheap to call. If the popover
// was visible, it's hidden now. If it wasn't visible, nothing happens.
// Because we do other things on Escape, we have to make sure that
// this Escape was for closing a currently open popover.
function escapeHandler(event: KeyboardEvent) {
if (event.key === 'Escape') {
const popover = getOrCreatePopoverGlobal()
if (popover.style.display !== 'none') {
popoverHide()
// If this is true, the keyboard shortcut was used to open
// the popover when the link (that can have a popover)
// was used. So upon, Escape go back to focussing on that link.
if (lastFocussedLink) {
lastFocussedLink.focus()
}
}
}
}
const links = Array.from(
document.querySelectorAll<HTMLLinkElement>(
'#article-contents a[href], #article-intro a[href]',
),
).filter((link) => {
// This filters out links that are not internal or in-page
// and the ones that are in-page anchor links next to the headings.
// Remember that `link.href` is always absolute because it comes
// from the DOM. So to test the pathname, we have to parse it
// and extract the pathname from the whole URL object.
const { pathname } = new URL(link.href)
return (
link.href.startsWith(window.location.origin) &&
!link.classList.contains('heading-link') &&
!pathname.startsWith('/public/') &&
!pathname.startsWith('/assets/') &&
// This skips those ToolPicker links with `data-tool="vscode"`
// attribute, for example.
!link.dataset.tool &&
!link.dataset.platform
)
})
// Ideally, we'd have an event listener for the entire container and
// the filter, at "runtime", within by filtering for the target
// elements we're interested in. However, this is not possible
// because then when you hover over the text in
// a tag like <a href="..."><strong>Link</strong></a> the target
// element is that of the `STRONG` tag.
// The reason it would be better to have a single event listener and
// filter is because it would work even if the DOM changes by
// adding new `<a>` elements.
for (const link of links) {
link.addEventListener('mouseover', showPopover)
link.addEventListener('mouseout', hidePopover)
link.addEventListener('keydown', keyboardHandler)
}
document.addEventListener('keydown', escapeHandler)
return () => {
for (const link of links) {
link.removeEventListener('mouseover', showPopover)
link.removeEventListener('mouseout', hidePopover)
link.removeEventListener('keydown', keyboardHandler)
}
document.removeEventListener('keydown', escapeHandler)
}
}) // Note that this runs on every single mount
return null
}