blob: 928624441e852d9d4e2d608c215dccb49854a87d [file]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// <if expr="is_ios">
import 'chrome://resources/js/ios/web_ui.js';
// </if>
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_tabs/cr_tabs.js';
import '/strings.m.js';
import './experiment.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {getDeepActiveElement} from 'chrome://resources/js/util.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {getCss} from './app.css.js';
import {getHtml} from './app.html.js';
import type {ExperimentElement as FlagsExperimentElement} from './experiment.js';
import type {ExperimentalFeaturesData, Feature} from './flags_browser_proxy.js';
import {FlagsBrowserProxyImpl} from './flags_browser_proxy.js';
/**
* Goes through all experiment text and highlights the relevant matches.
* Only the first instance of a match in each experiment text block is
* highlighted. This prevents the sea of yellow that happens using the
* global find in page search.
* @param experiments The list of elements to search on and highlight.
* @param searchTerm The query to search for.
* @return The number of matches found.
*/
async function highlightAllMatches(
experiments: NodeListOf<FlagsExperimentElement>,
searchTerm: string): Promise<number> {
let matches = 0;
// Not using for..of with async/await to spawn all searching in parallel.
await Promise.all(Array.from(experiments).map(async (experiment) => {
const hasMatch = await experiment.match(searchTerm);
matches += hasMatch ? 1 : 0;
experiment.hidden = !hasMatch;
}));
return matches;
}
/**
* Handles in page searching. Matches against the experiment flag name.
*/
class FlagSearch {
private flagsAppElement: FlagsAppElement;
private searchIntervalId: number|null = null;
// Delay in ms following a keypress, before a search is made.
private searchDebounceDelayMs: number = 150;
constructor(el: FlagsAppElement) {
this.flagsAppElement = el;
}
/**
* Performs a search against the experiment title, description, platforms and
* permalink text.
*/
async doSearch() {
await this.flagsAppElement.search();
this.searchIntervalId = null;
}
/**
* Debounces the search to improve performance and prevent too many searches
* from being initiated.
*/
debounceSearch() {
if (this.searchIntervalId) {
clearTimeout(this.searchIntervalId);
}
this.searchIntervalId =
setTimeout(this.doSearch.bind(this), this.searchDebounceDelayMs);
}
setSearchDebounceDelayMsForTesting(delay: number) {
this.searchDebounceDelayMs = delay;
}
}
export interface FlagsAppElement {
$: {
search: HTMLInputElement,
};
}
export class FlagsAppElement extends CrLitElement {
static get is() {
return 'flags-app';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
data: {type: Object},
defaultFeatures: {type: Array},
nonDefaultFeatures: {type: Array},
searching: {
type: Boolean,
reflect: true,
},
needsRestart: {
type: Boolean,
},
tabNames_: {type: Array},
selectedTabIndex_: {type: Number},
};
}
protected accessor tabNames_: string[] = [
loadTimeData.getString('available'),
// <if expr="not is_ios">
loadTimeData.getString('unavailable'),
// </if>
];
protected accessor selectedTabIndex_: number = 0;
protected accessor data: ExperimentalFeaturesData = {
supportedFeatures: [],
// <if expr="not is_ios">
unsupportedFeatures: [],
// </if>
needsRestart: false,
showBetaChannelPromotion: false,
showDevChannelPromotion: false,
// <if expr="is_chromeos">
showOwnerWarning: false,
// </if>
};
protected accessor defaultFeatures: Feature[] = [];
protected accessor nonDefaultFeatures: Feature[] = [];
protected accessor searching: boolean = false;
protected accessor needsRestart: boolean = false;
private announceStatusDelayMs: number = 100;
private featuresResolver: PromiseResolver<void> = new PromiseResolver();
private flagSearch: FlagSearch|null = null;
private lastChanged: HTMLElement|null = null;
// <if expr="not is_ios">
private lastFocused: HTMLElement|null = null;
// Whether the current URL is chrome://flags/deprecated. Only updated on
// initial load.
private isFlagsDeprecatedUrl_: boolean = false;
// </if>
private eventTracker_: EventTracker|null = null;
getRequiredElement<K extends keyof HTMLElementTagNameMap>(query: K):
HTMLElementTagNameMap[K];
getRequiredElement<E extends HTMLElement = HTMLElement>(query: string): E;
getRequiredElement(query: string) {
const el = this.shadowRoot.querySelector(query);
assert(el);
assert(el instanceof HTMLElement);
return el;
}
override connectedCallback() {
super.connectedCallback();
// <if expr="not is_ios">
const pathname = new URL(window.location.href).pathname;
this.isFlagsDeprecatedUrl_ =
['/deprecated', '/deprecated/test_loader.html'].includes(pathname);
// </if>
// Get and display the data upon loading.
this.requestExperimentalFeaturesData();
FocusOutlineManager.forDocument(document);
// <if expr="not is_ios">
if (this.isFlagsDeprecatedUrl_) {
// Update strings that are slightly different when on
// chrome://flags/deprecated
document.title = loadTimeData.getString('deprecatedTitle');
this.getRequiredElement('.section-header-title').textContent =
loadTimeData.getString('deprecatedHeading');
this.getRequiredElement('.blurb-warning').textContent = '';
this.getRequiredElement('.blurb-warning + span').textContent =
loadTimeData.getString('deprecatedPageWarningExplanation');
this.$.search.placeholder =
loadTimeData.getString('deprecatedSearchPlaceholder');
for (const element of this.shadowRoot.querySelectorAll('.no-match')) {
element.textContent = loadTimeData.getString('deprecatedNoResults');
}
}
// </if>
this.eventTracker_ = new EventTracker();
// Update the highlighted flag when the hash changes.
this.eventTracker_.add(
window, 'hashchange', () => this.highlightReferencedFlag());
this.eventTracker_.add(window, 'keyup', (e: KeyboardEvent) => {
// Check for an active input field inside a <flags-experiment>.
const activeElement = getDeepActiveElement();
const isTextInput = activeElement?.nodeName &&
['TEXTAREA', 'INPUT'].includes(activeElement.nodeName);
if (e.key === '/' && isTextInput) {
return;
}
switch (e.key) {
case '/':
this.$.search.focus();
break;
case 'Escape':
this.$.search.blur();
break;
default:
break;
}
});
}
override disconnectedCallback() {
super.disconnectedCallback();
assert(this.eventTracker_);
this.eventTracker_.removeAll();
this.eventTracker_ = null;
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedPrivateProperties.has('data')) {
const defaultFeatures: Feature[] = [];
const nonDefaultFeatures: Feature[] = [];
this.data.supportedFeatures.forEach(
f => (f.is_default ? defaultFeatures : nonDefaultFeatures).push(f));
this.defaultFeatures = defaultFeatures;
this.nonDefaultFeatures = nonDefaultFeatures;
// Maintain 'true' state if it was previously set, as
// `this.data.needsRestart` only matters on page load.
this.needsRestart = this.needsRestart || this.data.needsRestart;
}
}
override firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this.flagSearch = new FlagSearch(this);
this.$.search.focus();
}
override async updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (this.defaultFeatures.length === 0 &&
// <if expr="not is_ios">
this.data.unsupportedFeatures.length === 0 &&
// </if>
this.nonDefaultFeatures.length === 0) {
// Return early if this update corresponds to the initial dummy data, to
// avoid triggering `featuresResolver` prematurely in tests.
return;
}
await this.highlightReferencedFlag();
this.featuresResolver.resolve();
}
// <if expr="not is_ios">
private getRestartButton(): HTMLButtonElement {
return this.getRequiredElement<HTMLButtonElement>(
'#experiment-restart-button');
}
// </if>
setAnnounceStatusDelayMsForTesting(delay: number) {
this.announceStatusDelayMs = delay;
}
setSearchDebounceDelayMsForTesting(delay: number) {
assert(this.flagSearch);
this.flagSearch.setSearchDebounceDelayMsForTesting(delay);
}
experimentalFeaturesReadyForTesting() {
return this.featuresResolver.promise;
}
/**
* Cause a text string to be announced by screen readers
* @param text The text that should be announced.
*/
announceStatus(text: string): Promise<void> {
return new Promise((resolve) => {
this.getRequiredElement('#screen-reader-status-message').textContent = '';
setTimeout(() => {
this.getRequiredElement('#screen-reader-status-message').textContent =
text;
resolve();
}, this.announceStatusDelayMs);
});
}
async search() {
const searchTerm = this.$.search.value.trim().toLowerCase();
this.searching = Boolean(searchTerm);
// Available experiments
const availableExperiments =
this.shadowRoot.querySelectorAll<FlagsExperimentElement>(
'#tab-content-available flags-experiment');
const availableExperimentsHits =
await highlightAllMatches(availableExperiments, searchTerm);
let noMatchMsg =
this.getRequiredElement('#tab-content-available .no-match');
noMatchMsg.toggleAttribute('hidden', availableExperimentsHits > 0);
// <if expr="not is_ios">
// Unavailable experiments, which are undefined on iOS.
const unavailableExperiments =
this.shadowRoot.querySelectorAll<FlagsExperimentElement>(
'#tab-content-unavailable flags-experiment');
const unavailableExperimentsHits =
await highlightAllMatches(unavailableExperiments, searchTerm);
noMatchMsg = this.getRequiredElement('#tab-content-unavailable .no-match');
noMatchMsg.toggleAttribute('hidden', unavailableExperimentsHits > 0);
// </if>
if (this.searching) {
// <if expr="is_ios">
await this.announceSearchResults(searchTerm, availableExperimentsHits);
// </if>
// <if expr="not is_ios">
const hits = this.selectedTabIndex_ === 0 ? availableExperimentsHits :
unavailableExperimentsHits;
await this.announceSearchResults(searchTerm, hits);
// </if>
}
await this.updateComplete;
this.dispatchEvent(new Event('search-finished-for-testing', {
bubbles: true,
composed: true,
}));
}
private announceSearchResults(searchTerm: string, total: number):
Promise<void> {
if (total) {
return this.announceStatus(
total === 1 ?
loadTimeData.getStringF('searchResultsSingular', searchTerm) :
loadTimeData.getStringF(
'searchResultsPlural', total, searchTerm));
}
return Promise.resolve();
}
/*
* Focus restart button if a previous focus target has been set and
* tab key pressed.
*/
protected onResetAllKeydown_(e: KeyboardEvent) {
if (this.lastChanged && e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
// <if expr="not is_ios">
this.lastFocused = this.lastChanged;
this.getRestartButton().focus();
// </if>
}
}
protected onResetAllBlur_() {
this.lastChanged = null;
}
/**
* Highlight an element associated with the page's location's hash. We need to
* fake fragment navigation with '.scrollIntoView()', since the fragment IDs
* don't actually exist until after the template code runs; normal navigation
* therefore doesn't work.
*/
// eslint-disable-next-line @typescript-eslint/require-await
private async highlightReferencedFlag() {
if (!window.location.hash) {
return;
}
let experiment = null;
try {
experiment = this.shadowRoot.querySelector(window.location.hash);
} catch {
// Remove invalid hash from the URL.
window.history.replaceState(null, '', window.location.origin);
return;
}
if (!experiment || experiment.classList.contains('referenced')) {
return;
}
// Unhighlight whatever's highlighted.
const previous = this.shadowRoot.querySelector('.referenced');
if (previous) {
previous.classList.remove('referenced');
}
// Highlight the referenced element.
experiment.classList.add('referenced');
// <if expr="not is_ios">
// Switch to unavailable tab if the flag is in this section.
if (this.getRequiredElement('#tab-content-unavailable')
.contains(experiment)) {
this.selectedTabIndex_ = 1;
await this.updateComplete;
await this.getRequiredElement('cr-tabs').updateComplete;
}
// </if>
experiment.scrollIntoView();
}
/**
* Gets details and configuration about the available features.
*/
private async requestExperimentalFeaturesData() {
// <if expr="not is_ios">
const data = this.isFlagsDeprecatedUrl_ ?
await FlagsBrowserProxyImpl.getInstance().requestDeprecatedFeatures() :
await FlagsBrowserProxyImpl.getInstance().requestExperimentalFeatures();
// </if>
// <if expr="is_ios">
const data =
await FlagsBrowserProxyImpl.getInstance().requestExperimentalFeatures();
// </if>
this.data = data;
}
/**
* Clears a search showing all experiments.
*/
private clearSearch() {
this.$.search.value = '';
assert(this.flagSearch);
this.flagSearch.doSearch();
this.$.search.focus();
}
/** Reset all flags to their default values and refresh the UI. */
protected async onResetAllClick_(e: Event) {
this.lastChanged = e.target as HTMLElement;
FlagsBrowserProxyImpl.getInstance().resetAllFlags();
this.announceStatus(loadTimeData.getString('reset-acknowledged'));
this.needsRestart = true;
await this.requestExperimentalFeaturesData();
await this.updateComplete;
this.clearSearch();
}
protected onSearchInput_() {
assert(this.flagSearch);
this.flagSearch.debounceSearch();
}
protected onClearSearchClick_() {
this.clearSearch();
}
protected onSelectChange_(e: Event) {
const select = e.composedPath()[0];
assert(select instanceof HTMLSelectElement);
this.needsRestart = true;
if (this.lastChanged === select) {
return;
}
this.lastChanged = select;
// Add listeners so that next 'Tab' keystroke focuses the restart button.
const eventTracker = new EventTracker();
eventTracker.add(select, 'keydown', (e: KeyboardEvent) => {
if (e.key === 'Tab' && !e.shiftKey) {
assert(this.lastChanged === select);
e.preventDefault();
// <if expr="not is_ios">
this.lastFocused = this.lastChanged;
this.getRestartButton().focus();
// </if>
}
});
// Remove listeners that were special handling the "Tab" keystroke.
eventTracker.add(select, 'blur', () => {
assert(this.lastChanged === select);
this.lastChanged = null;
eventTracker.removeAll();
});
}
protected onTextareaChange_() {
this.needsRestart = true;
}
protected onInputChange_() {
this.needsRestart = true;
}
// <if expr="not is_ios">
protected onRestartButtonClick_() {
FlagsBrowserProxyImpl.getInstance().restartBrowser();
}
// </if>
protected getNeedsRestartRole_(): string {
return this.needsRestart ? 'alert' : 'none';
}
protected shouldShowPromos_(): boolean {
return this.data.showBetaChannelPromotion ||
this.data.showDevChannelPromotion;
}
protected onTabsSelectedChanged_(e: CustomEvent<{value: number}>) {
this.selectedTabIndex_ = e.detail.value;
}
protected isTabSelected_(index: number): boolean {
return index === this.selectedTabIndex_;
}
// <if expr="not is_ios">
/**
* Allows the restart button to jump back to the previously focused experiment
* in the list instead of going to the top of the page.
*/
protected onRestartButtonKeydown_(e: KeyboardEvent) {
if (e.shiftKey && e.key === 'Tab' && this.lastFocused) {
e.preventDefault();
this.lastFocused.focus();
}
}
protected onRestartButtonBlur_() {
this.lastFocused = null;
}
// </if>
}
declare global {
interface HTMLElementTagNameMap {
'flags-app': FlagsAppElement;
}
}
// Exported as AppElement to be used by the auto-generated .html.ts file.
export type AppElement = FlagsAppElement;
customElements.define(FlagsAppElement.is, FlagsAppElement);