| // 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; |
| } |
| } |