// 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`