From 34aa65b18a0a233073b7883dfd1d1bbb9f6c3cde Mon Sep 17 00:00:00 2001 From: Ross Lovas Date: Thu, 13 Aug 2026 15:47:43 +0930 Subject: [PATCH] Fix disconnect between tooltip bubble and caret --- src/scripts/main.js | 2 ++ src/scripts/modules/tooltips.ts | 36 ++++++++++++++++++++++ src/styles/main.css | 54 +++++++++++++++------------------ tests/copy-button.spec.ts | 31 ++++++++++--------- 4 files changed, 79 insertions(+), 44 deletions(-) create mode 100644 src/scripts/modules/tooltips.ts diff --git a/src/scripts/main.js b/src/scripts/main.js index 166b6d8921..4326188cfd 100644 --- a/src/scripts/main.js +++ b/src/scripts/main.js @@ -12,9 +12,11 @@ import { setExternalLinkAttributes } from './modules/external-links.js'; import { monitorInputType } from './modules/input-type.js'; import { enableSharing } from './modules/share.js'; import { highlightCurrentHeading } from './modules/toc.js'; +import { enhanceTooltips } from './modules/tooltips'; const resizedEventName = addResizedEvent(); +enhanceTooltips(); setClickableBlocks(); setExternalLinkAttributes(); addStickyNavigation( diff --git a/src/scripts/modules/tooltips.ts b/src/scripts/modules/tooltips.ts new file mode 100644 index 0000000000..09f3ba82d5 --- /dev/null +++ b/src/scripts/modules/tooltips.ts @@ -0,0 +1,36 @@ +const SELECTOR = '[data-tooltip]'; + +function addTooltipSpan(host: Element): void { + let bubble = host.querySelector(':scope > .tooltip'); + if (!bubble) { + bubble = document.createElement('span'); + bubble.className = 'tooltip'; + bubble.setAttribute('aria-hidden', 'true'); + host.append(bubble); + } + bubble.textContent = (host as HTMLElement).dataset.tooltip ?? ''; +} + +export function enhanceTooltips(): void { + // Every tooltip that exists now needs an associated span + document.querySelectorAll(SELECTOR).forEach(addTooltipSpan); + + // As do any that are dynamically added later + new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'attributes' && record.target instanceof Element) { + addTooltipSpan(record.target); + } + for (const node of record.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches(SELECTOR)) addTooltipSpan(node); + node.querySelectorAll(SELECTOR).forEach(addTooltipSpan); + } + } + }).observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-tooltip'], + }); +} diff --git a/src/styles/main.css b/src/styles/main.css index b334e887d9..506c28609c 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -2867,12 +2867,18 @@ a[data-youtube] { /* Edge-aligned: the button sits at the block's right edge, where a centred bubble would hang off the side of the page. */ -.code-block .code-block__copy::after { +.code-block .code-block__copy .tooltip { left: auto; right: 0; translate: 0 var(--tooltip-shift); } +.code-block .code-block__copy .tooltip::after { + left: auto; + right: calc(1rem - var(--tooltip-arrow-size)); + translate: 0 calc(-1 * var(--tooltip-arrow-overlap)); +} + /* The single fixed language */ .code-block__language { font: var(--textBodyRegularMedium); @@ -3088,15 +3094,10 @@ a.button.button--primary:active { --tooltip-arrow-size: 7px; --tooltip-offset: calc(var(--space8) + var(--borderWidth1)); --tooltip-arrow-overlap: 1px; - --tooltip-arrow-offset: calc( - var(--tooltip-offset) - var(--tooltip-arrow-size) + - var(--tooltip-arrow-overlap) - ); } /* The bubble */ -[data-tooltip]::after { - content: attr(data-tooltip); +.tooltip { position: absolute; left: 50%; inset-block-end: calc(100% + var(--tooltip-offset)); @@ -3110,56 +3111,51 @@ a.button.button--primary:active { white-space: nowrap; pointer-events: none; opacity: 0; + transition: + opacity 375ms cubic-bezier(0.165, 0.84, 0.44, 1), + translate 375ms cubic-bezier(0.165, 0.84, 0.44, 1); + + @media (prefers-reduced-motion: reduce) { + transition: none; + } } /* The arrow. A border triangle */ -[data-tooltip]::before { +.tooltip::after { content: ''; position: absolute; left: 50%; - inset-block-end: calc(100% + var(--tooltip-arrow-offset)); - translate: -50% var(--tooltip-shift); + top: 100%; + translate: -50% calc(-1 * var(--tooltip-arrow-overlap)); width: 0; height: 0; + border-inline: var(--tooltip-arrow-size) solid transparent; border-block-start: var(--tooltip-arrow-size) solid var(--colorBackgroundInversePrimary); - border-inline: var(--tooltip-arrow-size) solid transparent; - pointer-events: none; - opacity: 0; } [data-tooltip][data-tooltip-position='bottom'] { --tooltip-shift: -10px; } -[data-tooltip][data-tooltip-position='bottom']::after { +[data-tooltip][data-tooltip-position='bottom'] .tooltip { inset-block: calc(100% + var(--tooltip-offset)) auto; } -[data-tooltip][data-tooltip-position='bottom']::before { - inset-block: calc(100% + var(--tooltip-arrow-offset)) auto; +[data-tooltip][data-tooltip-position='bottom'] .tooltip::after { + top: auto; + bottom: 100%; + translate: -50% var(--tooltip-arrow-overlap); border-block-start: 0; border-block-end: var(--tooltip-arrow-size) solid var(--colorBackgroundInversePrimary); } -[data-tooltip]::before, -[data-tooltip]::after { - transition: - opacity 375ms cubic-bezier(0.165, 0.84, 0.44, 1), - translate 375ms cubic-bezier(0.165, 0.84, 0.44, 1); - - @media (prefers-reduced-motion: reduce) { - transition: none; - } -} - [data-tooltip]:is(:hover, :focus-visible, [data-copied], [data-failed]) { --tooltip-shift: 0px; } -[data-tooltip]:is(:hover, :focus-visible, [data-copied], [data-failed])::before, -[data-tooltip]:is(:hover, :focus-visible, [data-copied], [data-failed])::after { +[data-tooltip]:is(:hover, :focus-visible, [data-copied], [data-failed]) .tooltip { opacity: 1; transition-duration: 350ms; } diff --git a/tests/copy-button.spec.ts b/tests/copy-button.spec.ts index 194b4bf2a0..f9c508fd1c 100644 --- a/tests/copy-button.spec.ts +++ b/tests/copy-button.spec.ts @@ -59,23 +59,24 @@ test('the tooltip arrow overlaps the bubble it points from', async ({ }) => { await page.goto(PAGE); - const geometry = await page - .locator('.copy-heading-url') - .first() - .evaluate((button) => { - const bubble = getComputedStyle(button, '::after'); - const arrow = getComputedStyle(button, '::before'); - return { - bubbleBottom: parseFloat(bubble.bottom), - arrowBottom: parseFloat(arrow.bottom), - arrowHeight: parseFloat(arrow.borderTopWidth), - }; - }); + const button = page.locator('.copy-heading-url').first(); + await button.locator('.tooltip').waitFor({ state: 'attached' }); + + const overlap = await button.evaluate((el) => { + const bubble = el.querySelector('.tooltip'); + if (!bubble) throw new Error('expected the button to have a .tooltip bubble'); + + const caret = getComputedStyle(bubble, '::after'); + const bubbleHeight = bubble.getBoundingClientRect().height; + const caretTop = parseFloat(caret.top); + const caretShiftY = parseFloat(caret.translate.split(' ')[1] ?? '0'); + + return bubbleHeight - (caretTop + caretShiftY); + }); - const arrowTop = geometry.arrowBottom + geometry.arrowHeight; expect( - arrowTop - geometry.bubbleBottom, - 'expected the arrow to reach past the bubble’s edge' + overlap, + 'expected the caret to reach past the bubble’s edge' ).toBeGreaterThan(0); });