chromium/ash/webui/common/resources/cr_elements/localized_link/localized_link.ts

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

/**
 * @fileoverview 'localized-link' takes a localized string that
 * contains up to one anchor tag, and labels the string contained within the
 * anchor tag with the entire localized string. The string should not be bound
 * by element tags. The string should not contain any elements other than the
 * single anchor tagged element that will be aria-labelledby the entire string.
 *
 * Example: "lorem ipsum <a href="example.com">Learn More</a> dolor sit"
 *
 * The "Learn More" will be aria-labelledby like so: "lorem ipsum Learn More
 * dolor sit". Meanwhile, "Lorem ipsum" and "dolor sit" will be aria-hidden.
 *
 * This element also supports strings that do not contain anchor tags; in this
 * case, the element gracefully falls back to normal text. This can be useful
 * when the property is data-bound to a function which sometimes returns a
 * string with a link and sometimes returns a normal string.
 *
 * Forked from ui/webui/resources/ash/common/cr_elements/localized_link/localized_link.ts
 */

import '../cr_shared_vars.css.js';
import '../cr_shared_style.css.js';

import {assert, assertNotReached} from '//resources/js/assert.js';
import {sanitizeInnerHtml} from '//resources/js/parse_html_subset.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

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

export interface LocalizedLinkElement {
  $: {
    container: HTMLElement,
  };
}

export class LocalizedLinkElement extends PolymerElement {
  static get is() {
    return 'localized-link';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * The localized string that contains up to one anchor tag, the text
       * within which will be aria-labelledby the entire localizedString.
       */
      localizedString: String,

      /**
       * If provided, the URL that the anchor tag will point to. There is no
       * need to provide a linkUrl if the URL is embedded in the
       * localizedString.
       */
      linkUrl: {
        type: String,
        value: '',
      },

      /**
       * If true, localized link will be disabled.
       */
      linkDisabled: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
        observer: 'updateAnchorTagTabIndex_',
      },

      /**
       * localizedString, with aria attributes and the optionally provided link.
       */
      containerInnerHTML_: {
        type: String,
        value: '',
        computed: 'getAriaLabelledContent_(localizedString, linkUrl)',
        observer: 'setContainerInnerHtml_',
      },
    };
  }

  localizedString: string;
  linkUrl: string;
  linkDisabled: boolean;
  private containerInnerHTML_: string;

  /**
   * Attaches aria attributes and optionally provided link to the provided
   * localizedString.
   * @return localizedString formatted with additional ids, spans, and an
   *     aria-labelledby tag
   */
  private getAriaLabelledContent_(localizedString: string, linkUrl: string):
      string {
    const tempEl = document.createElement('div');
    tempEl.innerHTML = sanitizeInnerHtml(localizedString, {attrs: ['id']});

    const ariaLabelledByIds: string[] = [];
    tempEl.childNodes.forEach((node, index) => {
      // Text nodes should be aria-hidden and associated with an element id
      // that the anchor element can be aria-labelledby.
      if (node.nodeType === Node.TEXT_NODE) {
        const spanNode = document.createElement('span');
        spanNode.textContent = node.textContent;
        spanNode.id = `id${index}`;
        ariaLabelledByIds.push(spanNode.id);
        spanNode.setAttribute('aria-hidden', 'true');
        node.replaceWith(spanNode);
        return;
      }
      // The single element node with anchor tags should also be aria-labelledby
      // itself in-order with respect to the entire string.
      if (node.nodeType === Node.ELEMENT_NODE && node.nodeName === 'A') {
        const element = node as HTMLAnchorElement;
        element.id = `id${index}`;
        ariaLabelledByIds.push(element.id);
        return;
      }

      // Only text and <a> nodes are allowed.
      assertNotReached('localized-link has invalid node types');
    });

    const anchorTags = tempEl.querySelectorAll('a');
    // In the event the provided localizedString contains only text nodes,
    // populate the contents with the provided localizedString.
    if (anchorTags.length === 0) {
      return localizedString;
    }

    assert(
        anchorTags.length === 1,
        'localized-link should contain exactly one anchor tag');
    const anchorTag = anchorTags[0]!;
    anchorTag.setAttribute('aria-labelledby', ariaLabelledByIds.join(' '));
    anchorTag.tabIndex = this.linkDisabled ? -1 : 0;

    if (linkUrl !== '') {
      anchorTag.href = linkUrl;
      anchorTag.target = '_blank';
    }

    return tempEl.innerHTML;
  }

  private setContainerInnerHtml_() {
    this.$.container.innerHTML = sanitizeInnerHtml(this.containerInnerHTML_, {
      attrs: [
        'aria-hidden',
        'aria-labelledby',
        'id',
        'tabindex',
      ],
    });
    const anchorTag = this.shadowRoot!.querySelector('a');
    if (anchorTag) {
      anchorTag.addEventListener(
          'click', (event) => this.onAnchorTagClick_(event));
      anchorTag.addEventListener('auxclick', (event) => {
        // trigger the click handler on middle-button clicks
        if (event.button === 1) {
          this.onAnchorTagClick_(event);
        }
      });
    }
  }

  private onAnchorTagClick_(event: Event) {
    if (this.linkDisabled) {
      event.preventDefault();
      return;
    }
    this.dispatchEvent(new CustomEvent(
        'link-clicked', {bubbles: true, composed: true, detail: {event}}));
    // Stop propagation of the event, since it has already been handled by
    // opening the link.
    event.stopPropagation();
  }

  /**
   *  Removes anchor tag from being targeted by chromeVox when link is
   *  disabled.
   */
  private updateAnchorTagTabIndex_() {
    const anchorTag = this.shadowRoot!.querySelector('a');
    if (!anchorTag) {
      return;
    }
    anchorTag.tabIndex = this.linkDisabled ? -1 : 0;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'localized-link': LocalizedLinkElement;
  }
}

customElements.define(LocalizedLinkElement.is, LocalizedLinkElement);