chromium/ui/file_manager/file_manager/containers/nudge_container.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 '../widgets/xf_nudge.js';

import {storage} from '../common/js/storage.js';
import {str} from '../common/js/translations.js';
import type {XfNudge} from '../widgets/xf_nudge.js';
import {NudgeDirection} from '../widgets/xf_nudge.js';
import type {XfTreeItem} from '../widgets/xf_tree_item.js';

/**
 * NudgeContainer maintains the lifetime of a "nudge". A nudge refers to an
 * educational overlay that shows up to highlight new features, currently we
 * only support a single nudge showing in Files app.
 */
export class NudgeContainer {
  /**
   * The educational nudge that is added as a web component to the DOM.
   */
  private nudge_: XfNudge = document.querySelector('xf-nudge')!;

  /**
   * The handle that represents the requestIdleCallback to enable cancellation.
   */
  private idleCallbackHandle_: number = -1;

  /**
   * The current `NudgeType` that is visible.
   */
  private currentNudgeType_: NudgeType|undefined = undefined;

  /**
   * Each nudge has a described-by <p> tag to enable an announcement to be made
   * when hovering over or tabbing to the anchored element.
   */
  private readonly anchorAriaDescribedbyElement_ = document.createElement('p');

  /**
   * A controller which sends out an abort signal once we no longer want to
   * listen to events i.e. on nudge hide.
   */
  private listenerAbortController_: AbortController|null = null;

  /**
   * Cache the DOMRect of the anchor to allow comparison of the previous
   * location and in the case the anchor DOMRect changes, reposition the nudge
   * accordingly.
   */
  private anchorDomRect_: DOMRect|undefined = undefined;

  /**
   * Stores the ID of the current requestAnimationFrame(). Used to ensure only
   * run callback is running at a time.
   */
  private requestAnimationFrameId_: number|undefined = undefined;

  /**
   * True if the expiry period on the nudge is observed. False otherwise.
   */
  private expiryPeriodEnabled_: boolean = true;

  constructor() {
    this.anchorAriaDescribedbyElement_.id = 'nudge-content';
    this.anchorAriaDescribedbyElement_.style.display = 'none';
  }

  /**
   * A callback that repositions the nudge element prior to a repaint. The
   * callback is throttled to only run on animation frames since we call it for
   * scroll events; which can be numerous between frames.
   */
  private throttledRepositionCallback_() {
    if (this.requestAnimationFrameId_) {
      return;
    }

    this.requestAnimationFrameId_ = window.requestAnimationFrame(() => {
      if (!this.nudgeShowing_) {
        return;
      }

      const anchorDomRect = this.nudge_.anchor!.getBoundingClientRect();
      // First verify that the anchor has changed in some position or dimension
      // before repositioning the nudge. This ensures we're not too aggressive
      // in repositioning.
      if (this.anchorDomRect_ &&
          (anchorDomRect.x !== this.anchorDomRect_.x ||
           anchorDomRect.y !== this.anchorDomRect_.y ||
           anchorDomRect.width !== this.anchorDomRect_.width ||
           anchorDomRect.height !== this.anchorDomRect_.height)) {
        this.anchorDomRect_ = anchorDomRect;
        this.nudge_.reposition();
      }

      this.requestAnimationFrameId_ = undefined;
    });
  }

  /**
   * Attempts to reposition the visible nudge if it is showing. There is no easy
   * way to listen for DOM elements that change without user input (e.g. if a
   * volume is added or removed from the directory tree). So use an IdleCallback
   * to keep checking the nudge is in the right position.
   */
  private idleCallback_() {
    if (this.nudgeShowing_) {
      this.throttledRepositionCallback_();
      this.idleCallbackHandle_ = window.requestIdleCallback(
          this.idleCallback_.bind(this), {timeout: 1000});
      return;
    }

    window.cancelIdleCallback(this.idleCallbackHandle_);
  }

