chromium/ui/webui/resources/cr_components/help_bubble/help_bubble_mixin_lit.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.

/**
 * @fileoverview Logic common to components that support a help bubble.
 *
 * A component implementing this mixin should call
 * registerHelpBubble() to associate specific element identifiers
 * referenced  in an IPH or Tutorials journey with the ids of the HTML elements
 * that journey cares about (typically, points for help bubbles to anchor to).
 *
 * Multiple components in the same WebUI may have this mixin. Each mixin will
 * receive ALL help bubble-related messages from its associated WebUIController
 * and determines if any given message is relevant. This is done by checking
 * against registered identifier.
 *
 * See README.md for more information.
 */

import {assert} from '//resources/js/assert.js';
import {EventTracker} from '//resources/js/event_tracker.js';
import type {CrLitElement} from '//resources/lit/v3_0/lit.rollup.js';
import type {InsetsF, RectF} from '//resources/mojo/ui/gfx/geometry/mojom/geometry.mojom-webui.js';

import type {HelpBubbleDismissedEvent, HelpBubbleElement} from './help_bubble.js';
import {debounceEnd, HELP_BUBBLE_DISMISSED_EVENT, HELP_BUBBLE_TIMED_OUT_EVENT} from './help_bubble.js';
import type {HelpBubbleClientCallbackRouter, HelpBubbleHandlerInterface, HelpBubbleParams} from './help_bubble.mojom-webui.js';
import {HelpBubbleClosedReason} from './help_bubble.mojom-webui.js';
import type {Trackable} from './help_bubble_controller.js';
import {HelpBubbleController} from './help_bubble_controller.js';
import {HelpBubbleProxyImpl} from './help_bubble_proxy.js';

type Constructor<T> = new (...args: any[]) => T;

