chromium/chrome/test/data/webui/cr_elements/cr_tooltip_test.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// clang-format off
import {html, CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';

import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';

import {TooltipPosition} from 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import type {CrTooltipElement} from 'chrome://resources/cr_elements/cr_tooltip/cr_tooltip.js';
import {assertEquals, assertFalse, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {eventToPromise, microtasksFinished} from 'chrome://webui-test/test_util.js';

// clang-format on

suite('cr-tooltip', function() {
  let tooltip: CrTooltipElement;
  let parent: CrLitElement;

  // Test parent element.
  class TestElement extends CrLitElement {
    static get is() {
      return 'test-element';
    }

    override render() {
      return html`
        <div id="test-for"></div>
        <div id="test-manual"></div>
        <cr-tooltip animation-delay="0">
          <span id="tooltip-text"></span>
        </cr-tooltip>`;
    }
  }

  customElements.define(TestElement.is, TestElement);

  setup(async () => {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    parent = document.createElement('test-element') as TestElement;
    document.body.appendChild(parent);
    tooltip = parent.shadowRoot!.querySelector('cr-tooltip')!;
  });

  test('sets correct target', async() => {
    // The default target is the parent.
    assertEquals(parent, tooltip.target);
    tooltip.for = 'test-for';
    await microtasksFinished();
    assertEquals(parent.shadowRoot!.querySelector('#test-for'), tooltip.target);
    const manualDiv = parent.shadowRoot!.querySelector('#test-manual');
    assertTrue(!!manualDiv);
    tooltip.target = manualDiv;
    await microtasksFinished();
    assertEquals(manualDiv, tooltip.target);
  });

  test('show/hide', async() => {
    // Tooltip should not show since there is no text content.
    tooltip.show();
    await microtasksFinished();
    assertTrue(tooltip.$.tooltip.hidden);

    // Tooltip shows when show() is called and plays fade in animation.
    const text = parent.shadowRoot!.querySelector<HTMLElement>('#tooltip-text');
    assertTrue(!!text);
    text.textContent = 'test';
    tooltip.show();
    assertFalse(tooltip.$.tooltip.hidden);
    assertTrue(tooltip.$.tooltip.classList.contains('fade-in-animation'));
    assertFalse(tooltip.$.tooltip.classList.contains('fade-out-animation'));

    await eventToPromise('animationend', tooltip.$.tooltip);
    assertFalse(tooltip.$.tooltip.hidden);

    // Tooltip hides when hide() is called and plays fade out animation.
    tooltip.hide();
    assertTrue(tooltip.$.tooltip.classList.contains('fade-out-animation'));
    assertFalse(tooltip.$.tooltip.classList.contains('fade-in-animation'));
    assertFalse(tooltip.$.tooltip.hidden);
    await eventToPromise('animationend', tooltip.$.tooltip);
    assertTrue(tooltip.$.tooltip.hidden);

    // Tooltip shows when pointer enters the target.
    parent.dispatchEvent(
        new CustomEvent('pointerenter', {bubbles: true, composed: true}));
    await eventToPromise('animationend', tooltip.$.tooltip);
    assertFalse(tooltip.$.tooltip.hidden);

    // Tooltip hides when pointer leaves the target.
    parent.dispatchEvent(
        new CustomEvent('pointerleave', {bubbles: true, composed: true}));
    await eventToPromise('animationend', tooltip.$.tooltip);
    assertTrue(tooltip.$.tooltip.hidden);

    // If manual mode is enabled, does not respond to pointer events.
    tooltip.manualMode = true;
    await microtasksFinished();
    parent.dispatchEvent(
        new CustomEvent('pointerenter', {bubbles: true, composed: true}));
    await new Promise(resolve => setTimeout(resolve, 1));
    assertTrue(tooltip.$.tooltip.hidden);
  });

  test('positioning', async () => {
    const text = parent.shadowRoot!.querySelector<HTMLElement>('#tooltip-text');
    assertTrue(!!text);
    text.textContent = 'test';
    tooltip.for = 'test-for';
    await microtasksFinished();
    tooltip.show();

    const target = parent.shadowRoot!.querySelector('#test-for');
    assertTrue(!!target);
    const parentRect = parent.getBoundingClientRect();
    const targetRect = target.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();
    const horizontalCenterOffset = (targetRect.width - tooltipRect.width) / 2;
    const verticalCenterOffset = (targetRect.height - tooltipRect.height) / 2;
    const targetLeft = targetRect.left - parentRect.left;
    const targetTop = targetRect.top - parentRect.top;

    let expectedLeft = targetLeft + horizontalCenterOffset;
    let expectedTop = targetTop + targetRect.height + 14;  // default offset 14

    assertEquals(
        expectedLeft,
        (tooltip.computedStyleMap().get('left') as CSSUnitValue).value);
    assertEquals(
        expectedTop,
        (tooltip.computedStyleMap().get('top') as CSSUnitValue).value);

    // Check that setting a new offset and updating position works as expected.
    tooltip.offset = 6;
    tooltip.updatePosition();
    await microtasksFinished();
    expectedTop = expectedTop - 8;  // 14 - 6
    assertEquals(
        expectedLeft,
        (tooltip.computedStyleMap().get('left') as CSSUnitValue).value);
    assertEquals(
        expectedTop,
        (tooltip.computedStyleMap().get('top') as CSSUnitValue).value);

    // Check that a different tooltip position works as expected.
    tooltip.position = TooltipPosition.LEFT;
    tooltip.updatePosition();
    await microtasksFinished();
    expectedLeft = targetLeft - tooltipRect.width - 6;  // Offset 6
    expectedTop = targetTop + verticalCenterOffset;
    assertEquals(
        expectedLeft,
        (tooltip.computedStyleMap().get('left') as CSSUnitValue).value);
    assertEquals(
        expectedTop,
        (tooltip.computedStyleMap().get('top') as CSSUnitValue).value);
  });
});

suite('cr-tooltip in dialog', function() {
  let tooltip: CrTooltipElement;
  let parent: CrLitElement;

  // Test parent element.
  class TestDialogElement extends CrLitElement {
    static get is() {
      return 'test-dialog-element';
    }

    override render() {
      return html`
        <cr-dialog show-on-attach>
          <div slot="body">
            <div id="test-for">Hover for tooltip</div>
            <cr-tooltip animation-delay="0" for="test-for"
                fit-to-visible-bounds>
              <span id="tooltip-text">Hello from cr-dialog slot</span>
            </cr-tooltip>
          </div>
        </cr-dialog>`;
    }
  }

  customElements.define(TestDialogElement.is, TestDialogElement);

  setup(async () => {
    document.body.innerHTML = window.trustedTypes!.emptyHTML;
    parent = document.createElement('test-dialog-element') as TestDialogElement;
    document.body.appendChild(parent);
    tooltip = parent.shadowRoot!.querySelector('cr-tooltip')!;
    await microtasksFinished();
  });

  test('positioning', () => {
    tooltip.show();
    const dialog = parent.shadowRoot!.querySelector('cr-dialog');
    assertTrue(!!dialog);
    const parentRect = dialog.$.dialog.getBoundingClientRect();
    const target = parent.shadowRoot!.querySelector('#test-for');
    assertTrue(!!target);
    const targetRect = target.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();
    const horizontalCenterOffset = (targetRect.width - tooltipRect.width) / 2;
    const targetLeft = targetRect.left - parentRect.left;
    const targetTop =
        Math.max(targetRect.top - parentRect.top, -parentRect.top);
    const expectedLeft = targetLeft + horizontalCenterOffset;
    const expectedTop = targetTop + targetRect.height + 14;  // default offset
    assertEquals(
        expectedLeft,
        (tooltip.computedStyleMap().get('left') as CSSUnitValue).value);
    assertEquals(
        expectedTop,
        (tooltip.computedStyleMap().get('top') as CSSUnitValue).value);
  });
});