  /**
   * A method for the nudge manager to decide whether a given nudge has been
   * previously seen and dismissed by the user.
   */
  async checkSeen(nudgeId: string) {
    const seen = await storage.local.getAsync(nudgeId);
    return seen[nudgeId] === 'true';
  }

  /**
   * A method for the nudge manager to specify that a given nudge has been seen
   * and dismissed by the user.
   */
  async setSeen(nudgeId: string) {
    return storage.local.setAsync({[nudgeId]: 'true'});
  }

  /**
   * Clears the `seen` state from the localStorage for the given nudge.
   */
  async clearSeen(nudgeType: NudgeType) {
    storage.local.remove(nudgeType);
  }

  /**
   * Shows the nudge if it has not already been seen before.
   */
  async showNudge(nudge: NudgeType) {
    if (this.nudgeShowing_) {
      return;
    }

    // No nudge info exists for the supplied nudge.
    if (!nudgeInfo[nudge]) {
      console.warn('Nudge', nudge, 'does not exist');
      return;
    }
    if (!nudgeInfo[nudge].anchor()) {
      console.warn('nudge anchor', nudge, 'does not exist');
      return;
    }
    const info = nudgeInfo[nudge];
    const anchor = info.anchor() as HTMLElement;

    // Don't show the nudge if it's expired and the expiry period is enabled.
    if (info.expiryDate && info.expiryDate < new Date() &&
        this.expiryPeriodEnabled_) {
      return;
    }

    if (await this.checkSeen(nudge)) {
      return;
    }

    this.currentNudgeType_ = nudge;

    // Create a new controller since they can only be aborted once (adding an
    // aborted signal to a listener will result in no listening).
    this.listenerAbortController_ = new AbortController();
    // Anchor container scrolling and document resizes can potentially
    // reposition the anchor, which will need a matching reposition of the nudge
    // element-- so we listen to those events and reposition upon them
    // occurring. Note, it is possible that there are other ways of manipulating
    // the anchor position without triggering any of the events here (e.g.
    // resizing an element within the document); but no such use case exists
    // yet.
    const config = {
      signal: this.listenerAbortController_.signal,
      passive: true,
    };
    let anchorTreeNode: Node|null = anchor;
    while (anchorTreeNode) {
      if (anchorTreeNode instanceof EventTarget) {
        anchorTreeNode.addEventListener(
            'scroll', this.throttledRepositionCallback_.bind(this), config);
      }
      anchorTreeNode = anchorTreeNode.parentNode;
      if (anchorTreeNode instanceof ShadowRoot) {
        anchorTreeNode = anchorTreeNode.host;
      }
    }
    window.addEventListener(
        'resize', this.throttledRepositionCallback_.bind(this), config);

    if (info.selfDismiss) {
      // Self dismissable nudge only dismisses if the user clicks on the nudge.
      this.nudge_.addEventListener(
          'pointerdown', () => this.closeNudge(this.currentNudgeType_), config);
      anchor.addEventListener(
          'pointerdown', () => this.closeNudge(this.currentNudgeType_), config);
      const dismissOnKeyDown = info.dismissOnKeyDown;
      if (dismissOnKeyDown) {
        document.addEventListener('keydown', (event: KeyboardEvent) => {
          if (dismissOnKeyDown(anchor, event)) {
            this.closeNudge(this.currentNudgeType_);
          }
        }, config);
      }
    } else {
      // Otherwise the nudge dismisses when user clicks anywhere in the app.
      document.addEventListener('keydown', e => this.handleKeyDown_(e), config);
      document.addEventListener(
          'pointerdown', e => this.handlePointerDown_(e), config);
      anchor.addEventListener(
          'blur', (_: Event) => this.closeNudge(this.currentNudgeType_),
          config);
      this.nudge_.dismissText = '';
    }

    this.nudge_.anchor = anchor;
    this.nudge_.content = info.content();
    this.nudge_.direction = info.direction;

    this.nudge_.show();
    this.anchorDomRect_ = anchor.getBoundingClientRect();
    this.setAnchorAriaDescribedby_();

    window.cancelIdleCallback(this.idleCallbackHandle_);
    this.idleCallbackHandle_ = window.requestIdleCallback(
        this.idleCallback_.bind(this), {timeout: 1000});
  }

