chromium/ui/file_manager/file_manager/widgets/xf_nudge.ts

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

import {getTemplate} from './xf_nudge.html.js';

/**
 * The diameter of the dot that appears beside the anchor. Keep this up to date
 * with the width of the dot in the `xf_nudge.html` file.
 */
const DOT_DIAMETER_PX = 8;

/**
 * The default indent that the dot is from the side of the bubble.
 */
const DEFAULT_DOT_INDENT_PX = 32;

/**
 * An XfNudge represents an element on the screen to draw the user's
 * attention to a specific portion of the screen. This can be a new feature, the
 * location of a file that has just changed etc.
 */
export class XfNudge extends HTMLElement {
  /**
   * The dot element that appears near the anchor point.
   */
  private dot_: HTMLElement;

  /**
   * The bubble that contains the text content highlighting the anchor.
   */
  private bubble_: HTMLElement;

  /**
   * The anchor element that the nudge should be highlighting.
   */
  private anchor_: HTMLElement|undefined = undefined;

  /**
   * The internal content slot that is used to set the text of the nudge.
   */
  private contentSlot_: HTMLElement;

  /**
   * The dismiss button element.
   */
  private dismissButton_: HTMLElement;

  /**
   * The direction of the nudge relative to the anchor.
   */
  private direction_: NudgeDirection = NudgeDirection.TOP_STARTWARD;

  /**
   * The content of the nudge.
   */
  private content_: string = '';

  /**
   * Text used in the dismiss button. When empty the button is hidden.
   */
  private dismissText_: string = '';

  /**
   * How many times the nudge has been repositioned, this is reset when the
   * nudge is hidden.
   */
  private repositions_: number = 0;

  constructor() {
    super();

    const template = document.createElement('template');
    template.innerHTML = getTemplate() as unknown as string;
    const fragment = template.content.cloneNode(true);
    this.attachShadow({mode: 'open'}).appendChild(fragment);

    this.bubble_ = this.shadowRoot!.getElementById('bubble')!;
    this.contentSlot_ = this.shadowRoot!.getElementById('text')!;
    this.dismissButton_ = this.shadowRoot!.getElementById('dismiss')!;
    this.dismissButton_.addEventListener(
        'click', this.dismissClicked_.bind(this));
    this.dot_ = this.shadowRoot!.getElementById('dot')!;
  }

  static get events() {
    return {
      DISMISS: 'dismiss',
    } as const;
  }

  private dismissClicked_() {
    this.dispatchEvent(new CustomEvent(
        XfNudge.events.DISMISS, {bubbles: true, composed: true}));
  }

  /**
   * Show the nudge attached to a provided anchor. Note: This class should not
   * handle any logic on _when_ a nudge should be shown. This should be
   * completely handled by the NudgeManager.
   */
  show() {
    if (this.content_ === '') {
      throw new Error('Attempted to show <xf-nudge> without a message');
    }
    if (!this.anchor_) {
      throw new Error('Attempted to show <xf-nudge> without an anchor');
    }

    this.dismissButton_.innerText = this.dismissText_;
    this.dismissButton_.toggleAttribute('hidden', this.dismissText_ === '');
    this.contentSlot_.innerText = this.content_;
    this.reposition();
  }

  /**
   * Hide the nudge. Note: This class should not handle any logic on _when_ a
   * nudge should be hidden. This should be completely handled by the
   * NudgeManager.
   */
  hide() {
    // Rather than removing the nudge elements from the DOM, render them
    // off-screen so that they change size correctly when the nudge contents are
    // updated. In doing this, they will be the correct size before attempting
    // to position the nudge the next time it is shown.
    this.dot_.style.left = `-${DOT_DIAMETER_PX}px`;
    this.bubble_.style.left = '-296px';
    this.repositions_ = 0;
  }

  /**
   * Repositions the nudge component to be anchored to the anchor.
   */
  reposition() {
    if (!this.anchor_) {
      throw new Error('Attempted to position <xf-nudge> without an anchor');
    }

    // Reset CSS values which might not get set.
    this.bubble_.style.left = 'unset';
    this.bubble_.style.right = 'unset';
    this.bubble_.style.top = 'unset';
    this.bubble_.style.bottom = 'unset';

    const anchorRect = this.anchor_.getBoundingClientRect();

    this.positionDot_(anchorRect);

    if (this.positionedVertically_()) {
      this.positionBubbleVertical_(anchorRect);
    } else {
      this.positionBubbleHorizontal_(anchorRect);
    }

    this.repositions_++;
  }

  /**
   * Sets the anchor that the nudge is tied to. This element will serve as the
   * point where the nudge will position itself relative to.
   */
  set anchor(anchor: HTMLElement|undefined) {
    this.anchor_ = anchor;
  }

  /**
   * Get the anchor this nudge is highlighting.
   */
  get anchor(): HTMLElement|undefined {
    return this.anchor_;
  }

