1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Keyboard shortcut to open hover cards (#40100)

This commit is contained in:
Peter Bengtsson
2023-09-26 13:37:30 -04:00
committed by GitHub
parent 406e9521d9
commit aa8b19eb35
4 changed files with 259 additions and 69 deletions

View File

@@ -265,3 +265,6 @@ toggle_images:
show_single: Show image
scroll_button:
scroll_to_top: Scroll to top
popovers:
role_description: hover card
keyboard_shortcut_description: Press alt+up to activate

View File

@@ -1,4 +1,6 @@
import { useEffect } from 'react'
import { useTranslation } from 'src/languages/components/useTranslation'
import { useRouter } from 'next/router'
// 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
@@ -34,6 +36,14 @@ let currentlyOpen: HTMLLinkElement | null = null
// change accoding to the popover's true height. But this can cause a flicker.
const BOUNDING_TOP_MARGIN = 300
// All links that should have a hover card also get a
// `aria-describedby="..."`. That ID is used to look up another DOM
// element, that has a `visually-hidden` class. The value if the ID
// isn't very important as long as it connects.
// Note; at the moment this value is duplicated in the Playwright
// tests because of trying to extract the value of `aria-describedby`.
const DESCRIBEDBY_ELEMENT_ID = 'popover-describedby'
type Info = {
product: string
title: string
@@ -45,7 +55,7 @@ type APIInfo = {
}
function getOrCreatePopoverGlobal() {
let popoverGlobal = document.querySelector('div.Popover') as HTMLDivElement | null
let popoverGlobal = document.querySelector<HTMLDivElement>('div.Popover')
if (!popoverGlobal) {
const wrapper = document.createElement('div')
wrapper.setAttribute('data-testid', 'popover')
@@ -62,16 +72,22 @@ function getOrCreatePopoverGlobal() {
)
inner.style.width = `360px`
const product = document.createElement('p')
const product = document.createElement('h3')
product.classList.add('product')
product.classList.add('f6')
product.classList.add('color-fg-muted')
inner.appendChild(product)
const headingLink = document.createElement('a')
headingLink.href = ''
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 = ''
title.appendChild(titleLink)
inner.appendChild(title)
const intro = document.createElement('p')
@@ -112,7 +128,29 @@ function getOrCreatePopoverGlobal() {
return popoverGlobal
}
function popoverWrap(element: HTMLLinkElement) {
function getOrCreateDescribeByElement() {
let element = document.querySelector<HTMLParagraphElement>(`#${DESCRIBEDBY_ELEMENT_ID}`)
if (!element) {
element = document.createElement('p')
element.id = DESCRIBEDBY_ELEMENT_ID
element.classList.add('visually-hidden')
// "All page content should be contained by landmarks"
// https://dequeuniversity.com/rules/axe/4.7/region
// The element that we use for the `aria-describedby` attribute
// needs to exist in the DOM inside a landmark. For example
// `<div role="footer">`. In our case we use our
// `<main id="main-content">` element.
// We "know" that this querySelector() query will always find a
// valid element, but it's theoretically not perfectly true, so we have to
// use a fallback.
const main = document.querySelector<HTMLDivElement>('main') || document.body
main.appendChild(element)
}
return element
}
function popoverWrap(element: HTMLLinkElement, filledCallback?: (popover: HTMLDivElement) => void) {
if (element.parentElement && element.parentElement.classList.contains('Popover')) {
return
}
@@ -158,7 +196,7 @@ function popoverWrap(element: HTMLLinkElement) {
}
if (title) {
fillPopover(element, { product, title, intro, anchor })
fillPopover(element, { product, title, intro, anchor }, filledCallback)
}
return
}
@@ -168,25 +206,38 @@ function popoverWrap(element: HTMLLinkElement) {
fetch(`/api/pageinfo/v1?${new URLSearchParams({ pathname })}`).then(async (response) => {
if (response.ok) {
const { info } = (await response.json()) as APIInfo
fillPopover(element, info)
fillPopover(element, info, filledCallback)
}
})
}
function fillPopover(element: HTMLLinkElement, info: Info) {
function fillPopover(
element: HTMLLinkElement,
info: Info,
callback?: (popover: HTMLDivElement) => void,
) {
const { product, title, intro, anchor } = info
const popover = getOrCreatePopoverGlobal()
const productHead = popover.querySelector('p.product') as HTMLParagraphElement | null
const productHead = popover.querySelector('.product') as HTMLHeadingElement | null
if (productHead) {
const productHeadLink = productHead.querySelector('.product a') as HTMLLinkElement | null
if (product) {
productHead.textContent = 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.
productHeadLink.href = linkURL.pathname.split('/').slice(0, 3).join('/')
productHead.style.display = 'block'
}
} else {
productHead.style.display = 'none'
}
}
const anchorElement = popover.querySelector('p.anchor') as HTMLParagraphElement | null
const anchorElement = popover.querySelector('.anchor') as HTMLParagraphElement | null
if (anchorElement) {
if (anchor) {
anchorElement.textContent = anchor
@@ -200,8 +251,14 @@ function fillPopover(element: HTMLLinkElement, info: Info) {
window.clearTimeout(popoverCloseTimer)
}
const header = popover.querySelector('h4')
if (header) header.textContent = title
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) {
@@ -251,6 +308,10 @@ function fillPopover(element: HTMLLinkElement, info: Info) {
} else {
popover.style.top = `${top - popover.offsetHeight - 10}px`
}
if (callback) {
callback(popover)
}
}
// The top/left offset of an element is only relative to its parent.
@@ -281,7 +342,7 @@ function getBoundingOffset(element: HTMLElement) {
return [top, left]
}
function popoverShow(target: HTMLLinkElement) {
function popoverShow(target: HTMLLinkElement, callback?: (popover: HTMLDivElement) => void) {
if (popoverStartTimer) {
window.clearTimeout(popoverStartTimer)
}
@@ -295,10 +356,10 @@ function popoverShow(target: HTMLLinkElement) {
// 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)
popoverWrap(target, callback)
} else {
popoverStartTimer = window.setTimeout(() => {
popoverWrap(target)
popoverWrap(target, callback)
currentlyOpen = target
}, DELAY_SHOW)
}
@@ -323,24 +384,93 @@ function popoverHide() {
}, DELAY_HIDE)
}
let lastFocussedLink: HTMLLinkElement | null = null
export function LinkPreviewPopover() {
const { t } = useTranslation('popovers')
const { locale } = useRouter()
useEffect(() => {
const element = getOrCreateDescribeByElement()
if (element) {
element.textContent = t('keyboard_shortcut_description')
}
}, [locale])
// This is to track if the user entirely tabs out of the window.
// For example if they go to the address bar.
useEffect(() => {
function windowBlur() {
popoverHide()
}
window.addEventListener('blur', windowBlur)
return () => {
window.removeEventListener('blur', windowBlur)
}
}, [])
useEffect(() => {
function showPopover(event: MouseEvent) {
// If the current window is too narrow, the popover is not useful.
// Since this is tested on every event callback here in the handler,
// if the window width has changed since the mount, the number
// will change accordingly.
if (window.innerWidth < 767) {
const wideEnough = window.innerWidth > 767
function showPopover(event: MouseEvent) {
if (!wideEnough) {
return
}
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')
if (productHeadingLink) {
productHeadingLink.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]',
@@ -376,13 +506,25 @@ export function LinkPreviewPopover() {
for (const link of links) {
link.addEventListener('mouseover', showPopover)
link.addEventListener('mouseout', hidePopover)
link.addEventListener('keydown', keyboardHandler)
if (!link.getAttribute('aria-roledescription')) {
link.setAttribute('aria-roledescription', t('role_description'))
}
if (!link.getAttribute('aria-describedby')) {
link.setAttribute('aria-describedby', DESCRIBEDBY_ELEMENT_ID)
}
}
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

View File

@@ -265,3 +265,6 @@ toggle_images:
show_single: Show image
scroll_button:
scroll_to_top: Scroll to top
popovers:
role_description: hover card
keyboard_shortcut_description: Press alt+up to activate

View File

@@ -169,7 +169,8 @@ test('navigate with side bar into article inside a map-topic inside a category',
await expect(page).toHaveURL(/actions\/category\/map-topic\/article/)
})
test('hovercards', async ({ page }) => {
test.describe('hover cards', () => {
test('hover over link', async ({ page }) => {
await page.goto('/pages/quickstart')
// hover over a link and check for intro content from hovercard
@@ -225,6 +226,47 @@ test('hovercards', async ({ page }) => {
await expect(page.getByText('The most basic of fixture data for GitHub')).toBeVisible()
await page.locator('#article-contents').getByRole('link', { name: 'bar' }).hover()
await expect(page.getByText("This page doesn't really have an intro")).toBeVisible()
})
test('use keyboard shortcut to open hover card', async ({ page }) => {
await page.goto('/pages/quickstart')
// Simply putting focus on the link should not open the hovercard
await page.locator('#article-contents').getByRole('link', { name: 'Quickstart' }).focus()
await expect(
page.getByText(
'Get started using GitHub to manage Git repositories and collaborate with others.',
),
).not.toBeVisible()
// Once a link has got focus, you can use Alt+ArrowUp to open the hovercard
await page.keyboard.press('Alt+ArrowUp')
await expect(
page.getByText(
'Get started using GitHub to manage Git repositories and collaborate with others.',
),
).toBeVisible()
// Press Escape to close it
await page.keyboard.press('Escape')
await expect(
page.getByText(
'Get started using GitHub to manage Git repositories and collaborate with others.',
),
).not.toBeVisible()
})
test('internal links get a aria-roledescription and aria-describedby', async ({ page }) => {
await page.goto('/pages/quickstart')
const link = page.locator('#article-contents').getByRole('link', { name: 'Quickstart' })
await expect(link).toHaveAttribute('aria-roledescription', 'hover card')
// The link gets a `aria-describedby="...ID..."` attribute that points to
// another element in the DOM that has the description text.
const id = 'popover-describedby'
await expect(link).toHaveAttribute('aria-describedby', id)
await expect(page.locator(`#${id}`)).toHaveText('Press alt+up to activate')
})
})
test.describe('test nav at different viewports', () => {