0

WebUI: Fork localized_link for Ash

Since localized_link depends on cr_elements/ and is used by multiple UIs
on both Desktop and Ash, fork it for Ash WebUI.

Forking into ash/webui/common/resources/cr_elements/, since
localized_link does not have any $i18n{} or loadTimeData use, so does
not actually need to be a cr_component.

Bug: 1512231
Change-Id: I8cdfe36db3b1a02274d3bc55590c01cdf2ca8138
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5239069
Commit-Queue: Rebekah Potter <rbpotter@chromium.org>
Reviewed-by: Jimmy Gong <jimmyxgong@chromium.org>
Reviewed-by: Demetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1252939}
This commit is contained in:
rbpotter
2024-01-27 00:08:51 +00:00
committed by Chromium LUCI CQ
parent 90c05a47db
commit 45f89f2663
10 changed files with 456 additions and 4 deletions
ash/webui/common/resources/cr_elements
chrome/test/data/webui/chromeos/ash_common/cr_elements
tools/typescript
ui/webui/resources/cr_components/settings_prefs

@ -38,6 +38,7 @@ build_webui("build") {
"cr_toolbar/cr_toolbar_search_field.ts",
"cr_toolbar/cr_toolbar_selection_overlay.ts",
"cr_view_manager/cr_view_manager.ts",
"localized_link/localized_link.ts",
"policy/cr_policy_indicator.ts",
"policy/cr_policy_pref_indicator.ts",
"policy/cr_tooltip_icon.ts",

@ -0,0 +1,16 @@
// 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.
interface LocalizedLinkElement extends HTMLElement {
title: string;
body: string;
}
export {LocalizedLinkElement};
declare global {
interface HTMLElementTagNameMap {
'localized-link': LocalizedLinkElement;
}
}

@ -0,0 +1,34 @@
<style include="cr-shared-style">
:host {
--cr-localized-link-display: inline;
display: block;
}
:host([link-disabled]) {
cursor: pointer;
opacity: var(--cr-disabled-opacity);
pointer-events: none;
}
a {
display: var(--cr-localized-link-display);
}
a[href] {
color: var(--cr-link-color);
}
/**
* Prevent action-links from being selected to avoid accidental
* selection when trying to click it.
*/
a[is=action-link] {
user-select: none;
}
#container {
display: contents;
}
</style>
<!-- innerHTML is set via setContainerInnerHtml_. -->
<div id="container"></div>

@ -0,0 +1,206 @@
// 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/cr_components/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);

@ -0,0 +1,17 @@
// 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.
/* Minimal externs file provided for places in the code that
* still use JavaScript instead of TypeScript.
* @externs
*/
/**
* @constructor
* @extends {HTMLElement}
*/
function LocalizedLinkElement() {}
/** @type {string} */
LocalizedLinkElement.prototype.localizedString;

@ -45,6 +45,7 @@ build_webui_tests("build") {
"find_shortcut_mixin_test.ts",
"i18n_mixin_test.ts",
"list_property_update_mixin_test.ts",
"localized_link_test.ts",
"store_client_test.ts",
"web_ui_listener_mixin_test.ts",
]

