go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/tooltip/tooltip.ts (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { MobxLitElement } from '@adobe/lit-mobx';
    16  import { css, html } from 'lit';
    17  import { customElement } from 'lit/decorators.js';
    18  import { action, makeObservable, observable } from 'mobx';
    19  
    20  export interface ShowTooltipEventDetail {
    21    tooltip: HTMLElement;
    22    // The location around which the tooltip should be displayed.
    23    targetRect: DOMRectReadOnly;
    24    // The gap between the tooltip and the targetRect.
    25    gapSize: number;
    26  }
    27  
    28  export type ShowTooltipEvent = CustomEvent<ShowTooltipEventDetail>;
    29  
    30  export interface HideTooltipEventDetail {
    31    // Hide the tooltip after `delay` ms. Default value is 0.
    32    // When the tooltip is lingering, you can hover over it to stop it from
    33    // disappearing.
    34    delay?: number;
    35  }
    36  
    37  export type HideTooltipEvent = CustomEvent<HideTooltipEventDetail>;
    38  
    39  /**
    40   * A global listener for displaying instant tooltip. It should be added to
    41   * somewhere close to the root of the DOM tree.
    42   *
    43   * After mounting this to DOM, you can
    44   * 1. show a tooltip via 'show-tooltip' event.
    45   * 2. hide the tooltip via 'hide-tooltip' event.
    46   */
    47  // Comparing to a sub-component, a global tooltip implementation
    48  // 1. makes it easier to ensure there's at most one active tooltip.
    49  // 2. is not constrained by ancestors' overflow setting.
    50  @customElement('milo-tooltip')
    51  export class TooltipElement extends MobxLitElement {
    52    @observable.ref private tooltip?: HTMLElement;
    53    @observable.ref private targetRect?: DOMRectReadOnly;
    54    @observable.ref private gapSize?: number;
    55  
    56    private hideTooltipTimeout = 0;
    57  
    58    private onShowTooltip = action((event: Event) => {
    59      window.clearTimeout(this.hideTooltipTimeout);
    60  
    61      const e = event as CustomEvent<ShowTooltipEventDetail>;
    62      this.tooltip = e.detail.tooltip;
    63      this.targetRect = e.detail.targetRect;
    64      this.gapSize = e.detail.gapSize;
    65  
    66      this.style.display = 'block';
    67  
    68      // Hide the element until we decided where to render it.
    69      this.style.visibility = 'hidden';
    70  
    71      // Reset the position so it's easier to calculate the new position.
    72      this.style.left = '0';
    73      this.style.top = '0';
    74    });
    75  
    76    private onHideTooltip = (event: Event) => {
    77      window.clearTimeout(this.hideTooltipTimeout);
    78  
    79      const e = event as CustomEvent<HideTooltipEventDetail>;
    80      this.hideTooltipTimeout = window.setTimeout(
    81        this.hideTooltip,
    82        e.detail.delay || 0,
    83      );
    84    };
    85  
    86    private hideTooltip = action(() => {
    87      this.style.display = 'none';
    88      this.tooltip = undefined;
    89      this.targetRect = undefined;
    90      this.gapSize = undefined;
    91    });
    92  
    93    constructor() {
    94      super();
    95      makeObservable(this);
    96      this.addEventListener('mouseover', () =>
    97        window.clearTimeout(this.hideTooltipTimeout),
    98      );
    99      this.addEventListener('mouseout', this.hideTooltip);
   100    }
   101  
   102    connectedCallback() {
   103      super.connectedCallback();
   104      window.addEventListener('show-tooltip', this.onShowTooltip);
   105      window.addEventListener('hide-tooltip', this.onHideTooltip);
   106    }
   107  
   108    disconnectedCallback() {
   109      super.disconnectedCallback();
   110      window.removeEventListener('show-tooltip', this.onShowTooltip);
   111      window.removeEventListener('hide-tooltip', this.onHideTooltip);
   112    }
   113  
   114    protected render() {
   115      return html`${this.tooltip}`;
   116    }
   117  
   118    protected updated() {
   119      if (!this.tooltip || !this.targetRect || this.gapSize === undefined) {
   120        return;
   121      }
   122  
   123      const selfRect = this.getBoundingClientRect();
   124  
   125      const offsets = [
   126        // Bottom (left-aligned).
   127        [
   128          this.targetRect.left + window.scrollX,
   129          this.targetRect.bottom + this.gapSize + window.scrollY,
   130        ],
   131        // Bottom (right-aligned).
   132        [
   133          this.targetRect.right - selfRect.width + window.scrollX,
   134          this.targetRect.bottom + this.gapSize + window.scrollY,
   135        ],
   136        // Top (left-aligned).
   137        [
   138          this.targetRect.left + window.scrollX,
   139          this.targetRect.top - selfRect.height - this.gapSize + window.scrollY,
   140        ],
   141        // Top (right-aligned).
   142        [
   143          this.targetRect.right - selfRect.width + window.scrollX,
   144          this.targetRect.top - selfRect.height - this.gapSize + window.scrollY,
   145        ],
   146        // Right (top-aligned).
   147        [
   148          this.targetRect.right + this.gapSize + window.scrollX,
   149          this.targetRect.top + window.scrollY,
   150        ],
   151        // Right (bottom-aligned).
   152        [
   153          this.targetRect.right + this.gapSize + window.scrollX,
   154          this.targetRect.bottom - selfRect.height + window.scrollY,
   155        ],
   156        // Left (top-aligned).
   157        [
   158          this.targetRect.left - selfRect.width - this.gapSize + window.scrollX,
   159          this.targetRect.top + window.scrollY,
   160        ],
   161        // Left (bottom-aligned).
   162        [
   163          this.targetRect.left - selfRect.width - this.gapSize + window.scrollX,
   164          this.targetRect.bottom - selfRect.height + window.scrollY,
   165        ],
   166        // Bottom-right.
   167        [
   168          this.targetRect.right + this.gapSize + window.scrollX,
   169          this.targetRect.bottom + this.gapSize + window.scrollY,
   170        ],
   171        // Bottom-left.
   172        [
   173          this.targetRect.left - selfRect.width - this.gapSize + window.scrollX,
   174          this.targetRect.bottom + this.gapSize + window.scrollY,
   175        ],
   176        // Top-right.
   177        [
   178          this.targetRect.right + this.gapSize + window.scrollX,
   179          this.targetRect.top - selfRect.height - this.gapSize + window.scrollY,
   180        ],
   181        // Top-left.
   182        [
   183          this.targetRect.left - selfRect.width - this.gapSize + window.scrollX,
   184          this.targetRect.top - selfRect.height - this.gapSize + window.scrollY,
   185        ],
   186      ];
   187  
   188      // Show the tooltip at the bottom by default.
   189      let selectedOffset = offsets[0];
   190  
   191      // Find a place that can render the tooltip without overflowing the browser
   192      // window.
   193      for (const [dx, dy] of offsets) {
   194        if (dx + selfRect.left < 0) {
   195          continue;
   196        }
   197        if (dx + selfRect.right > window.innerWidth) {
   198          continue;
   199        }
   200        if (dy + selfRect.top < 0) {
   201          continue;
   202        }
   203        if (dy + selfRect.bottom > window.innerHeight) {
   204          continue;
   205        }
   206        selectedOffset = [dx, dy];
   207        break;
   208      }
   209  
   210      this.style.left = selectedOffset[0] + 'px';
   211      this.style.top = selectedOffset[1] + 'px';
   212      this.style.visibility = 'visible';
   213    }
   214  
   215    static styles = css`
   216      :host {
   217        display: none;
   218        position: absolute;
   219        background: white;
   220        border-radius: 4px;
   221        padding: 5px;
   222        box-shadow:
   223          rgb(0 0 0 / 20%) 0px 5px 5px -3px,
   224          rgb(0 0 0 / 14%) 0px 8px 10px 1px,
   225          rgb(0 0 0 / 12%) 0px 3px 14px 2px;
   226        z-index: 999;
   227      }
   228    `;
   229  }
   230  
   231  declare global {
   232    // eslint-disable-next-line @typescript-eslint/no-namespace
   233    namespace JSX {
   234      interface IntrinsicElements {
   235        'milo-tooltip': Record<string, never>;
   236      }
   237    }
   238  }