blob: b22443ddc9e97ef57bba52315f357a2f76f7ceac [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview A bubble for displaying in-product help. These are created
* dynamically by HelpBubbleMixin, and their API should be considered an
* implementation detail and subject to change (you should not add them to your
* components directly).
*/
import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import 'chrome://resources/cr_elements/hidden_style_css.m.js';
import 'chrome://resources/cr_elements/icons.m.js';
import {CrButtonElement} from '//resources/cr_elements/cr_button/cr_button.m.js';
import {CrIconButtonElement} from '//resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import {assert, assertNotReached} from '//resources/js/assert_ts.js';
import {isWindows} from '//resources/js/cr.m.js';
import {DomRepeatEvent, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {getTemplate} from './help_bubble.html.js';
import {HelpBubbleButtonParams, HelpBubblePosition, Progress} from './help_bubble.mojom-webui.js';
const ANCHOR_HIGHLIGHT_CLASS = 'help-anchor-highlight';
const ACTION_BUTTON_ID_PREFIX = 'action-button-';
export const HELP_BUBBLE_DISMISSED_EVENT = 'help-bubble-dismissed';
export type HelpBubbleDismissedEvent = CustomEvent<{
anchorId: string,
fromActionButton: boolean,
buttonIndex?: number,
}>;
export interface HelpBubbleElement {
$: {
arrow: HTMLElement,
buttons: HTMLElement,
close: CrIconButtonElement,
main: HTMLElement,
progress: HTMLElement,
topContainer: HTMLElement,
};
}
export class HelpBubbleElement extends PolymerElement {
static get is() {
return 'help-bubble';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
anchorId: {
type: String,
value: '',
reflectToAttribute: true,
},
closeText: String,
position: {
type: HelpBubblePosition,
value: HelpBubblePosition.BELOW,
reflectToAttribute: true,
},
};
}
anchorId: string;
bodyText: string;
titleText: string;
closeText: string;
position: HelpBubblePosition;
buttons: HelpBubbleButtonParams[] = [];
progress: Progress|null = null;
/**
* HTMLElement corresponding to |this.anchorId|.
*/
private anchorElement_: HTMLElement|null = null;
/**
* Backing data for the dom-repeat that generates progress indicators.
* The elements are placeholders only.
*/
private progressData_: void[] = [];
/**
* Shows the bubble.
*/
show() {
// Set up the progress track.
if (this.progress) {
this.progressData_ = new Array(this.progress.total);
} else {
this.progressData_ = [];
}
this.anchorElement_ =
this.parentElement!.querySelector<HTMLElement>(`#${this.anchorId}`)!;
assert(
this.anchorElement_,
'Tried to show a help bubble but couldn\'t find element with id ' +
this.anchorId);
// Reset the aria-hidden attribute as screen readers need to access the
// contents of an opened bubble.
this.style.display = 'block';
this.removeAttribute('aria-hidden');
this.updatePosition_();
this.setAnchorHighlight_(true);
}
/**
* Hides the bubble, clears out its contents, and ensures that screen readers
* ignore it while hidden.
*
* TODO(dfried): We are moving towards formalizing help bubbles as single-use;
* in which case most of this tear-down logic can be removed since the entire
* bubble will go away on hide.
*/
hide() {
this.style.display = 'none';
this.setAttribute('aria-hidden', 'true');
this.setAnchorHighlight_(false);
this.anchorElement_ = null;
}
/**
* Retrieves the current anchor element, if set and the bubble is showing,
* otherwise null.
*/
getAnchorElement(): HTMLElement|null {
return this.anchorElement_;
}
/**
* Returns the button with the given `buttonIndex`, or null if not found.
*/
getButtonForTesting(buttonIndex: number): CrButtonElement|null {
return this.$.buttons.querySelector<CrButtonElement>(
`[id="${ACTION_BUTTON_ID_PREFIX + buttonIndex}"]`);
}
/**
* Returns whether the default button is leading (true on Windows) vs trailing
* (all other platforms).
*/
static isDefaultButtonLeading(): boolean {
return isWindows;
}
private dismiss_() {
assert(this.anchorId, 'Dismiss: expected help bubble to have an anchor.');
this.dispatchEvent(new CustomEvent(HELP_BUBBLE_DISMISSED_EVENT, {
detail: {
anchorId: this.anchorId,
fromActionButton: false,
},
}));
}
private getProgressClass_(index: number): string {
return index < this.progress!.current ? 'current-progress' :
'total-progress';
}
private shouldShowTitleInTopContainer_(
progress: Progress|null, titleText: string): boolean {
return !!titleText && !progress;
}
private shouldShowTitleInMain_(progress: Progress|null, titleText: string):
boolean {
return !!titleText && !!progress;
}
private shouldShowBodyInTopContainer_(
progress: Progress|null, titleText: string): boolean {
return !progress && !titleText;
}
private shouldShowBodyInMain_(progress: Progress|null, titleText: string):
boolean {
return !!progress || !!titleText;
}
private onButtonClick_(e: DomRepeatEvent<HelpBubbleButtonParams>) {
assert(
this.anchorId,
'Action button clicked: expected help bubble to have an anchor.');
// There is no access to the model index here due to limitations of
// dom-repeat. However, the index is stored in the node's identifier.
const index: number = parseInt(
(e.target as Element).id.substring(ACTION_BUTTON_ID_PREFIX.length));
this.dispatchEvent(new CustomEvent(HELP_BUBBLE_DISMISSED_EVENT, {
detail: {
anchorId: this.anchorId,
fromActionButton: true,
buttonIndex: index,
},
}));
}
private getButtonId_(index: number): string {
return ACTION_BUTTON_ID_PREFIX + index;
}
private getButtonClass_(isDefault: boolean): string {
return isDefault ? 'default-button' : '';
}
private getButtonTabIndex_(index: number, isDefault: boolean): number {
return isDefault ? 1 : index + 2;
}
private buttonSortFunc_(
button1: HelpBubbleButtonParams,
button2: HelpBubbleButtonParams): number {
// Default button is leading on Windows, trailing on other platforms.
if (button1.isDefault) {
return isWindows ? -1 : 1;
}
if (button2.isDefault) {
return isWindows ? 1 : -1;
}
return 0;
}
private getArrowClass_(position: HelpBubblePosition): string {
switch (position) {
case HelpBubblePosition.ABOVE:
return 'above';
case HelpBubblePosition.BELOW:
return 'below';
case HelpBubblePosition.LEFT:
return 'left';
case HelpBubblePosition.RIGHT:
return 'right';
default:
assertNotReached('Unknown help bubble position: ' + position);
}
}
/**
* Sets the bubble position, as relative to that of the anchor element and
* |this.position|.
*/
private updatePosition_() {
assert(
this.anchorElement_, 'Update position: expected valid anchor element.');
// Inclusive of 8px visible arrow and 8px margin.
const parentRect = this.offsetParent!.getBoundingClientRect();
const anchorRect = this.anchorElement_.getBoundingClientRect();
const anchorLeft = anchorRect.left - parentRect.left;
const anchorHorizontalCenter = anchorLeft + anchorRect.width / 2;
const anchorTop = anchorRect.top - parentRect.top;
const ARROW_OFFSET = 16;
const LEFT_MARGIN = 8;
const HELP_BUBBLE_WIDTH = 362;
let helpLeft: string = '';
let helpTop: string = '';
switch (this.position) {
case HelpBubblePosition.ABOVE:
// Anchor the help bubble to the top center of the anchor element.
helpTop = `${anchorTop - ARROW_OFFSET}px`;
helpLeft = `${
Math.max(
LEFT_MARGIN,
anchorHorizontalCenter - HELP_BUBBLE_WIDTH / 2)}px`;
break;
case HelpBubblePosition.BELOW:
// Anchor the help bubble to the bottom center of the anchor element.
helpTop = `${anchorTop + anchorRect.height + ARROW_OFFSET}px`;
helpLeft = `${
Math.max(
LEFT_MARGIN,
anchorHorizontalCenter - HELP_BUBBLE_WIDTH / 2)}px`;
break;
case HelpBubblePosition.LEFT:
// Anchor the help bubble to the center left of the anchor element.
helpTop = `${anchorTop + anchorRect.height / 2}px`;
helpLeft = `${anchorLeft - ARROW_OFFSET}px`;
break;
case HelpBubblePosition.RIGHT:
// Anchor the help bubble to the center right of the anchor element.
helpTop = `${anchorTop + anchorRect.height / 2}px`;
helpLeft = `${anchorLeft + anchorRect.width + ARROW_OFFSET}px`;
break;
default:
assertNotReached();
}
this.style.top = helpTop;
this.style.left = helpLeft;
}
/**
* Styles the anchor element to appear highlighted while the bubble is open,
* or removes the highlight.
*/
private setAnchorHighlight_(highlight: boolean) {
assert(
this.anchorElement_,
'Set anchor highlight: expected valid anchor element.');
this.anchorElement_.classList.toggle(ANCHOR_HIGHLIGHT_CLASS, highlight);
}
}
customElements.define(HelpBubbleElement.is, HelpBubbleElement);
declare global {
interface HTMLElementTagNameMap {
'help-bubble': HelpBubbleElement;
}
interface HTMLElementEventMap {
[HELP_BUBBLE_DISMISSED_EVENT]: HelpBubbleDismissedEvent;
}
}