  /**
   * Hide the currently showing nudge and update the seen status if provided.
   */
  async closeNudge(seenNudgeId?: string) {
    window.cancelIdleCallback(this.idleCallbackHandle_);
    if (!this.nudgeShowing_) {
      return;
    }
    if (seenNudgeId) {
      await this.setSeen(seenNudgeId);
    }
    this.nudge_.hide();
    this.currentNudgeType_ = undefined;
    this.anchorDomRect_ = undefined;
    this.removeAnchorAriaDescribedby_();
    // Abort listeners since we don't want to update position after hiding.
    this.listenerAbortController_?.abort();
  }

  /**
   * Used to override the expiry period for nudges in test.
   */
  set setExpiryPeriodEnabledForTesting(value: boolean) {
    this.expiryPeriodEnabled_ = value;
  }

  /**
   * Handle key down events such that any "Escape", "Enter" or "Space" should
   * close the nudge.
   */
  private handleKeyDown_(event: KeyboardEvent) {
    switch (event.key) {
      case 'Escape':
      case 'Enter':
      case 'Space':
        this.closeNudge(this.currentNudgeType_);
        break;
      default:
        break;
    }
  }

  /**
   * Handle any pointer down events on the Nudge.
   */
  private handlePointerDown_(event: MouseEvent) {
    // Ignore pointer events on the nudge to allow copying the nudge's text.
    if (event.composedPath().includes(this.nudge_)) {
      return;
    }
    this.closeNudge(this.currentNudgeType_);
  }

  /**
   * Set the <p> aria-described-by content to enable screen readers to hear the
   * nudge content when navigating over the anchored element.
   */
  private setAnchorAriaDescribedby_() {
    if (!this.nudge_.anchor) {
      return;
    }

    this.anchorAriaDescribedbyElement_.innerText = this.nudge_.content;
    // Add a new element as a sibling of the anchor so we can aria-describedBy
    // it to read out the contents of the nudge.
    this.nudge_.anchor.insertAdjacentElement(
        'afterend', this.anchorAriaDescribedbyElement_);
    this.nudge_.anchor.setAttribute('aria-describedby', 'nudge-content');
  }

  /**
   * Remove the <p> aria-described-by content.
   */
  private removeAnchorAriaDescribedby_() {
    this.anchorAriaDescribedbyElement_.remove();

    if (!this.nudge_.anchor) {
      return;
    }

    this.nudge_.anchor.removeAttribute('aria-describedby');
  }

  /**
   * Helper function to return whether a current nudge is showing.
   */
  private get nudgeShowing_() {
    return this.currentNudgeType_ !== undefined;
  }
}

/**
 * An enum of nudges that can be shown, only a single nudge is shown at a time.
 */
export enum NudgeType {
  TEST_NUDGE = 'test-nudge',
  MANUAL_TEST_NUDGE = 'manual-test-nudge',
  ONE_DRIVE_MOVED_FILE_NUDGE = 'one-drive-moved-file-nudge',
  DRIVE_MOVED_FILE_NUDGE = 'drive-moved-file-nudge',
  SEARCH_V2_EDUCATION_NUDGE = 'search-v2-education-nudge',
}

/**
 * Type to define the callback used that gets the anchor element from the DOM.
 */
interface NudgeInfo {
  // The anchor that the nudge will appear near. The location of the nudge
  // relative to the anchor is dictated by the `direction`.
  anchor: () => HTMLElement | null;

  // The string contents of the nudge.
  content: () => string;

  // The direction that nudge appears relative to the anchor. For more
  // explanation on the various `NudgeDirection`'s look in `xf_nudge.ts` file.
  direction: NudgeDirection;

  // The date the nudge expires, after this date even if the nudge is invoked it
  // will not appear.
  expiryDate: Date;

  // When the using selfDimiss=true the user can dismiss by clicking in the
  // nudge. Otherwise the nudge is dismissed when clicking anywhere in
  // the app/document.
  selfDismiss?: boolean;

