Keyboard shortcut to open hover cards (#40100)
This commit is contained in:
@@ -265,3 +265,6 @@ toggle_images:
|
|||||||
show_single: Show image
|
show_single: Show image
|
||||||
scroll_button:
|
scroll_button:
|
||||||
scroll_to_top: Scroll to top
|
scroll_to_top: Scroll to top
|
||||||
|
popovers:
|
||||||
|
role_description: hover card
|
||||||
|
keyboard_shortcut_description: Press alt+up to activate
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect } from 'react'
|
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
|
// 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
|
// 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.
|
// change accoding to the popover's true height. But this can cause a flicker.
|
||||||
const BOUNDING_TOP_MARGIN = 300
|
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 = {
|
type Info = {
|
||||||
product: string
|
product: string
|
||||||
title: string
|
title: string
|
||||||
@@ -45,7 +55,7 @@ type APIInfo = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreatePopoverGlobal() {
|
function getOrCreatePopoverGlobal() {
|
||||||
let popoverGlobal = document.querySelector('div.Popover') as HTMLDivElement | null
|
let popoverGlobal = document.querySelector<HTMLDivElement>('div.Popover')
|
||||||
if (!popoverGlobal) {
|
if (!popoverGlobal) {
|
||||||
const wrapper = document.createElement('div')
|
const wrapper = document.createElement('div')
|
||||||
wrapper.setAttribute('data-testid', 'popover')
|
wrapper.setAttribute('data-testid', 'popover')
|
||||||
@@ -62,16 +72,22 @@ function getOrCreatePopoverGlobal() {
|
|||||||
)
|
)
|
||||||
inner.style.width = `360px`
|
inner.style.width = `360px`
|
||||||
|
|
||||||
const product = document.createElement('p')
|
const product = document.createElement('h3')
|
||||||
product.classList.add('product')
|
product.classList.add('product')
|
||||||
product.classList.add('f6')
|
product.classList.add('f6')
|
||||||
product.classList.add('color-fg-muted')
|
product.classList.add('color-fg-muted')
|
||||||
inner.appendChild(product)
|
const headingLink = document.createElement('a')
|
||||||
|
headingLink.href = ''
|
||||||
|
product.appendChild(headingLink)
|
||||||
inner.appendChild(product)
|
inner.appendChild(product)
|
||||||
|
|
||||||
const title = document.createElement('h4')
|
const title = document.createElement('h4')
|
||||||
|
title.classList.add('title')
|
||||||
title.classList.add('h5')
|
title.classList.add('h5')
|
||||||
title.classList.add('my-1')
|
title.classList.add('my-1')
|
||||||
|
const titleLink = document.createElement('a')
|
||||||
|
titleLink.href = ''
|
||||||
|
title.appendChild(titleLink)
|
||||||
inner.appendChild(title)
|
inner.appendChild(title)
|
||||||
|
|
||||||
const intro = document.createElement('p')
|
const intro = document.createElement('p')
|
||||||
@@ -112,7 +128,29 @@ function getOrCreatePopoverGlobal() {
|
|||||||
return popoverGlobal
|
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')) {
|
if (element.parentElement && element.parentElement.classList.contains('Popover')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -158,7 +196,7 @@ function popoverWrap(element: HTMLLinkElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
fillPopover(element, { product, title, intro, anchor })
|
fillPopover(element, { product, title, intro, anchor }, filledCallback)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -168,25 +206,38 @@ function popoverWrap(element: HTMLLinkElement) {
|
|||||||
fetch(`/api/pageinfo/v1?${new URLSearchParams({ pathname })}`).then(async (response) => {
|
fetch(`/api/pageinfo/v1?${new URLSearchParams({ pathname })}`).then(async (response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const { info } = (await response.json()) as APIInfo
|
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 { product, title, intro, anchor } = info
|
||||||
const popover = getOrCreatePopoverGlobal()
|
const popover = getOrCreatePopoverGlobal()
|
||||||
const productHead = popover.querySelector('p.product') as HTMLParagraphElement | null
|
|
||||||
|
const productHead = popover.querySelector('.product') as HTMLHeadingElement | null
|
||||||
if (productHead) {
|
if (productHead) {
|
||||||
|
const productHeadLink = productHead.querySelector('.product a') as HTMLLinkElement | null
|
||||||
if (product) {
|
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'
|
productHead.style.display = 'block'
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
productHead.style.display = 'none'
|
productHead.style.display = 'none'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const anchorElement = popover.querySelector('p.anchor') as HTMLParagraphElement | null
|
const anchorElement = popover.querySelector('.anchor') as HTMLParagraphElement | null
|
||||||
if (anchorElement) {
|
if (anchorElement) {
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
anchorElement.textContent = anchor
|
anchorElement.textContent = anchor
|
||||||
@@ -200,8 +251,14 @@ function fillPopover(element: HTMLLinkElement, info: Info) {
|
|||||||
window.clearTimeout(popoverCloseTimer)
|
window.clearTimeout(popoverCloseTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = popover.querySelector('h4')
|
const titleHead = popover.querySelector('.title')
|
||||||
if (header) header.textContent = 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')
|
const paragraph: HTMLParagraphElement | null = popover.querySelector('p.intro')
|
||||||
if (paragraph) {
|
if (paragraph) {
|
||||||
@@ -251,6 +308,10 @@ function fillPopover(element: HTMLLinkElement, info: Info) {
|
|||||||
} else {
|
} else {
|
||||||
popover.style.top = `${top - popover.offsetHeight - 10}px`
|
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.
|
// The top/left offset of an element is only relative to its parent.
|
||||||
@@ -281,7 +342,7 @@ function getBoundingOffset(element: HTMLElement) {
|
|||||||
return [top, left]
|
return [top, left]
|
||||||
}
|
}
|
||||||
|
|
||||||
function popoverShow(target: HTMLLinkElement) {
|
function popoverShow(target: HTMLLinkElement, callback?: (popover: HTMLDivElement) => void) {
|
||||||
if (popoverStartTimer) {
|
if (popoverStartTimer) {
|
||||||
window.clearTimeout(popoverStartTimer)
|
window.clearTimeout(popoverStartTimer)
|
||||||
}
|
}
|
||||||
@@ -295,10 +356,10 @@ function popoverShow(target: HTMLLinkElement) {
|
|||||||
// open, which happens when you hover over the popover and back again
|
// open, which happens when you hover over the popover and back again
|
||||||
// to the link, then we don't want any delay.
|
// to the link, then we don't want any delay.
|
||||||
if (target === currentlyOpen) {
|
if (target === currentlyOpen) {
|
||||||
popoverWrap(target)
|
popoverWrap(target, callback)
|
||||||
} else {
|
} else {
|
||||||
popoverStartTimer = window.setTimeout(() => {
|
popoverStartTimer = window.setTimeout(() => {
|
||||||
popoverWrap(target)
|
popoverWrap(target, callback)
|
||||||
currentlyOpen = target
|
currentlyOpen = target
|
||||||
}, DELAY_SHOW)
|
}, DELAY_SHOW)
|
||||||
}
|
}
|
||||||
@@ -323,24 +384,93 @@ function popoverHide() {
|
|||||||
}, DELAY_HIDE)
|
}, DELAY_HIDE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastFocussedLink: HTMLLinkElement | null = null
|
||||||
|
|
||||||
export function LinkPreviewPopover() {
|
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(() => {
|
useEffect(() => {
|
||||||
function showPopover(event: MouseEvent) {
|
|
||||||
// If the current window is too narrow, the popover is not useful.
|
// If the current window is too narrow, the popover is not useful.
|
||||||
// Since this is tested on every event callback here in the handler,
|
// Since this is tested on every event callback here in the handler,
|
||||||
// if the window width has changed since the mount, the number
|
// if the window width has changed since the mount, the number
|
||||||
// will change accordingly.
|
// will change accordingly.
|
||||||
if (window.innerWidth < 767) {
|
const wideEnough = window.innerWidth > 767
|
||||||
|
|
||||||
|
function showPopover(event: MouseEvent) {
|
||||||
|
if (!wideEnough) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const target = event.currentTarget as HTMLLinkElement
|
const target = event.currentTarget as HTMLLinkElement
|
||||||
popoverShow(target)
|
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() {
|
function hidePopover() {
|
||||||
popoverHide()
|
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(
|
const links = Array.from(
|
||||||
document.querySelectorAll<HTMLLinkElement>(
|
document.querySelectorAll<HTMLLinkElement>(
|
||||||
'#article-contents a[href], #article-intro a[href]',
|
'#article-contents a[href], #article-intro a[href]',
|
||||||
@@ -376,13 +506,25 @@ export function LinkPreviewPopover() {
|
|||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
link.addEventListener('mouseover', showPopover)
|
link.addEventListener('mouseover', showPopover)
|
||||||
link.addEventListener('mouseout', hidePopover)
|
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 () => {
|
return () => {
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
link.removeEventListener('mouseover', showPopover)
|
link.removeEventListener('mouseover', showPopover)
|
||||||
link.removeEventListener('mouseout', hidePopover)
|
link.removeEventListener('mouseout', hidePopover)
|
||||||
|
link.removeEventListener('keydown', keyboardHandler)
|
||||||
}
|
}
|
||||||
|
document.removeEventListener('keydown', escapeHandler)
|
||||||
}
|
}
|
||||||
}) // Note that this runs on every single mount
|
}) // Note that this runs on every single mount
|
||||||
|
|
||||||
|
|||||||
3
tests/fixtures/data/ui.yml
vendored
3
tests/fixtures/data/ui.yml
vendored
@@ -265,3 +265,6 @@ toggle_images:
|
|||||||
show_single: Show image
|
show_single: Show image
|
||||||
scroll_button:
|
scroll_button:
|
||||||
scroll_to_top: Scroll to top
|
scroll_to_top: Scroll to top
|
||||||
|
popovers:
|
||||||
|
role_description: hover card
|
||||||
|
keyboard_shortcut_description: Press alt+up to activate
|
||||||
|
|||||||
@@ -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/)
|
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')
|
await page.goto('/pages/quickstart')
|
||||||
|
|
||||||
// hover over a link and check for intro content from hovercard
|
// 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 expect(page.getByText('The most basic of fixture data for GitHub')).toBeVisible()
|
||||||
await page.locator('#article-contents').getByRole('link', { name: 'bar' }).hover()
|
await page.locator('#article-contents').getByRole('link', { name: 'bar' }).hover()
|
||||||
await expect(page.getByText("This page doesn't really have an intro")).toBeVisible()
|
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', () => {
|
test.describe('test nav at different viewports', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user