chrome-devtools-frontend
Version:
Chrome DevTools UI
147 lines (124 loc) • 4.65 kB
text/typescript
// Copyright 2025 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Lit from '../../lit/lit.js';
import tooltipStyles from './tooltip.css.js';
const {html} = Lit;
export type TooltipVariant = 'simple'|'rich';
/**
* @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.
* @attr variant - Variant of the tooltip, `"simple"` for strings only, inverted background,
* `"rich"` for interactive content, background according to theme's surface.
* @prop {String} id - reflects the `"id"` attribute.
* @prop {Number} hoverDelay - reflects the `"hover-delay"` attribute.
* @prop {String} variant - reflects the `"variant"` attribute.
*/
export class Tooltip extends HTMLElement {
static readonly observedAttributes = ['id', 'variant'];
readonly #shadow = this.attachShadow({mode: 'open'});
#anchor: HTMLElement|null = null;
#timeout: number|null = null;
get hoverDelay(): number {
return this.hasAttribute('hover-delay') ? Number(this.getAttribute('hover-delay')) : 200;
}
set hoverDelay(delay: number) {
this.setAttribute('hover-delay', delay.toString());
}
get variant(): TooltipVariant {
return this.getAttribute('variant') === 'rich' ? 'rich' : 'simple';
}
set variant(variant: TooltipVariant) {
this.setAttribute('variant', variant);
}
attributeChangedCallback(name: string): void {
if (name === 'id') {
this.#removeEventListeners();
this.#attachToAnchor();
}
}
connectedCallback(): void {
this.#attachToAnchor();
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'tooltip');
}
this.setAttribute('popover', 'manual');
// clang-format off
Lit.render(html`
<style>${tooltipStyles.cssContent}</style>
<!-- Wrapping it into a container, so that the tooltip doesn't disappear when the mouse moves from the anchor to the tooltip. -->
<div class="container">
<slot></slot>
</div>
`, this.#shadow, {host: this});
// clang-format on
}
disconnectedCallback(): void {
this.#removeEventListeners();
}
showTooltip = (): void => {
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
this.#timeout = window.setTimeout(() => {
this.showPopover();
}, this.hoverDelay);
};
hideTooltip = (event: MouseEvent): void => {
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
// Don't hide a rich tooltip when hovering over the tooltip itself.
if (this.variant === 'rich' && event.relatedTarget === this) {
return;
}
this.#timeout = window.setTimeout(() => {
this.hidePopover();
}, this.hoverDelay);
};
#preventDefault(event: Event): void {
event.preventDefault();
}
#removeEventListeners(): void {
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);
}
}
#attachToAnchor(): void {
const id = this.getAttribute('id');
if (!id) {
throw new Error('<devtools-tooltip> must have an id.');
}
const anchor = (this.getRootNode() as Element).querySelector(`[aria-describedby="${id}"]`);
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.');
}
const anchorName = `--${id}-anchor`;
anchor.style.anchorName = anchorName;
anchor.setAttribute('popovertarget', id);
this.style.positionAnchor = anchorName;
this.#anchor = anchor;
this.#anchor.addEventListener('mouseenter', this.showTooltip);
this.#anchor.addEventListener('mouseleave', this.hideTooltip);
// By default the anchor with a popovertarget would toggle the popover on click.
this.#anchor.addEventListener('click', this.#preventDefault);
this.addEventListener('mouseleave', this.hideTooltip);
}
}
customElements.define('devtools-tooltip', Tooltip);
declare global {
interface HTMLElementTagNameMap {
'devtools-tooltip': Tooltip;
}
// Remove this once the CSSStyleDeclaration interface is updated in the TypeScript standard library.
interface CSSStyleDeclaration {
anchorName: string;
positionAnchor: string;
}
}