blob: 7e5a70df329e3e43dbc40fe689224d2f63a7b97a [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../strings.m.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {afterNextRender, dedupingMixin, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
/**
* @fileoverview The NavigationMixin is in charge of manipulating and
* watching window.history.state changes. The page is using the history
* state object to remember state instead of changing the URL directly,
* because the flow requires that users can use browser-back/forward to
* navigate between steps, without being able to go directly or copy an URL
* that points at a specific step. Using history.state object allows adding
* or popping history state without actually changing the path.
*/
/**
* Valid route pathnames.
*/
export enum Routes {
LANDING = 'landing',
NEW_USER = 'new-user',
RETURNING_USER = 'returning-user'
}
/**
* Regular expression that captures the leading slash, the content and the
* trailing slash in three different groups.
*/
const CANONICAL_PATH_REGEX: RegExp = /(^\/)([\/-\w]+)(\/$)/;
const path = location.pathname.replace(CANONICAL_PATH_REGEX, '$1$2');
// Sets up history state based on the url path, unless it's already set (e.g.
// when user uses browser-back button to get back on chrome://welcome/...).
if (!history.state || !history.state.route || !history.state.step) {
switch (path) {
case `/${Routes.NEW_USER}`:
history.replaceState({route: Routes.NEW_USER, step: 1}, '', path);
break;
case `/${Routes.RETURNING_USER}`:
history.replaceState({route: Routes.RETURNING_USER, step: 1}, '', path);
break;
default:
history.replaceState(
{route: Routes.LANDING, step: Routes.LANDING}, '', '/');
}
}
const routeObservers: Set<NavigationMixinInterface> = new Set();
let currentRouteElement: NavigationMixinInterface|null;
// Notifies all the elements that extended NavigationMixin.
function notifyObservers() {
if (currentRouteElement) {
currentRouteElement.onRouteExit();
currentRouteElement = null;
}
const route = (history.state.route as Routes);
const step = history.state.step;
routeObservers.forEach(observer => {
observer.onRouteChange(route, step);
// Modules are only attached to DOM if they're for the current route, so
// as long as the id of an element matches up to the current step, it
// means that element is for the current route.
if ((observer as unknown as HTMLElement).id === `step-${step}`) {
currentRouteElement = observer;
}
});
// If currentRouteElement is not null, it means there was a new route.
if (currentRouteElement) {
(currentRouteElement as NavigationMixinInterface).notifyRouteEnter();
}
}
// Notifies all elements when browser history is popped.
window.addEventListener('popstate', notifyObservers);
// Notify the active element before unload.
window.addEventListener('beforeunload', () => {
if (currentRouteElement) {
(currentRouteElement as {onRouteUnload: Function}).onRouteUnload();
}
});
export function navigateToNextStep() {
history.pushState(
{route: history.state.route, step: history.state.step + 1}, '',
`/${history.state.route}`);
notifyObservers();
}
export function navigateTo(route: Routes, step: number) {
assert([
Routes.LANDING,
Routes.NEW_USER,
Routes.RETURNING_USER,
].includes(route));
history.pushState(
{
route: route,
step: step,
},
'', '/' + (route === Routes.LANDING ? '' : route));
notifyObservers();
}
type Constructor<T> = new (...args: any[]) => T;
/**
* Elements can override onRoute(Change|Enter|Exit) to handle route changes.
* Order of hooks being called:
* 1) onRouteExit() on the old route
* 2) onRouteChange() on all subscribed routes
* 3) onRouteEnter() on the new route
*/
export const NavigationMixin = dedupingMixin(
<T extends Constructor<PolymerElement>>(superClass: T): T&
Constructor<NavigationMixinInterface> => {
class NavigationMixin extends superClass {
static get properties() {
return {
subtitle: String,
};
}
subtitle?: string;
connectedCallback() {
super.connectedCallback();
assert(!routeObservers.has(this));
routeObservers.add(this);
const route = (history.state.route as Routes);
const step = history.state.step;
// history state was set when page loaded, so when the element first
// attaches, call the route-change handler to initialize first.
this.onRouteChange(route, step);
// Modules are only attached to DOM if they're for the current route,
// so as long as the id of an element matches up to the current step,
// it means that element is for the current route.
if (this.id === `step-${step}`) {
currentRouteElement = this;
this.notifyRouteEnter();
}
}
/**
* Notifies elements that route was entered and updates the state of the
* app based on the new route.
*/
notifyRouteEnter(): void {
this.onRouteEnter();
this.updateFocusForA11y();
this.updateTitle();
}
/** Called to update focus when progressing through the modules. */
updateFocusForA11y(): void {
const header = this.shadowRoot!.querySelector('h1');
if (header) {
afterNextRender(this, () => header.focus());
}
}
updateTitle(): void {
let title = loadTimeData.getString('headerText');
if (this.subtitle) {
title += ' - ' + this.subtitle;
}
document.title = title;
}
disconnectedCallback() {
super.disconnectedCallback();
assert(routeObservers.delete(this));
}
onRouteChange(_route: Routes, _step: number): void {}
onRouteEnter(): void {}
onRouteExit(): void {}
onRouteUnload(): void {}
}
return NavigationMixin;
});
export interface NavigationMixinInterface {
subtitle?: string;
notifyRouteEnter(): void;
updateFocusForA11y(): void;
updateTitle(): void;
onRouteChange(route: Routes, step: number): void;
onRouteEnter(): void;
onRouteExit(): void;
onRouteUnload(): void;
}