// 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 './strings.m.js';
import './experiment.js';
import {assert} from 'chrome://resources/js/assert.js';
import {CustomElement} from 'chrome://resources/js/custom_element.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 {getTemplate} from './app.html.js';
import type {FlagsExperimentElement} from './experiment.js';
import type {ExperimentalFeaturesData, Feature} from './flags_browser_proxy.js';
import {FlagsBrowserProxyImpl} from './flags_browser_proxy.js';
interface Tab {
tabEl: HTMLElement;
panelEl: HTMLElement;
}
/**
* Handles in page searching. Matches against the experiment flag name.
*/
export class FlagSearch {
private flagsAppElement: FlagsAppElement;
private initialized: boolean = false;
private noMatchMsg: NodeListOf<HTMLElement>;
private searchIntervalId: number|null = null;
private searchBox: HTMLInputElement;
// Delay in ms following a keypress, before a search is made.
private searchDebounceDelayMs: number = 150;
constructor(el: FlagsAppElement) {
this.flagsAppElement = el;
this.searchBox =
this.flagsAppElement.getRequiredElement<HTMLInputElement>('#search');
this.noMatchMsg = this.flagsAppElement.$all('.tab-content .no-match');
}
/**
* Initialises the in page search. Adds searchbox listeners and
* collates the text elements used for string matching.
*/
init() {
if (this.initialized) {
return;
}
this.searchBox.addEventListener('input', this.debounceSearch.bind(this));
this.flagsAppElement.getRequiredElement<HTMLInputElement>('.clear-search')
.addEventListener('click', this.clearSearch.bind(this));
window.addEventListener('keyup', e => {
// Check for an active textarea inside a <flags-experiment>.
const activeElement = getDeepActiveElement();
if (activeElement && activeElement.nodeName === 'TEXTAREA') {
return;
}
switch (e.key) {
case '/':
this.searchBox.focus();
break;
case 'Escape':
this.searchBox.blur();
break;
}
});
this.searchBox.focus();
this.initialized = true;
}
/**
* Clears a search showing all experiments.
*/
clearSearch() {
this.searchBox.value = '';
this.doSearch();
this.searchBox.focus();
}
/**
* 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.
*/
private highlightAllMatches(
experiments: NodeListOf<FlagsExperimentElement>,
searchTerm: string): number {
let matches = 0;
for (const experiment of experiments) {
const hasMatch = experiment.match(searchTerm);
matches += hasMatch ? 1 : 0;
experiment.classList.toggle('hidden', !hasMatch);
}
return matches;
}
/**
* Performs a search against the experiment title, description, permalink.
*/
async doSearch() {
const searchTerm = this.searchBox.value.trim().toLowerCase();
if (searchTerm || searchTerm === '') {
this.flagsAppElement.classList.toggle('searching', Boolean(searchTerm));
// Available experiments
const availableExperiments =
this.flagsAppElement.$all<FlagsExperimentElement>(
'#tab-content-available flags-experiment');
assert(this.noMatchMsg[0]);
this.noMatchMsg[0].classList.toggle(
'hidden',
this.highlightAllMatches(availableExperiments, searchTerm) > 0);
// <if expr="not is_ios">
// Unavailable experiments, which are undefined on iOS.
const unavailableExperiments =
this.flagsAppElement.$all<FlagsExperimentElement>(
'#tab-content-unavailable flags-experiment');
assert(this.noMatchMsg[1]);
this.noMatchMsg[1].classList.toggle(
'hidden',
this.highlightAllMatches(unavailableExperiments, searchTerm) > 0);
// </if>
await this.announceSearchResults();
this.flagsAppElement.dispatchEvent(
new Event('search-finished-for-testing', {
bubbles: true,
composed: true,
}));
}
this.searchIntervalId = null;
}
private announceSearchResults(): Promise<void> {
const searchTerm = this.searchBox.value.trim().toLowerCase();
if (!searchTerm) {
return Promise.resolve();
}
const selectedTab = this.flagsAppElement.tabs.find(
tab => tab.panelEl.classList.contains('selected'))!;
const selectedTabId = selectedTab.panelEl.id;
const queryString = `#${selectedTabId} flags-experiment:not(.hidden)`;
const total = this.flagsAppElement.$all(queryString).length;
if (total) {
return this.flagsAppElement.announceStatus(
total === 1 ?
loadTimeData.getStringF('searchResultsSingular', searchTerm) :
loadTimeData.getStringF(
'searchResultsPlural', total, searchTerm));
}
return Promise.resolve();
}
/**
* 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 class FlagsAppElement extends CustomElement {
static get is() {
return 'flags-app';
}
static override get template() {
return getTemplate();
}
private announceStatusDelayMs: number = 100;
private featuresResolver: PromiseResolver<void> = new PromiseResolver();
private flagSearch: FlagSearch = new FlagSearch(this);
private lastChanged: HTMLElement|null = null;
// <if expr="not is_ios">
private lastFocused: HTMLElement|null = null;
private restartButton: HTMLButtonElement =
this.getRequiredElement<HTMLButtonElement>('#experiment-restart-button');
// Whether the current URL is chrome://flags/deprecated. Only updated on
// initial load.
private isFlagsDeprecatedUrl_: boolean = false;
// </if>
tabs: Tab[] = [
{
tabEl: this.getRequiredElement('#tab-available'),
panelEl: this.getRequiredElement('#tab-content-available'),
},
// <if expr="not is_ios">
{
tabEl: this.getRequiredElement('#tab-unavailable'),
panelEl: this.getRequiredElement('#tab-content-unavailable'),
},
// </if>
];
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();
// There is no restart button on iOS.
// <if expr="not is_ios">
this.setupRestartButton();
// </if>
FocusOutlineManager.forDocument(document);
// Update the highlighted flag when the hash changes.
window.addEventListener('hashchange', () => this.highlightReferencedFlag);
// <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.getRequiredElement<HTMLInputElement>('#search').placeholder =
loadTimeData.getString('deprecatedSearchPlaceholder');
for (const element of this.$all('.no-match')) {
element.textContent = loadTimeData.getString('deprecatedNoResults');
}
}
// </if>
}
setAnnounceStatusDelayMsForTesting(delay: number) {
this.announceStatusDelayMs = delay;
}
setSearchDebounceDelayMsForTesting(delay: number) {
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);
});
}
/**
* Toggles necessary attributes to display selected tab.
*/
private selectTab(selectedTabEl: HTMLElement) {
for (const tab of this.tabs) {
const isSelectedTab = tab.tabEl === selectedTabEl;
tab.tabEl.classList.toggle('selected', isSelectedTab);
tab.tabEl.setAttribute('aria-selected', String(isSelectedTab));
tab.panelEl.classList.toggle('selected', isSelectedTab);
}
}
/**
* Takes the |experimentalFeaturesData| input argument which represents data
* about all the current feature entries and populates the page with
* that data. It expects an object structure like the above.
* @param experimentalFeaturesData Information about all experiments.
*/
private render(experimentalFeaturesData: ExperimentalFeaturesData) {
const defaultFeatures: Feature[] = [];
const nonDefaultFeatures: Feature[] = [];
experimentalFeaturesData.supportedFeatures.forEach(
f => (f.is_default ? defaultFeatures : nonDefaultFeatures).push(f));
this.renderExperiments(
nonDefaultFeatures,
this.getRequiredElement('#non-default-experiments'));
this.renderExperiments(
defaultFeatures, this.getRequiredElement('#default-experiments'));
// <if expr="not is_ios">
this.renderExperiments(
experimentalFeaturesData.unsupportedFeatures,
this.getRequiredElement('#unavailable-experiments'), true);
// </if>
this.showRestartToast(experimentalFeaturesData.needsRestart);
// <if expr="not is_ios">
this.restartButton.onclick = () =>
FlagsBrowserProxyImpl.getInstance().restartBrowser();
// </if>
// Tab panel selection.
for (const tab of this.tabs) {
tab.tabEl.addEventListener('click', e => {
e.preventDefault();
this.selectTab(tab.tabEl);
});
}
const resetAllButton =
this.getRequiredElement<HTMLButtonElement>('#experiment-reset-all');
resetAllButton.onclick = () => {
this.resetAllFlags();
this.lastChanged = resetAllButton;
};
this.registerFocusEvents(resetAllButton);
// <if expr="is_chromeos">
const crosUrlFlagsRedirectButton =
this.getRequiredElement<HTMLAnchorElement>('#os-link-href');
if (crosUrlFlagsRedirectButton) {
crosUrlFlagsRedirectButton.onclick =
FlagsBrowserProxyImpl.getInstance().crosUrlFlagsRedirect;
}
// </if>
this.highlightReferencedFlag();
}
/**
* Add events to an element in order to keep track of the last focused
* element. Focus restart button if a previous focus target has been set and
* tab key pressed.
*/
private registerFocusEvents(el: HTMLElement) {
el.addEventListener('keydown', e => {
if (this.lastChanged && e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
// <if expr="not is_ios">
this.lastFocused = this.lastChanged;
this.restartButton.focus();
// </if>
}
});
el.addEventListener('blur', () => {
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.
*/
private highlightReferencedFlag() {
if (!window.location.hash) {
return;
}
const experiment = this.shadowRoot!.querySelector(window.location.hash);
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.selectTab(this.getRequiredElement('#tab-unavailable'));
}
// </if>
experiment.scrollIntoView();
}
/**
* Gets details and configuration about the available features. The
* |returnExperimentalFeatures()| will be called with reply.
*/
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.returnExperimentalFeatures(data);
}
/** Reset all flags to their default values and refresh the UI. */
private resetAllFlags() {
FlagsBrowserProxyImpl.getInstance().resetAllFlags();
this.flagSearch.clearSearch();
this.announceStatus(loadTimeData.getString('reset-acknowledged'));
this.showRestartToast(true);
this.requestExperimentalFeaturesData();
}
private renderExperiments(
features: Feature[], container: HTMLElement, unsupported = false) {
const fragment = document.createDocumentFragment();
for (const feature of features) {
const experiment = document.createElement('flags-experiment');
experiment.toggleAttribute('unsupported', unsupported);
experiment.data = feature;
experiment.id = feature.internal_name;
const select = experiment.getSelect();
if (select) {
experiment.addEventListener('select-change', e => {
e.preventDefault();
this.showRestartToast(true);
this.lastChanged = select;
});
this.registerFocusEvents(select);
}
const textarea = experiment.getTextarea();
if (textarea) {
experiment.addEventListener('textarea-change', e => {
e.preventDefault();
this.showRestartToast(true);
});
}
const textbox = experiment.getTextbox();
if (textbox) {
experiment.addEventListener('input-change', e => {
e.preventDefault();
this.showRestartToast(true);
});
}
fragment.appendChild(experiment);
}
container.replaceChildren(fragment);
}
/**
* Show the restart toast.
* @param show Setting to toggle showing / hiding the toast.
*/
private showRestartToast(show: boolean) {
this.getRequiredElement('#needs-restart').classList.toggle('show', show);
// There is no restart button on iOS.
// <if expr="not is_ios">
this.restartButton.disabled = !show;
// </if>
if (show) {
this.getRequiredElement('#needs-restart').setAttribute('role', 'alert');
}
}
/**
* Called by the WebUI to re-populate the page with data representing the
* current state of all experimental features.
*/
private returnExperimentalFeatures(experimentalFeaturesData:
ExperimentalFeaturesData) {
const bodyContainer = this.getRequiredElement('#body-container');
this.render(experimentalFeaturesData);
if (experimentalFeaturesData.showBetaChannelPromotion) {
this.getRequiredElement<HTMLSpanElement>('#channel-promo-beta').hidden =
false;
} else if (experimentalFeaturesData.showDevChannelPromotion) {
this.getRequiredElement<HTMLSpanElement>('#channel-promo-dev').hidden =
false;
}
this.getRequiredElement<HTMLParagraphElement>('#promos').hidden =
!experimentalFeaturesData.showBetaChannelPromotion &&
!experimentalFeaturesData.showDevChannelPromotion;
bodyContainer.style.visibility = 'visible';
this.flagSearch.init();
// <if expr="chromeos_ash">
const ownerWarningDiv = this.$<HTMLParagraphElement>('#owner-warning');
if (ownerWarningDiv) {
ownerWarningDiv.hidden = !experimentalFeaturesData.showOwnerWarning;
}
// </if>
// <if expr="chromeos_lacros or chromeos_ash">
const systemFlagsLinkDiv = this.$<HTMLElement>('#os-link-container');
if (systemFlagsLinkDiv && !experimentalFeaturesData.showSystemFlagsLink) {
systemFlagsLinkDiv.style.display = 'none';
}
// </if>
this.featuresResolver.resolve();
}
// <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.
*/
private setupRestartButton() {
this.restartButton.addEventListener('keydown', e => {
if (e.shiftKey && e.key === 'Tab' && this.lastFocused) {
e.preventDefault();
this.lastFocused.focus();
}
});
this.restartButton.addEventListener('blur', () => {
this.lastFocused = null;
});
}
// </if>
}
declare global {
interface HTMLElementTagNameMap {
'flags-app': FlagsAppElement;
}
}
customElements.define(FlagsAppElement.is, FlagsAppElement);