// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// clang-format off
import {assert} from 'chrome://resources/js/assert.js';
import type { PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {beforeNextRender, dedupingMixin} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BaseMixin} from '../base_mixin.js';
import type {SettingsIdleLoadElement} from '../controls/settings_idle_load.js';
import {ensureLazyLoaded} from '../ensure_lazy_loaded.js';
import {loadTimeData} from '../i18n_setup.js';
import {routes} from '../route.js';
import type {Route} from '../router.js';
import { Router} from '../router.js';
// clang-format on
/**
* A categorization of every possible Settings URL, necessary for implementing
* a finite state machine.
*/
export enum RouteState {
// Initial state before anything has loaded yet.
INITIAL = 'initial',
// A dialog that has a dedicated URL (e.g. /importData).
DIALOG = 'dialog',
// A section (basically a scroll position within the top level page, e.g,
// /appearance.
SECTION = 'section',
// A subpage, or sub-subpage e.g, /searchEngins.
SUBPAGE = 'subpage',
// The top level Settings page, '/'.
TOP_LEVEL = 'top-level',
}
let guestTopLevelRoute = routes.SEARCH;
// <if expr="chromeos_ash">
guestTopLevelRoute = routes.PRIVACY;
// </if>
const TOP_LEVEL_EQUIVALENT_ROUTE: Route =
loadTimeData.getBoolean('isGuest') ? guestTopLevelRoute : routes.PEOPLE;
function classifyRoute(route: Route|null): RouteState {
if (!route) {
return RouteState.INITIAL;
}
const routes = Router.getInstance().getRoutes();
if (route === routes.BASIC) {
return RouteState.TOP_LEVEL;
}
if (route.isSubpage()) {
return RouteState.SUBPAGE;
}
if (route.isNavigableDialog) {
return RouteState.DIALOG;
}
return RouteState.SECTION;
}
type Constructor<T> = new (...args: any[]) => T;
/**
* Responds to route changes by expanding, collapsing, or scrolling to
* sections on the page. Expanded sections take up the full height of the
* container. At most one section should be expanded at any given time.
*/
export const MainPageMixin = dedupingMixin(
<T extends Constructor<PolymerElement>>(superClass: T): T&
Constructor<MainPageMixinInterface> => {
const superClassBase = BaseMixin(superClass);
class MainPageMixin extends superClassBase {
scroller: HTMLElement|null = null;
private validTransitions_: Map<RouteState, Set<RouteState>>;
private lastScrollTop_: number = 0;
// Populated by Polymer itself.
domHost: HTMLElement|null;
constructor(...args: any[]) {
super(...args);
/**
* A map holding all valid state transitions.
*/
this.validTransitions_ = (function() {
const allStates = new Set([
RouteState.DIALOG,
RouteState.SECTION,
RouteState.SUBPAGE,
RouteState.TOP_LEVEL,
]);
return new Map([
[RouteState.INITIAL, allStates],
[
RouteState.DIALOG,
new Set([
RouteState.SECTION,
RouteState.SUBPAGE,
RouteState.TOP_LEVEL,
]),
],
[RouteState.SECTION, allStates],
[RouteState.SUBPAGE, allStates],
[RouteState.TOP_LEVEL, allStates],
]);
})();
}
override connectedCallback() {
this.scroller =
this.domHost ? this.domHost.parentElement : document.body;
// Purposefully calling this after |scroller| has been initialized.
super.connectedCallback();
}
/**
* Method to be overridden by users of MainPageMixin.
* @return Whether the given route is part of |this| page.
*/
containsRoute(_route: Route|null): boolean {
return false;
}
private shouldExpandAdvanced_(route: Route): boolean {
const routes = Router.getInstance().getRoutes();
return this.tagName === 'SETTINGS-BASIC-PAGE' && !!routes.ADVANCED &&
routes.ADVANCED.contains(route);
}
/**
* Finds the settings section corresponding to the given route. If the
* section is lazily loaded it force-renders it.
* Note: If the section resides within "advanced" settings, a
* 'hide-container' event is fired (necessary to avoid flashing).
* Callers are responsible for firing a 'show-container' event.
*/
private ensureSectionForRoute_(route: Route): Promise<HTMLElement> {
const section = this.getSection(route.section);
if (section !== null) {
return Promise.resolve(section);
}
// The function to use to wait for <dom-if>s to render.
const waitFn = beforeNextRender.bind(null, this);
return new Promise<HTMLElement>(resolve => {
if (this.shouldExpandAdvanced_(route)) {
this.fire('hide-container');
waitFn(() => {
this.$$<SettingsIdleLoadElement>('#advancedPageTemplate')!.get()
.then(() => {
resolve(this.getSection(route.section)!);
});
});
} else {
waitFn(() => {
resolve(this.getSection(route.section)!);
});
}
});
}
/**
* Finds the settings-section instances corresponding to the given
* route. If the section is lazily loaded it force-renders it. Note: If
* the section resides within "advanced" settings, a 'hide-container'
* event is fired (necessary to avoid flashing). Callers are responsible
* for firing a 'show-container' event.
*/
private ensureSectionsForRoute_(route: Route): Promise<HTMLElement[]> {
const sections = this.querySettingsSections_(route.section);
if (sections.length > 0) {
return Promise.resolve(sections);
}
// The function to use to wait for <dom-if>s to render.
const waitFn = beforeNextRender.bind(null, this);
return new Promise(resolve => {
if (this.shouldExpandAdvanced_(route)) {
this.fire('hide-container');
waitFn(() => {
this.$$<SettingsIdleLoadElement>('#advancedPageTemplate')!.get()
.then(() => {
resolve(this.querySettingsSections_(route.section));
});
});
} else {
waitFn(() => {
resolve(this.querySettingsSections_(route.section));
});
}
});
}
private enterSubpage_(route: Route) {
this.lastScrollTop_ = this.scroller!.scrollTop;
this.scroller!.scrollTop = 0;
this.classList.add('showing-subpage');
this.fire('subpage-expand');
// Explicitly load the lazy_load.html module, since all subpages
// reside in the lazy loaded module.
ensureLazyLoaded();
this.ensureSectionForRoute_(route).then(section => {
section.classList.add('expanded');
// Fire event used by a11y tests only.
this.fire('settings-section-expanded');
this.fire('show-container');
});
}
private enterMainPage_(oldRoute: Route): Promise<void> {
const oldSection = this.getSection(oldRoute.section)!;
oldSection.classList.remove('expanded');
this.classList.remove('showing-subpage');
return new Promise((res) => {
requestAnimationFrame(() => {
if (Router.getInstance().lastRouteChangeWasPopstate()) {
this.scroller!.scrollTop = this.lastScrollTop_;
}
this.fire('showing-main-page');
res();
});
});
}
/**
* Shows the section(s) corresponding to |newRoute| and hides the
* previously |active| section(s), if any.
*/
private switchToSections_(newRoute: Route) {
this.ensureSectionsForRoute_(newRoute).then(sections => {
// Clear any previously |active| section.
const oldSections =
this.shadowRoot!.querySelectorAll(`settings-section[active]`);
for (const s of oldSections) {
s.toggleAttribute('active', false);
}
for (const s of sections) {
s.toggleAttribute('active', true);
}
this.fire('show-container');
});
}
/**
* Detects which state transition is appropriate for the given new/old
* routes.
*/
private getStateTransition_(newRoute: Route, oldRoute: Route|null) {
const containsNew = this.containsRoute(newRoute);
const containsOld = this.containsRoute(oldRoute);
if (!containsNew && !containsOld) {
// Nothing to do, since none of the old/new routes belong to this
// page.
return null;
}
// Case where going from |this| page to an unrelated page. For
// example:
// |this| is settings-basic-page AND
// oldRoute is /searchEngines AND
// newRoute is /help.
if (containsOld && !containsNew) {
return [classifyRoute(oldRoute), RouteState.TOP_LEVEL];
}
// Case where return from an unrelated page to |this| page. For
// example:
// |this| is settings-basic-page AND
// oldRoute is /help AND
// newRoute is /searchEngines
if (!containsOld && containsNew) {
return [RouteState.TOP_LEVEL, classifyRoute(newRoute)];
}
// Case where transitioning between routes that both belong to |this|
// page.
return [classifyRoute(oldRoute), classifyRoute(newRoute)];
}
// TODO(dpapad): Figure out why adding the |override| keyword here
// throws an error.
currentRouteChanged(newRoute: Route, oldRoute: Route|null) {
const transition = this.getStateTransition_(newRoute, oldRoute);
if (transition === null) {
return;
}
const oldState = transition[0];
const newState = transition[1];
assert(this.validTransitions_.get(oldState)!.has(newState));
if (oldState === RouteState.TOP_LEVEL) {
if (newState === RouteState.SECTION) {
this.switchToSections_(newRoute);
} else if (newState === RouteState.SUBPAGE) {
this.switchToSections_(newRoute);
this.enterSubpage_(newRoute);
} else if (newState === RouteState.TOP_LEVEL) {
// Case when navigating from '/?search=foo' to '/' (clearing
// search results).
this.switchToSections_(TOP_LEVEL_EQUIVALENT_ROUTE);
} else if (newState === RouteState.DIALOG) {
// Case when user clicks "Reset all settings" from within the
// settings-reset-profile-banner to navigate to
// /resetProfileSettings.
this.switchToSections_(newRoute);
}
return;
}
if (oldState === RouteState.SECTION) {
if (newState === RouteState.SECTION) {
this.switchToSections_(newRoute);
} else if (newState === RouteState.SUBPAGE) {
this.switchToSections_(newRoute);
this.enterSubpage_(newRoute);
} else if (newState === RouteState.TOP_LEVEL) {
this.switchToSections_(TOP_LEVEL_EQUIVALENT_ROUTE);
this.scroller!.scrollTop = 0;
}
// Nothing to do here for the case of RouteState.DIALOG.
return;
}
if (oldState === RouteState.SUBPAGE) {
if (newState === RouteState.SECTION) {
this.enterMainPage_(oldRoute!);
this.switchToSections_(newRoute);
} else if (newState === RouteState.SUBPAGE) {
// Handle case where the two subpages belong to
// different sections, but are linked to each other. For example
// /storage and /accounts (in ChromeOS).
if (!oldRoute!.contains(newRoute) &&
!newRoute.contains(oldRoute!)) {
this.enterMainPage_(oldRoute!).then(() => {
this.enterSubpage_(newRoute);
});
return;
}
// Handle case of subpage to sub-subpage navigation.
if (oldRoute!.contains(newRoute)) {
this.scroller!.scrollTop = 0;
return;
}
// When going from a sub-subpage to its parent subpage, scroll
// position is automatically restored, because we focus the
// sub-subpage entry point.
} else if (newState === RouteState.TOP_LEVEL) {
this.enterMainPage_(oldRoute!);
} else if (newState === RouteState.DIALOG) {
// The only known cases currently for such a transition are from
// 1) /synceSetup to /signOut
// 2) /synceSetup to /clearBrowserData using the "back" arrow
this.enterMainPage_(oldRoute!);
this.switchToSections_(newRoute);
}
return;
}
if (oldState === RouteState.INITIAL) {
if ([RouteState.SECTION, RouteState.DIALOG].includes(newState)) {
this.switchToSections_(newRoute);
} else if (newState === RouteState.SUBPAGE) {
this.switchToSections_(newRoute);
this.enterSubpage_(newRoute);
} else if (newState === RouteState.TOP_LEVEL) {
this.switchToSections_(TOP_LEVEL_EQUIVALENT_ROUTE);
}
return;
}
if (oldState === RouteState.DIALOG) {
if (newState === RouteState.SUBPAGE) {
// The only known cases currently for such a transition are from
// 1) /signOut to /syncSetup
// 2) /clearBrowserData to /syncSetup
this.switchToSections_(newRoute);
this.enterSubpage_(newRoute);
}
// Nothing to do for all other cases.
}
}
/**
* TODO(dpapad): Rename this to |querySection| to distinguish it from
* ensureSectionForRoute_() which force-renders the section as needed.
* Helper function to get a section from the local DOM.
* @param section Section name of the element to get.
*/
getSection(section: string): HTMLElement|null {
if (!section) {
return null;
}
return this.$$(`settings-section[section="${section}"]`);
}
/*
* @param sectionName Section name of the element to get.
*/
private querySettingsSections_(sectionName: string): HTMLElement[] {
const result = [];
const section = this.getSection(sectionName);
if (section) {
result.push(section);
}
const extraSections = this.shadowRoot!.querySelectorAll<HTMLElement>(
`settings-section[nest-under-section="${sectionName}"]`);
if (extraSections.length > 0) {
result.push(...extraSections);
}
return result;
}
}
return MainPageMixin;
});
export interface MainPageMixinInterface {
scroller: HTMLElement|null;
containsRoute(route: Route|null): boolean;
}