chrome-devtools-frontend
Version:
Chrome DevTools UI
669 lines (597 loc) • 25.2 kB
text/typescript
// Copyright 2025 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-lit-render-outside-of-view, @devtools/enforce-custom-element-definitions-location */
import * as UI from '../../legacy/legacy.js';
import * as Lit from '../../lit/lit.js';
import * as VisualLogging from '../../visual_logging/visual_logging.js';
import tooltipStyles from './tooltip.css.js';
const {html} = Lit;
interface ProposedRect {
left: number;
top: number;
}
interface PositioningParams {
anchorRect: DOMRect;
currentPopoverRect: DOMRect;
}
export enum PositionOption {
BOTTOM_SPAN_RIGHT = 'bottom-span-right',
BOTTOM_SPAN_LEFT = 'bottom-span-left',
TOP_SPAN_RIGHT = 'top-span-right',
TOP_SPAN_LEFT = 'top-span-left',
}
const positioningUtils = {
bottomSpanRight: ({anchorRect}: PositioningParams): ProposedRect => {
return {
left: anchorRect.left,
top: anchorRect.bottom,
};
},
bottomSpanLeft: ({anchorRect, currentPopoverRect}: PositioningParams): ProposedRect => {
return {
left: anchorRect.right - currentPopoverRect.width,
top: anchorRect.bottom,
};
},
bottomCentered: ({anchorRect, currentPopoverRect}: PositioningParams): ProposedRect => {
return {
left: anchorRect.left + anchorRect.width / 2 - currentPopoverRect.width / 2,
top: anchorRect.bottom,
};
},
topCentered: ({anchorRect, currentPopoverRect}: PositioningParams): ProposedRect => {
return {
left: anchorRect.left + anchorRect.width / 2 - currentPopoverRect.width / 2,
top: anchorRect.top - currentPopoverRect.height,
};
},
topSpanRight: ({anchorRect, currentPopoverRect}: PositioningParams): ProposedRect => {
return {
left: anchorRect.left,
top: anchorRect.top - currentPopoverRect.height,
};
},
topSpanLeft: ({anchorRect, currentPopoverRect}: PositioningParams): ProposedRect => {
return {
left: anchorRect.right - currentPopoverRect.width,
top: anchorRect.top - currentPopoverRect.height,
};
},
// Adjusts proposed rect so that the resulting popover is always inside the inspector view bounds.
insetAdjustedRect: ({inspectorViewRect, currentPopoverRect, proposedRect}:
{inspectorViewRect: DOMRect, currentPopoverRect: DOMRect, proposedRect: ProposedRect}):
ProposedRect => {
if (inspectorViewRect.left > proposedRect.left) {
proposedRect.left = inspectorViewRect.left;
}
if (inspectorViewRect.right < proposedRect.left + currentPopoverRect.width) {
proposedRect.left = inspectorViewRect.right - currentPopoverRect.width;
}
if (proposedRect.top < inspectorViewRect.top) {
proposedRect.top = inspectorViewRect.top;
}
if (proposedRect.top + currentPopoverRect.height > inspectorViewRect.bottom) {
proposedRect.top = inspectorViewRect.bottom - currentPopoverRect.height;
}
return proposedRect;
},
isInBounds: ({inspectorViewRect, currentPopoverRect, proposedRect}:
{inspectorViewRect: DOMRect, currentPopoverRect: DOMRect, proposedRect: ProposedRect}): boolean => {
return inspectorViewRect.left <= proposedRect.left &&
proposedRect.left + currentPopoverRect.width <= inspectorViewRect.right &&
inspectorViewRect.top <= proposedRect.top &&
proposedRect.top + currentPopoverRect.height <= inspectorViewRect.bottom;
},
isSameRect: (rect1: DOMRect|null, rect2: DOMRect|null): boolean => {
if (!rect1 || !rect2) {
return false;
}
return rect1 && rect1.left === rect2.left && rect1.top === rect2.top && rect1.width === rect2.width &&
rect1.height === rect2.height;
}
};
export const proposedRectForRichTooltip = ({inspectorViewRect, anchorRect, currentPopoverRect, preferredPositions}: {
inspectorViewRect: DOMRect,
anchorRect: DOMRect,
currentPopoverRect: DOMRect,
preferredPositions: PositionOption[],
}): ProposedRect => {
// The default positioning order is `BOTTOM_SPAN_RIGHT`, `BOTTOM_SPAN_LEFT`, `TOP_SPAN_RIGHT`
// and `TOP_SPAN_LEFT`. If `preferredPositions` are given, those are tried first, before
// continuing with the remaining options in default order. Duplicate entries are removed.
const uniqueOrder = [
...new Set([
...preferredPositions,
...Object.values(PositionOption),
]),
];
const getProposedRectForPositionOption = (positionOption: PositionOption): ProposedRect => {
switch (positionOption) {
case PositionOption.BOTTOM_SPAN_RIGHT:
return positioningUtils.bottomSpanRight({anchorRect, currentPopoverRect});
case PositionOption.BOTTOM_SPAN_LEFT:
return positioningUtils.bottomSpanLeft({anchorRect, currentPopoverRect});
case PositionOption.TOP_SPAN_RIGHT:
return positioningUtils.topSpanRight({anchorRect, currentPopoverRect});
case PositionOption.TOP_SPAN_LEFT:
return positioningUtils.topSpanLeft({anchorRect, currentPopoverRect});
}
};
// Tries the positioning options in the order given by `uniqueOrder`.
for (const positionOption of uniqueOrder) {
const proposedRect = getProposedRectForPositionOption(positionOption);
if (positioningUtils.isInBounds({inspectorViewRect, currentPopoverRect, proposedRect})) {
return proposedRect;
}
}
// If none of the options above work, we decide between top or bottom by which
// option is fewer vertical pixels out of the viewport. We pick left/right
// according to `uniqueOrder`. And finally we adjust the insets so that the
// tooltip is not out of bounds.
const bottomProposed = positioningUtils.bottomSpanRight({anchorRect, currentPopoverRect});
const bottomVerticalOutOfBounds =
Math.max(0, bottomProposed.top + currentPopoverRect.height - inspectorViewRect.bottom);
const topProposed = positioningUtils.topSpanRight({anchorRect, currentPopoverRect});
const topVerticalOutOfBounds = Math.max(0, inspectorViewRect.top - topProposed.top);
const prefersBottom = bottomVerticalOutOfBounds <= topVerticalOutOfBounds;
const fallbackOption = uniqueOrder.find(option => {
if (prefersBottom) {
return option === PositionOption.BOTTOM_SPAN_LEFT || option === PositionOption.BOTTOM_SPAN_RIGHT;
}
return option === PositionOption.TOP_SPAN_LEFT || option === PositionOption.TOP_SPAN_RIGHT;
}) ??
PositionOption.TOP_SPAN_RIGHT;
const fallbackRect = getProposedRectForPositionOption(fallbackOption);
return positioningUtils.insetAdjustedRect({currentPopoverRect, inspectorViewRect, proposedRect: fallbackRect});
};
export const proposedRectForSimpleTooltip =
({inspectorViewRect, anchorRect, currentPopoverRect}:
{inspectorViewRect: DOMRect, anchorRect: DOMRect, currentPopoverRect: DOMRect}): ProposedRect => {
// Default options are bottom centered & top centered.
let proposedRect = positioningUtils.bottomCentered({anchorRect, currentPopoverRect});
if (positioningUtils.isInBounds({inspectorViewRect, currentPopoverRect, proposedRect})) {
return proposedRect;
}
const bottomVerticalOutOfBoundsAmount =
Math.max(0, proposedRect.top + currentPopoverRect.height - inspectorViewRect.bottom);
proposedRect = positioningUtils.topCentered({anchorRect, currentPopoverRect});
if (positioningUtils.isInBounds({inspectorViewRect, currentPopoverRect, proposedRect})) {
return proposedRect;
}
const topVerticalOutOfBoundsAmount = Math.max(0, inspectorViewRect.top - proposedRect.top);
// The default options did not work out, so compare which option is fewer
// pixels out of the viewport vertically. Pick the better option and
// adjust the insets to make sure that the tooltip is not out of bounds.
if (bottomVerticalOutOfBoundsAmount <= topVerticalOutOfBoundsAmount) {
proposedRect = positioningUtils.bottomCentered({anchorRect, currentPopoverRect});
} else {
proposedRect = positioningUtils.topCentered({anchorRect, currentPopoverRect});
}
return positioningUtils.insetAdjustedRect({currentPopoverRect, inspectorViewRect, proposedRect});
};
export type TooltipVariant = 'simple'|'rich';
export type PaddingMode = 'small'|'large';
export type TooltipTrigger = 'hover'|'click'|'both';
export interface TooltipProperties {
id: string;
variant?: TooltipVariant;
padding?: PaddingMode;
anchor?: HTMLElement;
jslogContext?: string;
trigger?: TooltipTrigger;
}
/**
* @property useHotkey - reflects the `"use-hotkey"` attribute.
* @property id - reflects the `"id"` attribute.
* @property hoverDelay - reflects the `"hover-delay"` attribute.
* @property variant - reflects the `"variant"` attribute.
* @property padding - reflects the `"padding"` attribute.
* @property trigger - reflects the `"trigger"` attribute.
* @property verticalDistanceIncrease - reflects the `"vertical-distance-increase"` attribute.
* @property preferSpanLeft - reflects the `"prefer-span-left"` attribute.
* @attribute id - Id of the tooltip. Used for searching an anchor element with aria-describedby.
* @attribute hover-delay - Hover length in ms before the tooltip is shown and hidden.
* @attribute variant - Variant of the tooltip, `"simple"` for strings only, inverted background,
* `"rich"` for interactive content, background according to theme's surface.
* @attribute padding - Which padding to use, defaults to `"small"`. Use `"large"` for richer content.
* @attribute trigger - Specifies which action triggers the tooltip. `"hover"` is the default. `"click"` means the
* tooltip will be shown on click instead of hover. `"both"` means both hover and click trigger the
* tooltip.
* @attribute vertical-distance-increase - The tooltip is moved vertically this many pixels further away from its anchor.
* @attribute prefer-span-left - If present, the tooltip's preferred position is `"span-left"` (The right
* side of the tooltip and its anchor are aligned. The tooltip expands to the left from
* there.). Applies to rich tooltips only.
* @attribute use-hotkey - If present, the tooltip will be shown on hover but not when receiving focus.
* Requires a hotkey to open when fosed (Alt-down). When `"trigger"` is present
* as well, `"trigger"` takes precedence.
*/
export class Tooltip extends HTMLElement {
static readonly observedAttributes = ['id', 'variant', 'jslogcontext', 'trigger'];
static lastOpenedTooltipId: string|null = null;
readonly #shadow = this.attachShadow({mode: 'open'});
#anchor: HTMLElement|null = null;
#timeout: number|null = null;
#closing = false;
#anchorObserver: MutationObserver|null = null;
#openedViaHotkey = false;
#previousAnchorRect: DOMRect|null = null;
#previousPopoverRect: DOMRect|null = null;
get openedViaHotkey(): boolean {
return this.#openedViaHotkey;
}
get open(): boolean {
return this.matches(':popover-open');
}
get useHotkey(): boolean {
return this.hasAttribute('use-hotkey') ?? false;
}
set useHotkey(useHotkey: boolean) {
if (useHotkey) {
this.setAttribute('use-hotkey', '');
} else {
this.removeAttribute('use-hotkey');
}
}
get trigger(): TooltipTrigger {
switch (this.getAttribute('trigger')) {
case 'click':
return 'click';
case 'both':
return 'both';
case 'hover':
default:
return 'hover';
}
}
set trigger(trigger: TooltipTrigger) {
this.setAttribute('trigger', trigger);
}
get hoverDelay(): number {
return this.hasAttribute('hover-delay') ? Number(this.getAttribute('hover-delay')) : 300;
}
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);
}
get padding(): PaddingMode {
return this.getAttribute('padding') === 'large' ? 'large' : 'small';
}
set padding(padding: PaddingMode) {
this.setAttribute('padding', padding);
}
get jslogContext(): string|null {
return this.getAttribute('jslogcontext');
}
set jslogContext(jslogContext: string) {
this.setAttribute('jslogcontext', jslogContext);
this.#updateJslog();
}
get verticalDistanceIncrease(): number {
return this.hasAttribute('vertical-distance-increase') ? Number(this.getAttribute('vertical-distance-increase')) :
0;
}
set verticalDistanceIncrease(increase: number) {
this.setAttribute('vertical-distance-increase', increase.toString());
}
get preferSpanLeft(): boolean {
return this.hasAttribute('prefer-span-left');
}
set preferSpanLeft(value: boolean) {
if (value) {
this.setAttribute('prefer-span-left', '');
} else {
this.removeAttribute('prefer-span-left');
}
}
get anchor(): HTMLElement|null {
return this.#anchor;
}
constructor(properties?: TooltipProperties) {
super();
const {id, variant, padding, jslogContext, anchor, trigger} = properties ?? {};
if (id) {
this.id = id;
}
if (variant) {
this.variant = variant;
}
if (padding) {
this.padding = padding;
}
if (jslogContext) {
this.jslogContext = jslogContext;
}
if (anchor) {
const ref = anchor.getAttribute('aria-details') ?? anchor.getAttribute('aria-describedby');
if (ref !== id) {
throw new Error('aria-details or aria-describedby must be set on the anchor');
}
this.#anchor = anchor;
}
if (trigger) {
this.trigger = trigger;
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: 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();
if (Tooltip.lastOpenedTooltipId === oldValue) {
Tooltip.lastOpenedTooltipId = newValue;
}
} else if (name === 'jslogcontext') {
this.#updateJslog();
}
}
connectedCallback(): void {
this.#attachToAnchor();
this.#registerEventListeners();
this.#setAttributes();
// clang-format off
Lit.render(html`
<style>${tooltipStyles}</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 ${this.padding === 'large' ? 'large-padding' : ''}">
<slot></slot>
</div>
`, this.#shadow, {host: this});
// clang-format on
if (Tooltip.lastOpenedTooltipId === this.id) {
this.showPopover();
}
}
disconnectedCallback(): void {
this.#removeEventListeners();
this.#anchorObserver?.disconnect();
}
showTooltip = (event?: MouseEvent|FocusEvent): void => {
// Don't show the tooltip if the mouse is down.
if (event && 'buttons' in event && event.buttons) {
return;
}
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
this.#timeout = window.setTimeout(() => {
this.showPopover();
Tooltip.lastOpenedTooltipId = this.id;
}, this.hoverDelay);
};
#containsNode(target: EventTarget|null): boolean {
return target instanceof Node && this.contains(target);
}
hideTooltip = (event?: MouseEvent|FocusEvent): void => {
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
// If the event is a blur event, then:
// 1. event.currentTarget = the element that got blurred
// 2. event.relatedTarget = the element that gained focus
// https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent/relatedTarget
// If the blurred element (1) was our anchor or within the tooltip,
// and the newly focused element (2) is within the tooltip,
// we do not want to hide the tooltip.
if (event && this.variant === 'rich' && (event.target === this.#anchor || this.#containsNode(event.target)) &&
this.#containsNode(event.relatedTarget)) {
return;
}
// Don't hide a rich tooltip when hovering over the tooltip itself.
if (event && this.variant === 'rich' &&
(event.relatedTarget === this || (event.relatedTarget as Element)?.parentElement === this)) {
return;
}
if (this.open && Tooltip.lastOpenedTooltipId === this.id) {
Tooltip.lastOpenedTooltipId = null;
}
this.hidePopover();
};
toggle = (): void => {
// We need this check because clicking on the anchor while the tooltip is open will trigger both
// the click event on the anchor and the toggle event from the backdrop of the tooltip.
if (!this.#closing) {
this.togglePopover();
}
};
#positionPopover = (): void => {
if (!this.#anchor || !this.open) {
this.#previousAnchorRect = null;
this.#previousPopoverRect = null;
this.style.visibility = 'hidden';
return;
}
// If there is no change from the previous anchor rect, we don't need to recompute the position.
const anchorRect = this.#anchor.getBoundingClientRect();
const currentPopoverRect = this.getBoundingClientRect();
if (positioningUtils.isSameRect(this.#previousAnchorRect, anchorRect) &&
positioningUtils.isSameRect(this.#previousPopoverRect, currentPopoverRect)) {
requestAnimationFrame(this.#positionPopover);
return;
}
this.#previousAnchorRect = anchorRect;
this.#previousPopoverRect = currentPopoverRect;
const inspectorViewRect = UI.UIUtils.getDevToolsBoundingElement().getBoundingClientRect();
const preferredPositions =
this.preferSpanLeft ? [PositionOption.BOTTOM_SPAN_LEFT, PositionOption.TOP_SPAN_LEFT] : [];
const proposedPopoverRect = this.variant === 'rich' ?
proposedRectForRichTooltip({inspectorViewRect, anchorRect, currentPopoverRect, preferredPositions}) :
proposedRectForSimpleTooltip({inspectorViewRect, anchorRect, currentPopoverRect});
this.style.left = `${proposedPopoverRect.left}px`;
// If the tooltip is above its anchor, we need to decrease the tooltip's
// y-coordinate to increase the distance between tooltip and anchor.
// If the tooltip is below its anchor, we add to the tooltip's y-coord.
const actualVerticalOffset =
anchorRect.top < proposedPopoverRect.top ? this.verticalDistanceIncrease : -this.verticalDistanceIncrease;
this.style.top = `${proposedPopoverRect.top + actualVerticalOffset}px`;
this.style.visibility = 'visible';
requestAnimationFrame(this.#positionPopover);
};
#updateJslog(): void {
if (this.jslogContext && this.#anchor) {
VisualLogging.setMappedParent(this, this.#anchor);
this.setAttribute('jslog', VisualLogging.popover(this.jslogContext).parent('mapped').toString());
} else {
this.removeAttribute('jslog');
}
}
#setAttributes(): void {
if (!this.hasAttribute('role')) {
this.setAttribute('role', 'tooltip');
}
this.setAttribute('popover', this.trigger === 'hover' ? 'manual' : 'auto');
this.#updateJslog();
}
#stopPropagation(event: Event): void {
event.stopPropagation();
}
#setClosing = (event: Event): void => {
if ((event as ToggleEvent).newState === 'closed') {
this.#closing = true;
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
}
};
#resetClosing = (event: Event): void => {
if ((event as ToggleEvent).newState === 'closed') {
this.#closing = false;
this.#openedViaHotkey = false;
}
};
#globalKeyDown = (event: KeyboardEvent): void => {
if (!this.open || event.key !== 'Escape') {
return;
}
const childTooltip = this.querySelector('devtools-tooltip') as Tooltip | null;
if (childTooltip?.open) {
return;
}
this.#openedViaHotkey = false;
this.toggle();
event.consume(true);
};
#keyDown = (event: KeyboardEvent): void => {
// This supports the scenario where the user uses Alt+ArrowDown in hotkey
// mode to toggle the visibility.
// Note that the "Escape to close" scenario is handled in the global
// keydown function so we capture Escape presses even if the tooltip does
// not have focus.
const shouldToggleVisibility = (this.useHotkey && event.altKey && event.key === 'ArrowDown');
if (shouldToggleVisibility) {
this.#openedViaHotkey = !this.open;
this.toggle();
event.consume(true);
}
};
#registerEventListeners(): void {
document.body.addEventListener('keydown', this.#globalKeyDown);
if (this.#anchor) {
// We bind the keydown listener regardless of if use-hotkey is enabled
// as we always want to support ESC to close.
this.#anchor.addEventListener('keydown', this.#keyDown);
if (this.trigger === 'click' || this.trigger === 'both') {
this.#anchor.addEventListener('click', this.toggle);
}
if (this.trigger === 'hover' || this.trigger === 'both') {
this.#anchor.addEventListener('mouseenter', this.showTooltip);
if (!this.useHotkey) {
this.#anchor.addEventListener('focus', this.showTooltip);
}
this.#anchor.addEventListener('blur', this.hideTooltip);
this.#anchor.addEventListener('mouseleave', this.hideTooltip);
this.addEventListener('mouseleave', this.hideTooltip);
this.addEventListener('focusout', this.hideTooltip);
}
}
// Prevent interaction with the parent element.
this.addEventListener('click', this.#stopPropagation);
this.addEventListener('mouseup', this.#stopPropagation);
this.addEventListener('beforetoggle', this.#setClosing);
this.addEventListener('toggle', this.#resetClosing);
this.addEventListener('toggle', this.#positionPopover);
}
#removeEventListeners(): void {
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
// Should always exist when this component is used, but in test
// environments on Chromium this isn't always the case, hence the body? check.
document.body?.removeEventListener('keydown', this.#globalKeyDown);
if (this.#anchor) {
this.#anchor.removeEventListener('click', this.toggle);
this.#anchor.removeEventListener('mouseenter', this.showTooltip);
this.#anchor.removeEventListener('focus', this.showTooltip);
this.#anchor.removeEventListener('blur', this.hideTooltip);
this.#anchor.removeEventListener('keydown', this.#keyDown);
this.#anchor.removeEventListener('mouseleave', this.hideTooltip);
}
this.removeEventListener('mouseleave', this.hideTooltip);
this.removeEventListener('click', this.#stopPropagation);
this.removeEventListener('mouseup', this.#stopPropagation);
this.removeEventListener('beforetoggle', this.#setClosing);
this.removeEventListener('toggle', this.#resetClosing);
this.removeEventListener('toggle', this.#positionPopover);
}
#attachToAnchor(): void {
if (!this.#anchor) {
const id = this.getAttribute('id');
if (!id) {
throw new Error('<devtools-tooltip> must have an id.');
}
const root = this.getRootNode() as Document | ShadowRoot;
if (root.querySelectorAll(`#${id}`)?.length > 1) {
throw new Error('Duplicate <devtools-tooltip> ids found.');
}
const describedbyAnchor = root.querySelector(`[aria-describedby="${id}"]`);
const detailsAnchor = root.querySelector(`[aria-details="${id}"]`);
const 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.');
}
this.#anchor = anchor;
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.`);
}
}
this.#observeAnchorRemoval(this.#anchor);
this.#updateJslog();
}
#observeAnchorRemoval(anchor: Element): void {
if (anchor.parentElement === null) {
return;
}
if (this.#anchorObserver) {
this.#anchorObserver.disconnect();
}
this.#anchorObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && [...mutation.removedNodes].includes(anchor)) {
if (this.#timeout) {
window.clearTimeout(this.#timeout);
}
this.hidePopover();
}
}
});
this.#anchorObserver.observe(anchor.parentElement, {childList: true});
}
}
customElements.define('devtools-tooltip', Tooltip);
declare global {
interface HTMLElementTagNameMap {
'devtools-tooltip': Tooltip;
}
}