export const HelpBubbleMixinLit = <T extends Constructor<CrLitElement>>(
    superClass: T): T&Constructor<HelpBubbleMixinLitInterface> => {
  class HelpBubbleMixinLit extends superClass implements
      HelpBubbleMixinLitInterface {
    private helpBubbleHandler_: HelpBubbleHandlerInterface;
    private helpBubbleCallbackRouter_: HelpBubbleClientCallbackRouter;
    /**
     * A map from the name of the native identifier used in the tutorial or
     * IPH definition to the target element's HTML ID.
     *
     * Example entry:
     *   "kHeightenSecuritySettingsElementId" => "toggleSecureMode"
     */
    private helpBubbleControllerById_: Map<string, HelpBubbleController> =
        new Map();
    private helpBubbleListenerIds_: number[] = [];
    private helpBubbleFixedAnchorObserver_: IntersectionObserver|null = null;
    private helpBubbleResizeObserver_: ResizeObserver|null = null;
    private helpBubbleDismissedEventTracker_: EventTracker = new EventTracker();
    private debouncedAnchorMayHaveChangedCallback_: (() => void)|null = null;

    constructor(...args: any[]) {
      super(...args);

      this.helpBubbleHandler_ = HelpBubbleProxyImpl.getInstance().getHandler();
      this.helpBubbleCallbackRouter_ =
          HelpBubbleProxyImpl.getInstance().getCallbackRouter();
    }

    override connectedCallback() {
      super.connectedCallback();

      const router = this.helpBubbleCallbackRouter_;
      this.helpBubbleListenerIds_.push(
          router.showHelpBubble.addListener(this.onShowHelpBubble_.bind(this)),
          router.toggleFocusForAccessibility.addListener(
              this.onToggleHelpBubbleFocusForAccessibility_.bind(this)),
          router.hideHelpBubble.addListener(this.onHideHelpBubble_.bind(this)),
          router.externalHelpBubbleUpdated.addListener(
              this.onExternalHelpBubbleUpdated_.bind(this)));

      const isVisible = (element: Element) => {
        const rect = element.getBoundingClientRect();
        return rect.height > 0 && rect.width > 0;
      };

      this.debouncedAnchorMayHaveChangedCallback_ =
          debounceEnd(this.onAnchorBoundsMayHaveChanged_.bind(this), 50);

      this.helpBubbleResizeObserver_ =
          new ResizeObserver(entries => entries.forEach(({target}) => {
            if (target === document.body) {
              if (this.debouncedAnchorMayHaveChangedCallback_) {
                this.debouncedAnchorMayHaveChangedCallback_();
              }
            } else {
              this.onAnchorVisibilityChanged_(
                  target as HTMLElement, isVisible(target));
            }
          }));
      this.helpBubbleFixedAnchorObserver_ = new IntersectionObserver(
          entries => entries.forEach(
              ({target, isIntersecting}) => this.onAnchorVisibilityChanged_(
                  target as HTMLElement, isIntersecting)),
          {root: null});

      document.addEventListener(
          'scroll', this.debouncedAnchorMayHaveChangedCallback_,
          {passive: true});
      this.helpBubbleResizeObserver_.observe(document.body);

      // When the component is connected, if the target elements were
      // already registered, they should be observed now. Any targets
      // registered from this point forward will observed on registration.
      this.controllers.forEach(ctrl => this.observeControllerAnchor_(ctrl));
    }

    private get controllers(): HelpBubbleController[] {
      return Array.from(this.helpBubbleControllerById_.values());
    }

    override disconnectedCallback() {
      super.disconnectedCallback();

      for (const listenerId of this.helpBubbleListenerIds_) {
        this.helpBubbleCallbackRouter_.removeListener(listenerId);
      }
      this.helpBubbleListenerIds_ = [];
      assert(this.helpBubbleResizeObserver_);
      this.helpBubbleResizeObserver_.disconnect();
      this.helpBubbleResizeObserver_ = null;
      assert(this.helpBubbleFixedAnchorObserver_);
      this.helpBubbleFixedAnchorObserver_.disconnect();
      this.helpBubbleFixedAnchorObserver_ = null;
      this.helpBubbleDismissedEventTracker_.removeAll();
      this.helpBubbleControllerById_.clear();
      if (this.debouncedAnchorMayHaveChangedCallback_) {
        document.removeEventListener(
            'scroll', this.debouncedAnchorMayHaveChangedCallback_);
        this.debouncedAnchorMayHaveChangedCallback_ = null;
      }
    }

    /**
     * Maps `nativeId`, which should be the name of a ui::ElementIdentifier
     * referenced by the WebUIController, with either:
     * - a selector
     * - an array of selectors (will traverse shadow DOM elements)
     * - an arbitrary HTMLElement
     *
     * The referenced element should have block display and non-zero size
     * when visible (inline elements may be supported in the future).
     *
     * Example:
     *   registerHelpBubble(
     *       'kMyComponentTitleLabelElementIdentifier',
     *       '#title');
     *
     * Example:
     *   registerHelpBubble(
     *       'kMyComponentTitleLabelElementIdentifier',
     *       ['#child-component', '#child-component-button']);
     *
     * Example:
     *   registerHelpBubble(
     *       'kMyComponentTitleLabelElementIdentifier',
     *       this.$.list.childNodes[0]);
     *
     * See README.md for full instructions.
     *
     * This method can be called multiple times to re-register the
     * nativeId to a new element/selector. If the help bubble is already
     * showing, the registration will fail and return null. If successful,
     * this method returns the new controller.
     *
     * Optionally, an options object may be supplied to change the
     * default behavior of the help bubble.
     *
     * - Fixed positioning detection:
     *  e.g. `{fixed: true}`
     *  By default, this mixin detects anchor elements when
     *  rendered within the document. This breaks with
     *  fix-positioned elements since they are not in the regular
     *  flow of the document but they are always visible. Passing
     *  {"fixed": true} will detect the anchor element when it is
     *  visible.
     *
     * - Add padding around anchor element:
     *  e.g. `{anchorPaddingTop: 5}`
     *  To add to the default margin around the anchor element in all
     *  4 directions, e.g. {"anchorPaddingTop": 5} adds 5 pixels to
     *  the margin at the top off the anchor element. The margin is
     *  used when calculating how far the help bubble should be spaced
     *  from the anchor element. Larger values equate to a larger visual
     *  gap. These values must be positive integers in the range [0, 20].
     *  This option should be used sparingly where the help bubble would
     *  otherwise conceal important UI.
     */
    registerHelpBubble(
        nativeId: string, trackable: Trackable,
        options: Options = {}): HelpBubbleController|null {
      if (this.helpBubbleControllerById_.has(nativeId)) {
        const ctrl = this.helpBubbleControllerById_.get(nativeId);
        if (ctrl && ctrl.isBubbleShowing()) {
          return null;
        }
        this.unregisterHelpBubble(nativeId);
      }
      const controller = new HelpBubbleController(nativeId, this.shadowRoot!);
      controller.track(trackable, parseOptions(options));
      this.helpBubbleControllerById_.set(nativeId, controller);
      // This can be called before or after `connectedCallback()`, so if the
      // component isn't connected and the observer set up yet, delay
      // observation until it is.
      if (this.helpBubbleResizeObserver_) {
        this.observeControllerAnchor_(controller);
      }
      return controller;
    }

    /**
     * Unregisters a help bubble nativeId.
     *
     * This method will remove listeners, hide the help bubble if
     * showing, and forget the nativeId.
     */
    unregisterHelpBubble(nativeId: string): void {
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      if (ctrl && ctrl.hasAnchor()) {
        this.onAnchorVisibilityChanged_(ctrl.getAnchor()!, false);
        this.unobserveControllerAnchor_(ctrl);
      }
      this.helpBubbleControllerById_.delete(nativeId);
    }

    private observeControllerAnchor_(controller: HelpBubbleController) {
      const anchor = controller.getAnchor();
      assert(anchor, 'Help bubble does not have anchor');
      if (controller.isAnchorFixed()) {
        assert(this.helpBubbleFixedAnchorObserver_);
        this.helpBubbleFixedAnchorObserver_.observe(anchor);
      } else {
        assert(this.helpBubbleResizeObserver_);
        this.helpBubbleResizeObserver_.observe(anchor);
      }
    }

    private unobserveControllerAnchor_(controller: HelpBubbleController) {
      const anchor = controller.getAnchor();
      assert(anchor, 'Help bubble does not have anchor');
      if (controller.isAnchorFixed()) {
        assert(this.helpBubbleFixedAnchorObserver_);
        this.helpBubbleFixedAnchorObserver_.unobserve(anchor);
      } else {
        assert(this.helpBubbleResizeObserver_);
        this.helpBubbleResizeObserver_.unobserve(anchor);
      }
    }

    /**
     * Returns whether any help bubble is currently showing in this
     * component.
     */
    isHelpBubbleShowing(): boolean {
      return this.controllers.some(ctrl => ctrl.isBubbleShowing());
    }

    /**
     * Returns whether any help bubble is currently showing on a tag
     * with this id.
     */
    isHelpBubbleShowingForTesting(id: string): boolean {
      const ctrls =
          this.controllers.filter(this.filterMatchingIdForTesting_(id));
      return !!ctrls[0];
    }

    /**
     * Returns the help bubble currently showing on a tag with this
     * id.
     */
    getHelpBubbleForTesting(id: string): HelpBubbleElement|null {
      const ctrls =
          this.controllers.filter(this.filterMatchingIdForTesting_(id));
      return ctrls[0] ? ctrls[0].getBubble() : null;
    }

    private filterMatchingIdForTesting_(anchorId: string):
        (ctrl: HelpBubbleController) => boolean {
      return ctrl => ctrl.isBubbleShowing() && ctrl.getAnchor() !== null &&
          ctrl.getAnchor()!.id === anchorId;
    }

    /**
     * Testing method to validate that anchors will be properly
     * located at runtime
     *
     * Call this method in your browser_tests after your help
     * bubbles have been registered. Results are sorted to be
     * deterministic.
     */
    getSortedAnchorStatusesForTesting(): Array<[string, boolean]> {
      return this.controllers
          .sort((a, b) => a.getNativeId().localeCompare(b.getNativeId()))
          .map(ctrl => ([ctrl.getNativeId(), ctrl.hasAnchor()]));
    }

    /**
     * Returns whether a help bubble can be shown
     * This requires:
     * - the mixin is tracking this controller
     * - the controller is in a state to be shown, e.g.
     *   `.canShowBubble()`
     * - no other showing bubbles are anchored to the same element
     */
    canShowHelpBubble(controller: HelpBubbleController): boolean {
      if (!this.helpBubbleControllerById_.has(controller.getNativeId())) {
        return false;
      }
      if (!controller.canShowBubble()) {
        return false;
      }
      const anchor = controller.getAnchor();
      // Make sure no other help bubble is showing for this anchor.
      const anchorIsUsed = this.controllers.some(
          otherCtrl =>
              otherCtrl.isBubbleShowing() && otherCtrl.getAnchor() === anchor);
      return !anchorIsUsed;
    }

    /**
     * Displays a help bubble with `params` anchored to the HTML element
     * with id `anchorId`. Note that `params.nativeIdentifier` is ignored by
     * this method, since the anchor is already specified.
     */
    showHelpBubble(controller: HelpBubbleController, params: HelpBubbleParams):
        void {
      assert(this.canShowHelpBubble(controller), 'Can\'t show help bubble');
      const bubble = controller.createBubble(params);

      this.helpBubbleDismissedEventTracker_.add(
          bubble, HELP_BUBBLE_DISMISSED_EVENT,
          this.onHelpBubbleDismissed_.bind(this));
      this.helpBubbleDismissedEventTracker_.add(
          bubble, HELP_BUBBLE_TIMED_OUT_EVENT,
          this.onHelpBubbleTimedOut_.bind(this));

      controller.show();
    }

    /**
     * Hides a help bubble anchored to element with id `anchorId` if there
     * is one. Returns true if a bubble was hidden.
     */
    hideHelpBubble(nativeId: string): boolean {
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      if (!ctrl || !ctrl.hasBubble()) {
        // `!ctrl` means this identifier is not handled by this mixin
        return false;
      }

      this.helpBubbleDismissedEventTracker_.remove(
          ctrl.getBubble()!, HELP_BUBBLE_DISMISSED_EVENT);
      this.helpBubbleDismissedEventTracker_.remove(
          ctrl.getBubble()!, HELP_BUBBLE_TIMED_OUT_EVENT);

      ctrl.hide();
      return true;
    }

    /**
     * Sends an "activated" event to the ElementTracker system for the
     * element with id `anchorId`, which must have been registered as a help
     * bubble anchor. This event will be processed in the browser and may
     * e.g. cause a Tutorial or interactive test to advance to the next
     * step.
     *
     * TODO(crbug.com/40243127): Figure out how to automatically send the
     * activated event when an anchor element is clicked.
     */
    notifyHelpBubbleAnchorActivated(nativeId: string): boolean {
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      if (!ctrl || !ctrl.isBubbleShowing()) {
        return false;
      }
      this.helpBubbleHandler_.helpBubbleAnchorActivated(nativeId);
      return true;
    }

    /**
     * Sends a custom event to the ElementTracker system for the element
     * with id `anchorId`, which must have been registered as a help bubble
     * anchor. This event will be processed in the browser and may e.g.
     * cause a Tutorial or interactive test to advance to the next step.
     *
     * The `customEvent` string should correspond to the name of a
     * ui::CustomElementEventType declared in the browser code.
     */
    notifyHelpBubbleAnchorCustomEvent(nativeId: string, customEvent: string):
        boolean {
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      if (!ctrl || !ctrl.isBubbleShowing()) {
        return false;
      }
      this.helpBubbleHandler_.helpBubbleAnchorCustomEvent(
          nativeId, customEvent);
      return true;
    }

    /**
     * This event is emitted by the mojo router
     */
    private onAnchorVisibilityChanged_(
        target: HTMLElement, isVisible: boolean) {
      const nativeId = target.dataset['nativeId'];
      assert(nativeId);
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      const hidden = this.hideHelpBubble(nativeId);
      if (hidden) {
        this.helpBubbleHandler_.helpBubbleClosed(
            nativeId, HelpBubbleClosedReason.kPageChanged);
      }
      const bounds: RectF = isVisible ? this.getElementBounds_(target) :
                                        {x: 0, y: 0, width: 0, height: 0};
      if (!ctrl || ctrl.updateAnchorVisibility(isVisible, bounds)) {
        this.helpBubbleHandler_.helpBubbleAnchorVisibilityChanged(
            nativeId, isVisible, bounds);
      }
    }

    /**
     * When the document scrolls or resizes, we need to update cached
     * positions of bubble anchors.
     */
    private onAnchorBoundsMayHaveChanged_() {
      for (const ctrl of this.controllers) {
        if (ctrl.hasAnchor() && ctrl.getAnchorVisibility()) {
          const bounds = this.getElementBounds_(ctrl.getAnchor()!);
          if (ctrl.updateAnchorVisibility(true, bounds)) {
            this.helpBubbleHandler_.helpBubbleAnchorVisibilityChanged(
                ctrl.getNativeId(), true, bounds);
          }
        }
      }
    }

    /**
     * Returns bounds of the anchor element
     */
    private getElementBounds_(element: HTMLElement) {
      const rect: RectF = {x: 0, y: 0, width: 0, height: 0};
      const bounds = element.getBoundingClientRect();
      rect.x = bounds.x;
      rect.y = bounds.y;
      rect.width = bounds.width;
      rect.height = bounds.height;
      const nativeId = element.dataset['nativeId'];
      if (!nativeId) {
        return rect;
      }
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      if (ctrl) {
        const padding = ctrl.getPadding();
        rect.x -= padding.left;
        rect.y -= padding.top;
        rect.width += padding.left + padding.right;
        rect.height += padding.top + padding.bottom;
      }
      return rect;
    }

    /**
     * This event is emitted by the mojo router
     */
    private onShowHelpBubble_(params: HelpBubbleParams): void {
      if (!this.helpBubbleControllerById_.has(params.nativeIdentifier)) {
        // Identifier not handled by this mixin.
        return;
      }
      const ctrl = this.helpBubbleControllerById_.get(params.nativeIdentifier)!;
      this.showHelpBubble(ctrl, params);
    }

    /**
     * This event is emitted by the mojo router
     */
    private onToggleHelpBubbleFocusForAccessibility_(nativeId: string) {
      if (!this.helpBubbleControllerById_.has(nativeId)) {
        // Identifier not handled by this mixin.
        return;
      }

      const ctrl = this.helpBubbleControllerById_.get(nativeId)!;
      if (ctrl) {
        const anchor = ctrl.getAnchor();
        if (anchor) {
          anchor.focus();
        }
      }
    }

    /**
     * This event is emitted by the mojo router
     */
    private onHideHelpBubble_(nativeId: string): void {
      // This may be called with nativeId not handled by this mixin
      // Ignore return value to silently fail
      this.hideHelpBubble(nativeId);
    }

    /**
     * This event is emitted by the mojo router.
     */
    private onExternalHelpBubbleUpdated_(nativeId: string, shown: boolean) {
      if (!this.helpBubbleControllerById_.has(nativeId)) {
        // Identifier not handled by this mixin.
        return;
      }

      // Get the associated bubble and update status
      const ctrl = this.helpBubbleControllerById_.get(nativeId)!;
      ctrl.updateExternalShowingStatus(shown);
    }

    /**
     * This event is emitted by the help-bubble component
     */
    private onHelpBubbleDismissed_(e: HelpBubbleDismissedEvent) {
      const nativeId = e.detail.nativeId;
      assert(nativeId);
      const hidden = this.hideHelpBubble(nativeId);
      assert(hidden);
      if (nativeId) {
        if (e.detail.fromActionButton) {
          this.helpBubbleHandler_.helpBubbleButtonPressed(
              nativeId, e.detail.buttonIndex!);
        } else {
          this.helpBubbleHandler_.helpBubbleClosed(
              nativeId, HelpBubbleClosedReason.kDismissedByUser);
        }
      }
    }

    /**
     * This event is emitted by the help-bubble component
     */
    private onHelpBubbleTimedOut_(e: HelpBubbleDismissedEvent) {
      const nativeId = e.detail.nativeId;
      const ctrl = this.helpBubbleControllerById_.get(nativeId);
      assert(ctrl);
      const hidden = this.hideHelpBubble(nativeId);
      assert(hidden);
      if (nativeId) {
        this.helpBubbleHandler_.helpBubbleClosed(
            nativeId, HelpBubbleClosedReason.kTimedOut);
      }
    }
  }

  return HelpBubbleMixinLit;
};