  /**
   * Sets the content that the nudge will show.
   */
  set content(content: string) {
    this.content_ = content;
  }

  /**
   * Returns the content that the nudge will display.
   */
  get content() {
    return this.content_;
  }

  /**
   * Sets the text for the dismiss button, when empty hides the button.
   */
  set dismissText(text: string) {
    this.dismissText_ = text;
  }

  get dismissText() {
    return this.dismissText_;
  }

  /**
   * Sets the direction of the nudge to appear relative to the anchor point.
   */
  set direction(direction: NudgeDirection) {
    this.direction_ = direction;
  }

  /**
   * Helper method that exposes the bounding DOMRect of the dot to introspect in
   * tests.
   */
  get dotRect() {
    return this.dot_.getBoundingClientRect();
  }

  /**
   * Helper method that exposes the bounding DOMRect of the bubble to introspect
   * in tests.
   */
  get bubbleRect() {
    return this.bubble_.getBoundingClientRect();
  }

  /**
   * Returns the number of repositions for the nudge that is currently showing.
   * This is reset when the nudge is hidden.
   */
  get repositions() {
    return this.repositions_;
  }

  /**
   * Position the dot of the nudge to be at the correct position to the anchored
   * element.
   */
  private positionDot_(anchorRect: DOMRect) {
    let dotTop = anchorRect.top + anchorRect.height / 2 - DOT_DIAMETER_PX / 2;
    let dotLeft = anchorRect.left + anchorRect.width / 2 - DOT_DIAMETER_PX / 2;

    if (this.positionedTop_()) {
      dotTop = anchorRect.top - DOT_DIAMETER_PX - 4;
    }
    if (this.positionedBottom_()) {
      dotTop = anchorRect.bottom + 4;
    }
    if (this.positionedLeft()) {
      dotLeft = anchorRect.left - DOT_DIAMETER_PX - 4;
    }
    if (this.positionedRight_()) {
      dotLeft = anchorRect.right + 4;
    }

    this.dot_.style.top = `${dotTop}px`;
    this.dot_.style.left = `${dotLeft}px`;
  }

  /**
   * Position the bubble that has the nudge contents vertically above or below
   * the dot.
   */
  private positionBubbleVertical_(anchorRect: DOMRect) {
    // Calculate the bubble's vertical position.
    if (this.positionedTop_()) {
      const bubbleBottom = anchorRect.top - DOT_DIAMETER_PX - 2 * 4;
      // Fixed position bottom refers to how far the bottom edge of the element
      // should be from the bottom edge of the window, so transform our value to
      // account for this difference in semantics.
      this.bubble_.style.bottom = `${window.innerHeight - bubbleBottom}px`;
    } else {
      this.bubble_.style.top =
          `${anchorRect.bottom + DOT_DIAMETER_PX + 2 * 4}px`;
    }

    // Calculate the bubble's horizontal position.
    if (this.growsLeft_()) {
      // E.g.,
      //  _________________
      //  |  Nudge        |
      //  |_______________|
      //              .
      //             []

      // Calculate the ideal right edge position for the bubble to have it
      // appear towards the left of the dot.
      const dotRightEdge =
          anchorRect.left + anchorRect.width / 2 + DOT_DIAMETER_PX / 2 + 4;
      // The bubble's right edge should be `DEFAULT_DOT_INDENT_PX` further right
      // than the dot's right edge.
      const idealBubbleRight = dotRightEdge + DEFAULT_DOT_INDENT_PX;

      // The bubble should not be positioned so far right that it goes
      // off-screen.
      const maxBubbleRight = window.innerWidth;

      // Fixed position right refers to how far the right edge of the element
      // should be from the right edge of the window, so transform our value to
      // account for this difference in semantics.
      this.bubble_.style.right =
          `${window.innerWidth - Math.min(idealBubbleRight, maxBubbleRight)}px`;
    } else {
      // E.g.,
      //  _________________
      //  |  Nudge        |
      //  |_______________|
      //      .
      //     []

      // Calculate the ideal left offset for the bubble to have it appear
      // towards the right of the dot.
      const dotLeftEdge =
          anchorRect.left + anchorRect.width / 2 - DOT_DIAMETER_PX / 2 - 4;
      const idealBubbleLeft = dotLeftEdge - DEFAULT_DOT_INDENT_PX;

      this.bubble_.style.left = `${Math.max(idealBubbleLeft, 0)}px`;
    }
  }

