Skip to content

Commit 87bddfd

Browse files
Marcel PützDevtools-frontend LUCI CQ
authored andcommitted
Improve how a tooltip can be connected to an anchor
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]>
1 parent 0621aea commit 87bddfd

File tree

3 files changed

+162
-15
lines changed

3 files changed

+162
-15
lines changed

front_end/ui/components/docs/tooltip/basic.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import '../../tooltip/Tooltip.js';
6-
75
import * as FrontendHelpers from '../../../../testing/EnvironmentHelpers.js';
86
import * as Lit from '../../../lit/lit.js';
97
import * as ComponentHelpers from '../../helpers/helpers.js';
8+
import {Tooltip} from '../../tooltip/Tooltip.js';
109

1110
const {html} = Lit;
1211

@@ -26,17 +25,27 @@ Lit.render(
2625
<devtools-tooltip id="simple-tooltip">Simple content</devtools-tooltip>
2726
</div>
2827
<div style="position: relative; z-index: 0;">
29-
<button aria-describedby="rich-tooltip" style="position: absolute; left: 16px; top: 116px;">
28+
<button aria-details="rich-tooltip" style="position: absolute; left: 16px; top: 116px;">
3029
Rich
3130
</button>
3231
<devtools-tooltip id="rich-tooltip" variant="rich">
3332
<p>Rich tooltip</p>
3433
<button>Action</button>
3534
</devtools-tooltip>
3635
</div>
36+
<div>
37+
<button class="anchor" style="position: absolute; left: 16px; top: 216px;">
38+
Programmatic creation
39+
</button>
40+
</div>
3741
`,
3842
container);
3943

44+
const anchor = container.querySelector('.anchor') as HTMLElement;
45+
const programmaticTooltip = new Tooltip({id: 'programatic', variant: 'rich', anchor});
46+
programmaticTooltip.append('Text content');
47+
anchor.appendChild(programmaticTooltip);
48+
4049
// Make the buttons draggable, so that we can experiment with the position of the tooltip.
4150
container.querySelectorAll('button').forEach(draggable);
4251
function draggable(element: HTMLElement|null) {

front_end/ui/components/tooltip/Tooltip.test.ts

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,32 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5-
import './Tooltip.js';
6-
75
import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
86
import {checkForPendingActivity} from '../../../testing/TrackAsyncOperations.js';
97
import * as Lit from '../../lit/lit.js';
108

9+
import * as TooltipModule from './Tooltip.js';
1110
import type {TooltipVariant} from './Tooltip.js';
1211

13-
const {html} = Lit;
12+
const {
13+
closestAnchor,
14+
Tooltip,
15+
} = TooltipModule;
16+
17+
const {html, Directives} = Lit;
18+
const {ref, createRef} = Directives;
1419

15-
function renderTooltip({variant}: {variant: TooltipVariant} = {
16-
variant: 'simple'
17-
}) {
20+
function renderTooltip(
21+
{variant = 'simple',
22+
attribute = 'aria-describedby'}: {variant?: TooltipVariant, attribute?: 'aria-describedby'|'aria-details'} = {}) {
1823
const container = document.createElement('div');
1924
// clang-format off
2025
Lit.render(html`
21-
<button aria-describedby="simple-tooltip">Simple</button>
22-
<devtools-tooltip id="simple-tooltip" variant=${variant}>
26+
${attribute === 'aria-details' ?
27+
html`<button aria-details="tooltip-id">Button</button>` :
28+
html`<button aria-describedby="tooltip-id">Button</button>`
29+
}
30+
<devtools-tooltip id="tooltip-id" variant=${variant}>
2331
${variant === 'rich' ? html`<p>Rich content</p>` : 'Simple content'}
2432
</devtools-tooltip>
2533
`, container);
@@ -37,7 +45,7 @@ describe('Tooltip', () => {
3745
});
3846

3947
it('renders a rich tooltip', () => {
40-
const container = renderTooltip({variant: 'rich'});
48+
const container = renderTooltip({variant: 'rich', attribute: 'aria-details'});
4149
const tooltip = container.querySelector('devtools-tooltip');
4250
assert.strictEqual(tooltip?.variant, 'rich');
4351
assert.strictEqual(container.querySelector('devtools-tooltip')?.querySelector('p')?.textContent, 'Rich content');
@@ -68,4 +76,100 @@ describe('Tooltip', () => {
6876
container.removeEventListener(eventName, callback);
6977
});
7078
});
79+
80+
it('should print a warning if rich tooltip is used with wrong aria label on anchor', () => {
81+
const consoleSpy = sinon.spy(console, 'warn');
82+
renderTooltip({variant: 'rich'});
83+
assert.isTrue(consoleSpy.calledOnce);
84+
});
85+
86+
it('can be instantiated programatically', () => {
87+
const container = document.createElement('div');
88+
const anchor = document.createElement('button');
89+
const tooltip = new Tooltip({id: 'tooltip-id', anchor});
90+
tooltip.append('Text content');
91+
container.appendChild(anchor);
92+
container.appendChild(tooltip);
93+
renderElementIntoDOM(container);
94+
95+
assert.strictEqual(anchor.style.anchorName, '--tooltip-id-anchor');
96+
});
97+
});
98+
99+
describe('closestAnchor', () => {
100+
function renderTemplate(template: Lit.TemplateResult) {
101+
const container = document.createElement('div');
102+
Lit.render(template, container);
103+
renderElementIntoDOM(container);
104+
}
105+
106+
it('finds a previous sibling anchor', () => {
107+
const origin = createRef();
108+
const expectedAchnor = createRef();
109+
// clang-format off
110+
renderTemplate(html`
111+
<div class="anchor" ${ref(expectedAchnor)}></div>
112+
<div ${ref(origin)}></div>
113+
`);
114+
// clang-format on
115+
116+
const actual = closestAnchor(origin.value!, '.anchor');
117+
118+
assert.strictEqual(actual, expectedAchnor.value);
119+
});
120+
121+
it('finds a parent', () => {
122+
const origin = createRef();
123+
const expectedAchnor = createRef();
124+
// clang-format off
125+
renderTemplate(html`
126+
<div class="anchor" ${ref(expectedAchnor)}>
127+
<div ${ref(origin)}></div>
128+
</div>
129+
`);
130+
// clang-format on
131+
132+
const actual = closestAnchor(origin.value!, '.anchor');
133+
134+
assert.strictEqual(actual, expectedAchnor.value);
135+
});
136+
137+
it('finds an ancestors decendant', () => {
138+
const origin = createRef();
139+
const expectedAchnor = createRef();
140+
// clang-format off
141+
renderTemplate(html`
142+
<div>
143+
<div>
144+
<div class="anchor" ${ref(expectedAchnor)}></div>
145+
</div>
146+
<div>
147+
<div ${ref(origin)}></div>
148+
</div>
149+
</div>
150+
`);
151+
// clang-format on
152+
153+
const actual = closestAnchor(origin.value!, '.anchor');
154+
155+
assert.strictEqual(actual, expectedAchnor.value);
156+
});
157+
158+
it('takes the next anchor up the tree', () => {
159+
const origin = createRef();
160+
const expectedAchnor = createRef();
161+
// clang-format off
162+
renderTemplate(html`
163+
<div class="anchor a"></div>
164+
<div class="anchor b"></div>
165+
<div class="anchor c" ${ref(expectedAchnor)}></div>
166+
<div ${ref(origin)}></div>
167+
<div class="anchor d"></div>
168+
`);
169+
// clang-format on
170+
171+
const actual = closestAnchor(origin.value!, '.anchor');
172+
173+
assert.strictEqual(actual, expectedAchnor.value);
174+
});
71175
});

front_end/ui/components/tooltip/Tooltip.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ const {html} = Lit;
1010

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

13+
export interface TooltipProperties {
14+
id?: string;
15+
variant?: TooltipVariant;
16+
anchor?: HTMLElement;
17+
}
18+
1319
/**
1420
* @attr id - Id of the tooltip. Used for searching an anchor element with aria-describedby.
1521
* @attr hover-delay - Hover length in ms before the tooltip is shown and hidden.
@@ -40,7 +46,22 @@ export class Tooltip extends HTMLElement {
4046
this.setAttribute('variant', variant);
4147
}
4248

49+
constructor({id, variant, anchor}: TooltipProperties = {}) {
50+
super();
51+
if (id) {
52+
this.id = id;
53+
}
54+
if (variant) {
55+
this.variant = variant;
56+
}
57+
this.#anchor = anchor ?? null;
58+
}
59+
4360
attributeChangedCallback(name: string): void {
61+
if (!this.isConnected) {
62+
// There is no need to do anything before the connectedCallback is called.
63+
return;
64+
}
4465
if (name === 'id') {
4566
this.#removeEventListeners();
4667
this.#attachToAnchor();
@@ -121,7 +142,6 @@ export class Tooltip extends HTMLElement {
121142
if (this.#anchor) {
122143
this.#anchor.removeEventListener('mouseenter', this.showTooltip);
123144
this.#anchor.removeEventListener('mouseleave', this.hideTooltip);
124-
this.#anchor.removeEventListener('click', this.#preventDefault);
125145
}
126146
this.removeEventListener('mouseleave', this.hideTooltip);
127147
this.removeEventListener('click', this.#stopPropagation);
@@ -133,22 +153,36 @@ export class Tooltip extends HTMLElement {
133153
if (!id) {
134154
throw new Error('<devtools-tooltip> must have an id.');
135155
}
136-
const anchor = (this.getRootNode() as Element).querySelector(`[aria-describedby="${id}"]`);
156+
const describedbyAnchor = closestAnchor(this, `[aria-describedby="${id}"]`);
157+
const detailsAnchor = closestAnchor(this, `[aria-details="${id}"]`);
158+
const anchor = this.#anchor ?? describedbyAnchor ?? detailsAnchor;
137159
if (!anchor) {
138160
throw new Error(`No anchor for tooltip with id ${id} found.`);
139161
}
140162
if (!(anchor instanceof HTMLElement)) {
141163
throw new Error('Anchor must be an HTMLElement.');
142164
}
165+
if (this.variant === 'rich' && describedbyAnchor) {
166+
console.warn(`The anchor for tooltip ${
167+
id} was defined with "aria-describedby". For rich tooltips "aria-details" is more appropriate.`);
168+
}
143169

144170
const anchorName = `--${id}-anchor`;
145171
anchor.style.anchorName = anchorName;
146-
anchor.setAttribute('popovertarget', id);
147172
this.style.positionAnchor = anchorName;
148173
this.#anchor = anchor;
149174
}
150175
}
151176

177+
export function closestAnchor(tooltip: Element, selector: string): Element|null {
178+
const anchors: NodeListOf<Element>|undefined = (tooltip.getRootNode() as Element)?.querySelectorAll(selector);
179+
// Find the last anchor with a matching selector that is before the tooltip in the document order.
180+
const anchor = [...anchors ?? []]
181+
.filter(anchor => tooltip.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_PRECEDING)
182+
.at(-1);
183+
return anchor ?? null;
184+
}
185+
152186
customElements.define('devtools-tooltip', Tooltip);
153187

154188
declare global {

0 commit comments

Comments
 (0)