export interface HelpBubbleMixinLitInterface {
  registerHelpBubble(nativeId: string, trackable: Trackable, options?: Options):
      HelpBubbleController|null;
  unregisterHelpBubble(nativeId: string): void;
  isHelpBubbleShowing(): boolean;
  isHelpBubbleShowingForTesting(id: string): boolean;
  getHelpBubbleForTesting(id: string): HelpBubbleElement|null;
  getSortedAnchorStatusesForTesting(): Array<[string, boolean]>;
  canShowHelpBubble(controller: HelpBubbleController): boolean;
  showHelpBubble(controller: HelpBubbleController, params: HelpBubbleParams):
      void;
  hideHelpBubble(nativeId: string): boolean;
  notifyHelpBubbleAnchorActivated(anchorId: string): boolean;
  notifyHelpBubbleAnchorCustomEvent(anchorId: string, customEvent: string):
      boolean;
}

export interface Options {
  anchorPaddingTop?: number;
  anchorPaddingLeft?: number;
  anchorPaddingBottom?: number;
  anchorPaddingRight?: number;
  fixed?: boolean;
}

export function parseOptions(options: Options) {
  const padding: InsetsF = {top: 0, bottom: 0, left: 0, right: 0};
  padding.top = clampPadding(options.anchorPaddingTop);
  padding.left = clampPadding(options.anchorPaddingLeft);
  padding.bottom = clampPadding(options.anchorPaddingBottom);
  padding.right = clampPadding(options.anchorPaddingRight);
  return {
    padding,
    fixed: !!options.fixed,
  };
}

function clampPadding(n: number = 0) {
  return Math.max(0, Math.min(20, n));
}