@ -132,6 +132,11 @@ IN_PROC_BROWSER_TEST_F(AshCommonCrElementsTest, I18nMixin) {
RunTest("chromeos/ash_common/cr_elements/i18n_mixin_test.js", "mocha.run()");
}
IN_PROC_BROWSER_TEST_F(AshCommonCrElementsTest, LocalizedLink) {
RunTest("chromeos/ash_common/cr_elements/localized_link_test.js",
"mocha.run()");
}
IN_PROC_BROWSER_TEST_F(AshCommonCrElementsTest, ListPropertyUpdateMixin) {
RunTest("chromeos/ash_common/cr_elements/list_property_update_mixin_test.js",
"mocha.run()");

@ -0,0 +1,166 @@
// 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.
import '//resources/ash/common/cr_elements/localized_link/localized_link.js';
import {LocalizedLinkElement} from '//resources/ash/common/cr_elements/localized_link/localized_link.js';
import {assertEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
import {flushTasks} from 'chrome://webui-test/polymer_test_util.js';
import {eventToPromise} from 'chrome://webui-test/test_util.js';
import {getTrustedHtml} from 'chrome://webui-test/trusted_html.js';
suite('localized_link', function() {
let localizedStringWithLink: LocalizedLinkElement|null;
function getLocalizedStringWithLinkElementHtml(
localizedString: string, linkUrl: string): TrustedHTML {
return getTrustedHtml(
`<localized-link localized-string="${localizedString}"` +
` link-url="${linkUrl}"></localized-link>`);
}
test('LinkFirst', function() {
document.body.innerHTML =
getLocalizedStringWithLinkElementHtml(`<a>first link</a>then text`, ``);
localizedStringWithLink = document.body.querySelector('localized-link');
assertTrue(!!localizedStringWithLink);
assertEquals(
localizedStringWithLink.$.container.innerHTML,
`<a id="id0" aria-labelledby="id0 id1" tabindex="0">first link</a>` +
`<span id="id1" aria-hidden="true">then text</span>`);
});
test('TextLinkText', function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`first text <a>then link</a> then more text`, ``);
localizedStringWithLink = document.body.querySelector('localized-link');
assertTrue(!!localizedStringWithLink);
assertEquals(
localizedStringWithLink.$.container.innerHTML,
`<span id="id0" aria-hidden="true">first text </span>` +
`<a id="id1" aria-labelledby="id0 id1 id2" tabindex="0">then link</a>` +
`<span id="id2" aria-hidden="true"> then more text</span>`);
});
test('LinkLast', function() {
document.body.innerHTML =
getLocalizedStringWithLinkElementHtml(`first text<a>then link</a>`, ``);
localizedStringWithLink = document.body.querySelector('localized-link');
assertTrue(!!localizedStringWithLink);
assertEquals(
localizedStringWithLink.$.container.innerHTML,
`<span id="id0" aria-hidden="true">first text</span>` +
`<a id="id1" aria-labelledby="id0 id1" tabindex="0">then link</a>`);
});
test('PopulatedLink', function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`<a>populated link</a>`, `https://google.com`);
localizedStringWithLink = document.body.querySelector('localized-link');
assertTrue(!!localizedStringWithLink);
assertEquals(
localizedStringWithLink.$.container.innerHTML,
`<a id="id0" aria-labelledby="id0" tabindex="0" ` +
`href="https://google.com" target="_blank">populated link</a>`);
});
test('PrepopulatedLink', function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`<a href='https://google.com'>pre-populated link</a>`, ``);
localizedStringWithLink = document.body.querySelector('localized-link');
assertTrue(!!localizedStringWithLink);
assertEquals(
localizedStringWithLink.$.container.innerHTML,
`<a href="https://google.com" id="id0" aria-labelledby="id0" tabindex="0">` +
`pre-populated link</a>`);
});
test('NoLinkPresent', function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`No anchor tags in this sentence.`, ``);
localizedStringWithLink = document.body.querySelector('localized-link');
assertTrue(!!localizedStringWithLink);
assertEquals(
localizedStringWithLink.$.container.innerHTML,
`No anchor tags in this sentence.`);
});
test('LinkClick', function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`Text with a <a href='#'>link</a>`, ``);
return flushTasks().then(async () => {
const localizedLink = document.body.querySelector('localized-link');
assertTrue(!!localizedLink);
const anchorTag = localizedLink.shadowRoot!.querySelector('a');
assertTrue(!!anchorTag);
const localizedLinkPromise =
eventToPromise('link-clicked', localizedLink);
anchorTag.click();
await Promise.all([localizedLinkPromise, flushTasks()]);
});
});
test('LinkAuxclick', function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`Text with a <a href='#'>link</a>`, ``);
return flushTasks().then(async () => {
const localizedLink = document.body.querySelector('localized-link');
assertTrue(!!localizedLink);
const anchorTag = localizedLink.shadowRoot!.querySelector('a');
assertTrue(!!anchorTag);
const localizedLinkPromise =
eventToPromise('link-clicked', localizedLink);
// simulate a middle-button click
anchorTag.dispatchEvent(new MouseEvent('auxclick', {button: 1}));
await Promise.all([localizedLinkPromise, flushTasks()]);
});
});
test('link disabled', async function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`Text with a <a href='#'>link</a>`, ``);
await flushTasks();
const localizedLink = document.body.querySelector('localized-link');
assertTrue(!!localizedLink);
const anchorTag = localizedLink.shadowRoot!.querySelector('a');
assertTrue(!!anchorTag);
assertEquals(anchorTag.getAttribute('tabindex'), '0');
localizedLink.linkDisabled = true;
await flushTasks();
assertEquals(anchorTag.getAttribute('tabindex'), '-1');
});
test('change localizedString', async function() {
document.body.innerHTML = getLocalizedStringWithLinkElementHtml(
`Text with a <a href='#'>link</a>`, ``);
await flushTasks();
const localizedLink = document.body.querySelector('localized-link');
assertTrue(!!localizedLink);
localizedLink.linkDisabled = true;
const localizedLinkPromise = eventToPromise('link-clicked', localizedLink);
await flushTasks();
localizedLink.localizedString = `Different text with <a href='#'>link</a>`;
await flushTasks();
// Tab index is still -1 due to it being disabled.
const anchorTag = localizedLink.shadowRoot!.querySelector('a');
assertTrue(!!anchorTag);
assertEquals(anchorTag.getAttribute('tabindex'), '-1');
localizedLink.linkDisabled = false;
await flushTasks();
// Clicking the link still fires the link-clicked event.
anchorTag.click();
await Promise.all([localizedLinkPromise, flushTasks()]);
});
});

@ -214,11 +214,15 @@ def isMigratedAshFolder(path):
def isBrowserOnlyDep(dep):
return dep == '//ui/webui/resources/cr_elements'
browser_only_deps = [
'//ui/webui/resources/cr_elements',
'//ui/webui/resources/cr_components/localized_link',
]
return any(dep.startswith(dep_folder) for dep_folder in browser_only_deps)
def isDependencyAllowed(is_ash_target, raw_dep, target_path):
if isMigratedAshFolder(target_path) and \
raw_dep.startswith('//ui/webui/resources/cr_elements'):
if isMigratedAshFolder(target_path) and isBrowserOnlyDep(raw_dep):
return False
is_ash_dep = isInAshFolder(raw_dep[2:])

@ -16,9 +16,11 @@ build_webui("build") {
ts_definitions = [ "//tools/typescript/definitions/settings_private.d.ts" ]
ts_composite = true
# Note: Do not add a dep on ui/webui/resources/cr_elements:build_ts here, as
# this component is also used by OS Settings.
ts_deps = [
"//third_party/polymer/v3_0:library",
"//ui/webui/resources/cr_elements:build_ts",
"//ui/webui/resources/js:build_ts",
]