| // 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/util.js'; |
| import {NudgeDirection, XfNudge} from '../widgets/xf_nudge.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); |
| 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( |
| /** @type {!EventTarget} */ (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', |
| TRASH_NUDGE = 'trash-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. |
| if (anchor?.parentElement?.parentElement?.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), |
| }, |
| // A nudge that is shown when an item is first sent to the trash. |
| [NudgeType['TRASH_NUDGE']]: { |
| anchor: () => |
| document.querySelector<HTMLSpanElement>('span[root-type-icon="trash"]'), |
| content: () => str('TRASH_NUDGE_LABEL'), |
| direction: NudgeDirection.BOTTOM_ENDWARD, |
| // Expire this after 4 releases (expires when M112 hits Stable). |
| expiryDate: new Date(2023, 4, 6), |
| }, |
| [NudgeType['MANUAL_TEST_NUDGE']]: { |
| anchor: () => { |
| const children = Array.from(document.querySelectorAll<HTMLElement>( |
| '.tree-item[section-start="my_files"] > .tree-children > .tree-item .entry-name')); |
| |
| for (const child of children) { |
| if (child.innerText !== 'Downloads') { |
| continue; |
| } |
| |
| return child.parentElement?.querySelector<HTMLSpanElement>( |
| '.item-icon') ?? |
| null; |
| } |
| |
| return null; |
| }, |
| 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: () => { |
| return document |
| .querySelector<HTMLSpanElement>( |
| '.tree-item[one-drive] .file-row .item-icon') |
| ?.parentElement || |
| null; |
| }, |
| content: () => str('ONE_DRIVE_MOVED_FILE_NUDGE'), |
| direction: NudgeDirection.TRAILING_DOWNWARD, |
| // Expire after 4 releases (expires when M120 hits Stable). |
| expiryDate: new Date(2023, 12, 5), |
| selfDismiss: true, |
| dismissOnKeyDown: treeDismissOnKeyDownOnTreeItem, |
| }, |
| [NudgeType['DRIVE_MOVED_FILE_NUDGE']]: { |
| anchor: () => { |
| return document |
| .querySelector<HTMLSpanElement>( |
| '.tree-item .item-icon[volume-type-icon="drive"]') |
| ?.parentElement || |
| null; |
| }, |
| content: () => str('DRIVE_MOVED_FILE_NUDGE'), |
| direction: NudgeDirection.TRAILING_DOWNWARD, |
| // Expire after 4 releases (expires when M120 hits Stable). |
| expiryDate: new Date(2023, 12, 5), |
| selfDismiss: true, |
| dismissOnKeyDown: treeDismissOnKeyDownOnTreeItem, |
| }, |
| [NudgeType['SEARCH_V2_EDUCATION_NUDGE']]: { |
| anchor: () => document.querySelector<HTMLSpanElement>('#search-wrapper'), |
| 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), |
| }, |
| }; |