  /**
   * Position the bubble that has the nudge contents horizontally to the left or
   * right of the dot.
   */
  private positionBubbleHorizontal_(anchorRect: DOMRect) {
    // Calculate the bubble's vertical position.
    if (this.growsUpward_()) {
      // We can't guarantee the height of the anchor, so position the bottom of
      // the bubble 10px below the dot.
      const dotBottom =
          anchorRect.top + anchorRect.height / 2 + DOT_DIAMETER_PX / 2 + 4;
      const bubbleBottom = dotBottom + 10;
      // Fixed position bottom refers to how far the bottom edge of the element
      // should be from the bottom edge of the window, so transform our value to
      // account for this difference in semantics.
      this.bubble_.style.bottom = `${window.innerHeight - bubbleBottom}px`;
    } else {
      // We can't guarantee the height of the anchor, so position the top of the
      // bubble 10px above the dot.
      const dotTop =
          anchorRect.top + anchorRect.height / 2 - DOT_DIAMETER_PX / 2 - 4;
      const bubbleTop = dotTop - 10;
      this.bubble_.style.top = `${bubbleTop}px`;
    }

    // Calculate the bubble's horizontal position.
    if (this.positionedLeft()) {
      // E.g.,
      //  _________________
      //  |  Nudge        |
      //  |_______________| . []

      const bubbleRight = anchorRect.left - DOT_DIAMETER_PX - 2 * 4;

      // Fixed position right refers to how far the right edge of the element
      // should be from the right edge of the window, so transform our value to
      // account for this difference in semantics.
      this.bubble_.style.right = `${window.innerWidth - bubbleRight}px`;
    } else {
      // E.g.,
      //      _________________
      //      |  Nudge        |
      // [] . |_______________|

      const bubbleLeft = anchorRect.right + DOT_DIAMETER_PX + 2 * 4;
      this.bubble_.style.left = `${bubbleLeft}px`;
    }
  }

  /**
   * For the remainder methods, look at the NudgeDirection to understand what
   * they mean.
   */
  private positionedTop_() {
    return this.direction_ === NudgeDirection.TOP_STARTWARD ||
        this.direction_ === NudgeDirection.TOP_ENDWARD;
  }

  private positionedBottom_() {
    return this.direction_ === NudgeDirection.BOTTOM_STARTWARD ||
        this.direction_ === NudgeDirection.BOTTOM_ENDWARD;
  }

  private positionedLeading_() {
    return this.direction_ === NudgeDirection.LEADING_UPWARD ||
        this.direction_ === NudgeDirection.LEADING_DOWNWARD;
  }

  private positionedTrailing_() {
    return this.direction_ === NudgeDirection.TRAILING_UPWARD ||
        this.direction_ === NudgeDirection.TRAILING_DOWNWARD;
  }

  private positionedLeft() {
    if (document.dir === 'rtl') {
      return this.positionedTrailing_();
    } else {
      return this.positionedLeading_();
    }
  }

  private positionedRight_() {
    if (document.dir === 'rtl') {
      return this.positionedLeading_();
    } else {
      return this.positionedTrailing_();
    }
  }

  private positionedVertically_() {
    return this.positionedTop_() || this.positionedBottom_();
  }

  private growsUpward_() {
    return this.direction_ === NudgeDirection.LEADING_UPWARD ||
        this.direction_ === NudgeDirection.TRAILING_UPWARD;
  }

  private growsLeft_() {
    if (document.dir === 'rtl') {
      return this.direction_ === NudgeDirection.TOP_ENDWARD ||
          this.direction_ === NudgeDirection.BOTTOM_ENDWARD;
    } else {
      return this.direction_ === NudgeDirection.TOP_STARTWARD ||
          this.direction_ === NudgeDirection.BOTTOM_STARTWARD;
    }
  }
}

/**
 * The direction a nudge should render relative to its anchor.
 */
export enum NudgeDirection {
  /** Shows above the anchor and extends to the left in LTR. */
  TOP_STARTWARD = 'top-startward',
  /** Shows above the anchor and extends to the right in LTR. */
  TOP_ENDWARD = 'top-endward',
  /** Shows below the anchor and extends to the left in LTR. */
  BOTTOM_STARTWARD = 'bottom-startward',
  /** Shows below the anchor and extends to the right in LTR. */
  BOTTOM_ENDWARD = 'bottom-endward',
  /**
   * Shows left of the anchor in LTR and grows upwards if the content spans
   * multiple lines.
   */
  LEADING_UPWARD = 'leading-upward',
  /**
   * Shows left of the anchor in LTR and grows downwards if the content spans
   * multiple lines.
   */
  LEADING_DOWNWARD = 'leading-downward',
  /**
   * Shows right of the anchor in LTR and grows upwards if the content spans
   * multiple lines.
   */
  TRAILING_UPWARD = 'trailing-upward',
  /**
   * Shows right of the anchor in LTR and grows downwards if the content spans
   * multiple lines.
   */
  TRAILING_DOWNWARD = 'trailing-downward',
}

declare global {
  interface HTMLElementTagNameMap {
    'xf-nudge': XfNudge;
  }
}

customElements.define('xf-nudge', XfNudge);