Skip to content

Commit

Permalink
Improve how a tooltip can be connected to an anchor
Browse files Browse the repository at this point in the history
Key changes:
* Easier programmatic instantiation of tooltips with passing down anchor reference and other props via constructor
* `aria-details` is now also supported to connect the anchor element (and a warning is filed for rich tooltips not using this)
* The matching anchor closest to the tooltip element is used, so that tooltip ids don't necessarily have to be unique.

Change-Id: I6a083cd1cf64e22653b8065eb171d240eccb2943
Bug: 392078321
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6286493
Commit-Queue: Marcel Pütz <[email protected]>
Reviewed-by: Philip Pfaffe <[email protected]>
Reviewed-by: Kateryna Prokopenko <[email protected]>
  • Loading branch information
Marcel Pütz authored and Devtools-frontend LUCI CQ committed Feb 21, 2025
1 parent 0621aea commit 87bddfd
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 15 deletions.
15 changes: 12 additions & 3 deletions front_end/ui/components/docs/tooltip/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import '../../tooltip/Tooltip.js';

import * as FrontendHelpers from '../../../../testing/EnvironmentHelpers.js';
import * as Lit from '../../../lit/lit.js';
import * as ComponentHelpers from '../../helpers/helpers.js';
import {Tooltip} from '../../tooltip/Tooltip.js';

const {html} = Lit;