  // For selfDismiss nudge the nudge and its anchor might not get keyboard focus
  // to be able to dismiss via keyboard.
  // Implement this callback that receives the keydown from document and should
  // return true if the nudge should be dismissed.
  dismissOnKeyDown?:
      (anchor: HTMLElement|null, event: KeyboardEvent) => boolean;
}

/**
 * Dismisses the nudge when the tree-item that anchors the nudge is selected.
 *
 * NOTE: It relies on the nudge anchor being in the icon, to traverse 2 parents
 * up to the tree-item.
 */
function treeDismissOnKeyDownOnTreeItem(
    anchor: HTMLElement|null, event: KeyboardEvent) {
  const dismissKeys = new Set(['Enter', 'Space']);
  if (!dismissKeys.has(event.key)) {
    return false;
  }

  // When the anchor (tree item) is selected we dismiss.
  const parentTreeItem: Element|null|undefined =
      (anchor?.getRootNode() as ShadowRoot)?.host;
  if (parentTreeItem?.hasAttribute('selected')) {
    return true;
  }
  return false;
}

/**
 * A mapping of nudges to their information that can be shown throughout the
 * Files app.
 */
export const nudgeInfo: {[type in NudgeType]: NudgeInfo} = {
  [NudgeType['TEST_NUDGE']]: {
    anchor: () => document.querySelector<HTMLDivElement>('div#test'),
    content: () => 'Test content',
    direction: NudgeDirection.BOTTOM_ENDWARD,
    expiryDate: new Date(2999, 1, 1),
  },
  [NudgeType['MANUAL_TEST_NUDGE']]: {
    anchor: () => {
      const downloadsTreeItem =
          document.querySelector<XfTreeItem>('xf-tree-item[icon="downloads"]')!;
      return downloadsTreeItem.shadowRoot!.querySelector('xf-icon');
    },
    content: () => str('ONE_DRIVE_MOVED_FILE_NUDGE'),
    direction: NudgeDirection.TRAILING_DOWNWARD,
    expiryDate: new Date(2999, 1, 1),
    selfDismiss: true,
    dismissOnKeyDown: treeDismissOnKeyDownOnTreeItem,
  },
  [NudgeType['ONE_DRIVE_MOVED_FILE_NUDGE']]: {
    anchor: () => {
      const oneDriveTreeItem =
          document.querySelector<XfTreeItem>('xf-tree-item[one-drive]');
      return oneDriveTreeItem?.shadowRoot!.querySelector('.tree-row') || null;
    },
    content: () => str('ONE_DRIVE_MOVED_FILE_NUDGE'),
    direction: NudgeDirection.TRAILING_DOWNWARD,
    expiryDate: new Date(2025, 12, 5),
    selfDismiss: true,
    dismissOnKeyDown: treeDismissOnKeyDownOnTreeItem,
  },
  [NudgeType['DRIVE_MOVED_FILE_NUDGE']]: {
    anchor: () => {
      const driveTreeItem = document.querySelector<XfTreeItem>(
          'xf-tree-item[icon="service_drive"]');
      return driveTreeItem?.shadowRoot!.querySelector('.tree-row') || null;
    },
    content: () => str('DRIVE_MOVED_FILE_NUDGE'),
    direction: NudgeDirection.TRAILING_DOWNWARD,
    expiryDate: new Date(2025, 12, 5),
    selfDismiss: true,
    dismissOnKeyDown: treeDismissOnKeyDownOnTreeItem,
  },
  [NudgeType['SEARCH_V2_EDUCATION_NUDGE']]: {
    anchor: () =>
        document.querySelector<HTMLDivElement>('#search-button > .icon'),
    content: () => str('SEARCH_V2_EDUCATION_NUDGE'),
    direction: NudgeDirection.BOTTOM_STARTWARD,
    // Expire after 4 releases (expires when M120 hits Stable).
    expiryDate: new Date(2023, 12, 5),
  },
};