Skip to content

Commit a520c17

Browse files
committed
Add tooltip enhanced styling / component
1 parent e7b9091 commit a520c17

13 files changed

Lines changed: 630 additions & 76 deletions

File tree

src/components/Footer/index.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ const footerTextLinkClasses = [
252252
{/* Page footer RSS link */}
253253
<a
254254
href="/rss.xml"
255+
data-tooltip
255256
title="RSS Feed"
256257
class={iconButtonClasses}
257258
>
@@ -263,6 +264,7 @@ const footerTextLinkClasses = [
263264
<button
264265
id="bugReporterTrigger"
265266
type="button"
267+
data-tooltip
266268
title="Report a bug"
267269
class={iconButtonClasses}
268270
>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { beforeEach, describe, expect, test } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import TooltipAstro from '@components/Tooltip/index.astro'
4+
import {
5+
enhanceTooltipElements,
6+
type TooltipElement as TooltipElementInstance,
7+
} from '@components/Tooltip/client'
8+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
9+
import { executeRender } from '@test/unit/helpers/litRuntime'
10+
import { getTooltipElements } from '../selectors'
11+
12+
type TooltipComponentModule = WebComponentModule<TooltipElementInstance>
13+
14+
describe('TooltipElement', () => {
15+
let container: AstroContainer
16+
17+
beforeEach(async () => {
18+
container = await AstroContainer.create()
19+
})
20+
21+
const runComponentRender = async (
22+
args: Parameters<AstroContainer['renderToString']>[1],
23+
assertion: (_context: {
24+
element: TooltipElementInstance
25+
window: Window
26+
}) => Promise<void> | void
27+
): Promise<void> => {
28+
await executeRender<TooltipComponentModule>({
29+
container,
30+
component: TooltipAstro,
31+
moduleSpecifier: '@components/Tooltip/client/index',
32+
args,
33+
waitForReady: async (element: TooltipElementInstance) => {
34+
await element.updateComplete
35+
},
36+
assert: async ({ element, window }) => {
37+
await assertion({ element, window: window as unknown as Window })
38+
},
39+
})
40+
}
41+
42+
test('wires aria-describedby and keeps the tooltip hidden by default', async () => {
43+
await runComponentRender(
44+
{
45+
props: {
46+
text: 'HyperText Markup Language',
47+
},
48+
slots: {
49+
default: '<abbr>HTML</abbr>',
50+
},
51+
},
52+
async ({ element }) => {
53+
const { trigger, tooltip } = getTooltipElements(element)
54+
55+
expect(trigger.getAttribute('aria-describedby')).toBe(tooltip.id)
56+
expect(trigger.tabIndex).toBe(0)
57+
expect(tooltip.classList.contains('hidden')).toBe(true)
58+
expect(tooltip.getAttribute('aria-hidden')).toBe('true')
59+
}
60+
)
61+
})
62+
63+
test('shows on hover and hides on mouse leave', async () => {
64+
await runComponentRender(
65+
{
66+
props: {
67+
text: 'HyperText Markup Language',
68+
},
69+
slots: {
70+
default: '<abbr>HTML</abbr>',
71+
},
72+
},
73+
async ({ element }) => {
74+
const { trigger, tooltip } = getTooltipElements(element)
75+
76+
trigger.dispatchEvent(new Event('mouseenter'))
77+
await element.updateComplete
78+
79+
expect(tooltip.classList.contains('hidden')).toBe(false)
80+
expect(tooltip.getAttribute('aria-hidden')).toBe('false')
81+
82+
trigger.dispatchEvent(new Event('mouseleave'))
83+
await element.updateComplete
84+
85+
expect(tooltip.classList.contains('hidden')).toBe(true)
86+
expect(tooltip.getAttribute('aria-hidden')).toBe('true')
87+
}
88+
)
89+
})
90+
91+
test('closes when escape is pressed after opening on focus', async () => {
92+
await runComponentRender(
93+
{
94+
props: {
95+
text: 'Tooltip body',
96+
},
97+
slots: {
98+
default: 'Info',
99+
},
100+
},
101+
async ({ element }) => {
102+
const { trigger, tooltip } = getTooltipElements(element)
103+
104+
trigger.dispatchEvent(new Event('focusin', { bubbles: true }))
105+
await element.updateComplete
106+
expect(tooltip.classList.contains('hidden')).toBe(false)
107+
108+
const escapeEvent = new Event('keyup', { bubbles: true })
109+
Object.defineProperty(escapeEvent, 'key', { value: 'Escape' })
110+
trigger.dispatchEvent(escapeEvent)
111+
await element.updateComplete
112+
expect(tooltip.classList.contains('hidden')).toBe(true)
113+
}
114+
)
115+
})
116+
117+
test('does not add a wrapper tabindex when the slotted content is already focusable', async () => {
118+
await runComponentRender(
119+
{
120+
props: {
121+
text: 'Open documentation',
122+
},
123+
slots: {
124+
default: '<a href="/docs">Docs</a>',
125+
},
126+
},
127+
async ({ element }) => {
128+
const { trigger } = getTooltipElements(element)
129+
130+
expect(trigger.hasAttribute('tabindex')).toBe(false)
131+
}
132+
)
133+
})
134+
135+
test('upgrades markdown-style tooltip candidates without throwing during connection', async () => {
136+
await runComponentRender(
137+
{
138+
props: {
139+
text: 'Tooltip body',
140+
},
141+
slots: {
142+
default: 'Info',
143+
},
144+
},
145+
async ({ window }) => {
146+
const host = window.document.createElement('div')
147+
host.innerHTML = '<abbr data-tooltip="" title="Pod Security Policy">PSP</abbr>'
148+
window.document.body.append(host)
149+
150+
expect(() => enhanceTooltipElements(host)).not.toThrow()
151+
152+
const upgradedTooltip = host.querySelector('site-tooltip')
153+
const trigger = upgradedTooltip?.querySelector('[data-tooltip-trigger]')
154+
const tooltip = upgradedTooltip?.querySelector('[data-tooltip-popup]')
155+
156+
expect(upgradedTooltip).toBeTruthy()
157+
expect(trigger?.textContent).toBe('PSP')
158+
expect(tooltip?.textContent).toBe('Pod Security Policy')
159+
}
160+
)
161+
})
162+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
3+
import TooltipAstro from '@components/Tooltip/index.astro'
4+
import type { TooltipElement as TooltipElementInstance } from '@components/Tooltip/client'
5+
import type { WebComponentModule } from '@components/scripts/@types/webComponentModule'
6+
import { executeRender } from '@test/unit/helpers/litRuntime'
7+
import {
8+
SELECTORS,
9+
getTooltipElements,
10+
queryTooltipFocusableDescendant,
11+
queryTooltipInteractiveDescendant,
12+
} from '../selectors'
13+
14+
type TooltipComponentModule = WebComponentModule<TooltipElementInstance>
15+
16+
describe('Tooltip selectors', () => {
17+
let container: AstroContainer
18+
19+
beforeEach(async () => {
20+
container = await AstroContainer.create()
21+
})
22+
23+
it('selectors stay in sync with the tooltip layout', async () => {
24+
await executeRender<TooltipComponentModule>({
25+
container,
26+
component: TooltipAstro,
27+
moduleSpecifier: '@components/Tooltip/client/index',
28+
args: {
29+
props: {
30+
text: 'Tooltip body',
31+
},
32+
slots: {
33+
default: '<a href="/docs">Docs</a>',
34+
},
35+
},
36+
waitForReady: async (element: TooltipElementInstance) => {
37+
await element.updateComplete
38+
},
39+
assert: async ({ element }) => {
40+
const { trigger, tooltip } = getTooltipElements(element)
41+
const focusableDescendant = queryTooltipFocusableDescendant(trigger)
42+
const interactiveDescendant = queryTooltipInteractiveDescendant(trigger)
43+
44+
expect(trigger.matches(SELECTORS.trigger)).toBe(true)
45+
expect(tooltip.matches(SELECTORS.tooltip)).toBe(true)
46+
expect(tooltip.getAttribute('role')).toBe('tooltip')
47+
expect(focusableDescendant).toBeInstanceOf(HTMLAnchorElement)
48+
expect(interactiveDescendant).toBeInstanceOf(HTMLAnchorElement)
49+
},
50+
})
51+
})
52+
})

0 commit comments

Comments
 (0)