Expand All @@ -26,17 +25,27 @@ Lit.render(
<devtools-tooltip id="simple-tooltip">Simple content</devtools-tooltip>
</div>
<div style="position: relative; z-index: 0;">
<button aria-describedby="rich-tooltip" style="position: absolute; left: 16px; top: 116px;">
<button aria-details="rich-tooltip" style="position: absolute; left: 16px; top: 116px;">
Rich
</button>
<devtools-tooltip id="rich-tooltip" variant="rich">
<p>Rich tooltip</p>
<button>Action</button>
</devtools-tooltip>
</div>
<div>
<button class="anchor" style="position: absolute; left: 16px; top: 216px;">
Programmatic creation
</button>
</div>
`,
container);

const anchor = container.querySelector('.anchor') as HTMLElement;
const programmaticTooltip = new Tooltip({id: 'programatic', variant: 'rich', anchor});
programmaticTooltip.append('Text content');
anchor.appendChild(programmaticTooltip);

// Make the buttons draggable, so that we can experiment with the position of the tooltip.
container.querySelectorAll('button').forEach(draggable);
function draggable(element: HTMLElement|null) {
Expand Down
122 changes: 113 additions & 9 deletions front_end/ui/components/tooltip/Tooltip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './Tooltip.js';

import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
import {checkForPendingActivity} from '../../../testing/TrackAsyncOperations.js';
import * as Lit from '../../lit/lit.js';

import * as TooltipModule from './Tooltip.js';
import type {TooltipVariant} from './Tooltip.js';

const {html} = Lit;
const {
closestAnchor,
Tooltip,
} = TooltipModule;

const {html, Directives} = Lit;
const {ref, createRef} = Directives;

function renderTooltip({variant}: {variant: TooltipVariant} = {
variant: 'simple'
}) {
function renderTooltip(
{variant = 'simple',
attribute = 'aria-describedby'}: {variant?: TooltipVariant, attribute?: 'aria-describedby'|'aria-details'} = {}) {
const container = document.createElement('div');
// clang-format off
Lit.render(html`
<button aria-describedby="simple-tooltip">Simple</button>
<devtools-tooltip id="simple-tooltip" variant=${variant}>
${attribute === 'aria-details' ?
html`<button aria-details="tooltip-id">Button</button>` :
html`<button aria-describedby="tooltip-id">Button</button>`
}
<devtools-tooltip id="tooltip-id" variant=${variant}>
${variant === 'rich' ? html`<p>Rich content</p>` : 'Simple content'}
</devtools-tooltip>
`, container);
Expand All @@ -37,7 +45,7 @@ describe('Tooltip', () => {
});

it('renders a rich tooltip', () => {
const container = renderTooltip({variant: 'rich'});
const container = renderTooltip({variant: 'rich', attribute: 'aria-details'});
const tooltip = container.querySelector('devtools-tooltip');
assert.strictEqual(tooltip?.variant, 'rich');
assert.strictEqual(container.querySelector('devtools-tooltip')?.querySelector('p')?.textContent, 'Rich content');
Expand Down Expand Up @@ -68,4 +76,100 @@ describe('Tooltip', () => {
container.removeEventListener(eventName, callback);
});
});

it('should print a warning if rich tooltip is used with wrong aria label on anchor', () => {
const consoleSpy = sinon.spy(console, 'warn');
renderTooltip({variant: 'rich'});
assert.isTrue(consoleSpy.calledOnce);
});

it('can be instantiated programatically', () => {
const container = document.createElement('div');
const anchor = document.createElement('button');
const tooltip = new Tooltip({id: 'tooltip-id', anchor});
tooltip.append('Text content');
container.appendChild(anchor);
container.appendChild(tooltip);
renderElementIntoDOM(container);

assert.strictEqual(anchor.style.anchorName, '--tooltip-id-anchor');
});
});

describe('closestAnchor', () => {
function renderTemplate(template: Lit.TemplateResult) {
const container = document.createElement('div');
Lit.render(template, container);
renderElementIntoDOM(container);
}

it('finds a previous sibling anchor', () => {
const origin = createRef();
const expectedAchnor = createRef();
// clang-format off
renderTemplate(html`
<div class="anchor" ${ref(expectedAchnor)}></div>
<div ${ref(origin)}></div>
`);
// clang-format on

const actual = closestAnchor(origin.value!, '.anchor');

assert.strictEqual(actual, expectedAchnor.value);
});

it('finds a parent', () => {
const origin = createRef();
const expectedAchnor = createRef();
// clang-format off
renderTemplate(html`
<div class="anchor" ${ref(expectedAchnor)}>
<div ${ref(origin)}></div>
</div>
`);
// clang-format on

const actual = closestAnchor(origin.value!, '.anchor');

assert.strictEqual(actual, expectedAchnor.value);
});

it('finds an ancestors decendant', () => {
const origin = createRef();
const expectedAchnor = createRef();
// clang-format off
renderTemplate(html`
<div>
<div>
<div class="anchor" ${ref(expectedAchnor)}></div>
</div>
<div>
<div ${ref(origin)}></div>
</div>
</div>
`);
// clang-format on

const actual = closestAnchor(origin.value!, '.anchor');

assert.strictEqual(actual, expectedAchnor.value);
});

it('takes the next anchor up the tree', () => {
const origin = createRef();
const expectedAchnor = createRef();
// clang-format off
renderTemplate(html`
<div class="anchor a"></div>
<div class="anchor b"></div>
<div class="anchor c" ${ref(expectedAchnor)}></div>
<div ${ref(origin)}></div>
<div class="anchor d"></div>
`);
// clang-format on

const actual = closestAnchor(origin.value!, '.anchor');

assert.strictEqual(actual, expectedAchnor.value);
});
});
40 changes: 37 additions & 3 deletions front_end/ui/components/tooltip/Tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const {html} = Lit;

export type TooltipVariant = 'simple'|'rich';

export interface TooltipProperties {
id?: string;
variant?: TooltipVariant;
anchor?: HTMLElement;
}

/**
* @attr id - Id of the tooltip. Used for searching an anchor element with aria-describedby.
* @attr hover-delay - Hover length in ms before the tooltip is shown and hidden.
Expand Down Expand Up @@ -40,7 +46,22 @@ export class Tooltip extends HTMLElement {
this.setAttribute('variant', variant);
}

constructor({id, variant, anchor}: TooltipProperties = {}) {
super();
if (id) {
this.id = id;
}
if (variant) {
this.variant = variant;
}
this.#anchor = anchor ?? null;
}

attributeChangedCallback(name: string): void {
if (!this.isConnected) {
// There is no need to do anything before the connectedCallback is called.
return;
}
if (name === 'id') {
this.#removeEventListeners();
this.#attachToAnchor();
Expand Down Expand Up @@ -121,7 +142,6 @@ export class Tooltip extends HTMLElement {
if (this.#anchor) {
this.#anchor.removeEventListener('mouseenter', this.showTooltip);
this.#anchor.removeEventListener('mouseleave', this.hideTooltip);
this.#anchor.removeEventListener('click', this.#preventDefault);
}
this.removeEventListener('mouseleave', this.hideTooltip);
this.removeEventListener('click', this.#stopPropagation);
Expand All @@ -133,22 +153,36 @@ export class Tooltip extends HTMLElement {
if (!id) {
throw new Error('<devtools-tooltip> must have an id.');
}
const anchor = (this.getRootNode() as Element).querySelector(`[aria-describedby="${id}"]`);
const describedbyAnchor = closestAnchor(this, `[aria-describedby="${id}"]`);
const detailsAnchor = closestAnchor(this, `[aria-details="${id}"]`);
const anchor = this.#anchor ?? describedbyAnchor ?? detailsAnchor;
if (!anchor) {
throw new Error(`No anchor for tooltip with id ${id} found.`);
}
if (!(anchor instanceof HTMLElement)) {
throw new Error('Anchor must be an HTMLElement.');
}
if (this.variant === 'rich' && describedbyAnchor) {
console.warn(`The anchor for tooltip ${
id} was defined with "aria-describedby". For rich tooltips "aria-details" is more appropriate.`);
}

const anchorName = `--${id}-anchor`;
anchor.style.anchorName = anchorName;
anchor.setAttribute('popovertarget', id);
this.style.positionAnchor = anchorName;
this.#anchor = anchor;
}
}

export function closestAnchor(tooltip: Element, selector: string): Element|null {
const anchors: NodeListOf<Element>|undefined = (tooltip.getRootNode() as Element)?.querySelectorAll(selector);
// Find the last anchor with a matching selector that is before the tooltip in the document order.
const anchor = [...anchors ?? []]
.filter(anchor => tooltip.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_PRECEDING)
.at(-1);
return anchor ?? null;
}

customElements.define('devtools-tooltip', Tooltip);

declare global {
Expand Down

0 comments on commit 87bddfd

Please sign in to comment.