Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

feat(demos): Add theme switcher to theme demo page #1975

Merged
merged 30 commits into from
Jan 19, 2018
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ee38ec1
WIP: Copy experimental/theme/demo branch at 7e588ce
acdvorak Jan 17, 2018
6de574e
Merge branch 'master' into experimental/theme/demo-v2
acdvorak Jan 17, 2018
96703fa
WIP: Squashed commit of the following:
acdvorak Jan 17, 2018
29f120c
WIP: Refactor RTL toggling out into separate class
acdvorak Jan 17, 2018
c024802
WIP: Remove RTL toggle button from toolbar
acdvorak Jan 17, 2018
fa725d8
Merge remote-tracking branch 'origin/master' into experimental/theme/…
acdvorak Jan 17, 2018
53da253
WIP: JSDoc
acdvorak Jan 17, 2018
8777138
WIP: Remove RtlToggler; add it in future PR
acdvorak Jan 17, 2018
1594af7
WIP: Remove unneeded ID
acdvorak Jan 17, 2018
096e779
WIP: Undo RTL fixes from #1974
acdvorak Jan 17, 2018
85cc99f
chore(demos): Add `root` param to demoReady
acdvorak Jan 17, 2018
eca2972
WIP: Don't subclass HotSwapper; use single shared instance
acdvorak Jan 17, 2018
d2ea4c7
WIP: Refactoring
acdvorak Jan 17, 2018
2c5bf94
WIP: Refactoring
acdvorak Jan 17, 2018
2188c65
WIP: Refactoring
acdvorak Jan 17, 2018
a06a54a
Merge remote-tracking branch 'origin/master' into experimental/theme/…
acdvorak Jan 17, 2018
f064477
WIP: Rename methods
acdvorak Jan 17, 2018
f5c9690
WIP: Reorder params
acdvorak Jan 17, 2018
13ff649
WIP: Refactoring
acdvorak Jan 17, 2018
6da0a67
WIP: Remove unnecessary class
acdvorak Jan 17, 2018
ed8d8aa
WIP: It ain't over until the async method completes
acdvorak Jan 17, 2018
871d783
WIP: Fix botched auto-merge
acdvorak Jan 17, 2018
e72566e
WIP: Code style
acdvorak Jan 17, 2018
8838f93
WIP: Rename optional args and use default values
acdvorak Jan 19, 2018
3792390
WIP: Remove pointless `getElementById_` method
acdvorak Jan 19, 2018
bbe077e
WIP: Remove duplicate styles
acdvorak Jan 19, 2018
f2670cb
WIP: Rename `initialize()` to `lazyInit()` for clarity
acdvorak Jan 19, 2018
bd0d961
WIP: Add missing @param to JSDoc
acdvorak Jan 19, 2018
a98ceaa
WIP: Remove TODO
acdvorak Jan 19, 2018
2029047
Merge branch 'master' into experimental/theme/demo-v2
acdvorak Jan 19, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions demos/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as interactivity from './interactivity.js';

demoReady((root) => {
interactivity.init(root);
});

// Export useful libs to aid debugging/experimentation in the browser's dev tools console.
import * as dom from './dom.js';
import * as pony from './ponyfill.js';
import * as util from './util.js';
export {dom, pony, util};
43 changes: 43 additions & 0 deletions demos/dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* @param {string} query
* @param {!Document|!Element=} opt_root
* @return {!Array<!Element>}
*/
export function getAll(query, opt_root) {
opt_root = opt_root || document;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop opt_ prefix and inline as default parameter? getAll(query, root = document)

The opt_ prefix is discouraged now in favor of default parameters. (See reference)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return [].slice.call(opt_root.querySelectorAll(query));
}

/**
* @param {!Window|!Document|!Element} root
* @return {!Document|undefined}
*/
export function getDocument(root) {
return root.ownerDocument || root.document || (root.documentElement ? root : undefined);
}

/**
* @param {!Window|!Document|!Element} root
* @return {!Window|undefined}
*/
export function getWindow(root) {
const doc = getDocument(root);
return doc.defaultView || doc.parentWindow || (root.document ? root : undefined);
}
313 changes: 313 additions & 0 deletions demos/interactivity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
/**
* @license
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as dom from './dom.js';
import * as util from './util.js';

const classes = {
TOOLBAR_PROGRESS_BAR_ACTIVE: 'demo-toolbar-progress-bar--active',
};

const attrs = {
HOT_SWAP: 'data-hot',
IS_LOADING: 'data-is-loading',
};

const ids = {
TOOLBAR_PROGRESS_BAR: 'demo-toolbar-progress-bar',
};

/** @abstract */
export class InteractivityProvider {
constructor(root) {
/** @protected {!Document|!Element} */
this.root_ = root;

/** @protected {!Document} */
this.document_ = dom.getDocument(this.root_);

/** @protected {!Window} */
this.window_ = dom.getWindow(this.root_);
}

initialize() {}

/**
* @param {string} id
* @param {!Document|!Element=} opt_root
* @return {?Element}
* @protected
*/
getElementById_(id, opt_root) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this more complicated than simply calling document.getElementById?

For that matter, are these various root parameters ever passed anything other than document in practice, or do we have some cases of YAGNI in here?

Also, same idea here and below RE opt_root -> root = ...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

const root = opt_root || this.root_;
return root.querySelector(`#${id}`);
}

/**
* @param {string} selector
* @param {!Document|!Element=} opt_root
* @return {!Array<!Element>}
* @protected
*/
querySelectorAll_(selector, opt_root) {
const root = opt_root || this.root_;
return dom.getAll(selector, root);
}
}

export class ToolbarProvider extends InteractivityProvider {
/** @param {!Document|!Element} root */
static attachTo(root) {
const instance = new ToolbarProvider(root);
instance.initialize();
return instance;
}

/** @override */
initialize() {
/** @type {?Element} */
this.progressBarEl_ = this.getElementById_(ids.TOOLBAR_PROGRESS_BAR);
}

/** @param {boolean} isLoading */
setIsLoading(isLoading) {
if (!this.progressBarEl_) {
return;
}

if (isLoading) {
this.progressBarEl_.classList.add(classes.TOOLBAR_PROGRESS_BAR_ACTIVE);
} else {
this.progressBarEl_.classList.remove(classes.TOOLBAR_PROGRESS_BAR_ACTIVE);
}
}
}

export class HotSwapper extends InteractivityProvider {
/**
* @param {!Document|!Element} root
* @param {!ToolbarProvider} toolbarProvider
*/
static attachTo(root, toolbarProvider) {
const instance = new HotSwapper(root);
instance.initialize(toolbarProvider);
return instance;
}

/** @private {number} */
static get hotUpdateWaitPeriodMs_() {
return 250;
}

/**
* @param {!ToolbarProvider} toolbarProvider
* @override
*/
initialize(toolbarProvider) {
/** @type {!ToolbarProvider} */
this.toolbarProvider_ = toolbarProvider;

/** @type {!Array<string>} */
this.pendingRequests_ = [];

this.registerHotUpdateHandler_();
}

/** @private */
registerHotUpdateHandler_() {
const hotSwapAllStylesheets = util.debounce(() => {
this.hotSwapAllStylesheets_();
}, HotSwapper.hotUpdateWaitPeriodMs_);

this.window_.addEventListener('message', (evt) => {
if (this.isWebpackRecompileStart_(evt)) {
this.toolbarProvider_.setIsLoading(true);
} else if (this.isWebpackRecompileEnd_(evt)) {
hotSwapAllStylesheets();
}
});
}

/**
* @param {!Event} evt
* @return {boolean}
* @private
*/
isWebpackRecompileStart_(evt) {
return Boolean(evt.data) && evt.data.type === 'webpackInvalid';
}

/**
* @param {!Event} evt
* @return {boolean}
* @private
*/
isWebpackRecompileEnd_(evt) {
return typeof evt.data === 'string' && evt.data.indexOf('webpackHotUpdate') === 0;
}

/** @private */
hotSwapAllStylesheets_() {
this.querySelectorAll_(`link[${attrs.HOT_SWAP}]:not([${attrs.IS_LOADING}])`).forEach((link) => {
this.hotSwapStylesheet(link);
});
}

/**
* @param {!Element} oldLink
* @param {string|undefined=} newUri
*/
hotSwapStylesheet(oldLink, newUri) {
const oldUri = oldLink.getAttribute('href');

// Reload existing stylesheet
if (!newUri) {
newUri = oldUri;
}

// Force IE 11 and Edge to bypass the cache and request a fresh copy of the CSS.
newUri = this.bustCache_(newUri);

this.swapItLikeItsHot_(oldLink, oldUri, newUri);
}

/**
* @param {!Element} oldLink
* @param {string} oldUri
* @param {string} newUri
* @private
*/
swapItLikeItsHot_(oldLink, oldUri, newUri) {
this.logHotSwap_('swapping', oldUri, newUri, '...');

// Ensure that oldLink has a unique ID so we can remove all stale stylesheets from the DOM after newLink loads.
// This is a more robust approach than holding a reference to oldLink and removing it directly, because a user might
// quickly switch themes several times before the first stylesheet finishes loading (especially over a slow network)
// and each new stylesheet would try to remove the first one, leaving multiple conflicting stylesheets in the DOM.
if (!oldLink.id) {
oldLink.id = `stylesheet-${Math.floor(Math.random() * Date.now())}`;
}

const newLink = /** @type {!Element} */ (oldLink.cloneNode(false));
newLink.setAttribute('href', newUri);
newLink.setAttribute(attrs.IS_LOADING, 'true');

// IE 11 and Edge fire the `load` event twice for `<link>` elements.
newLink.addEventListener('load', util.debounce(() => {
this.handleStylesheetLoad_(newLink, newUri, oldUri);
}, 50));

oldLink.parentNode.insertBefore(newLink, oldLink);

this.pendingRequests_.push(newUri);
this.toolbarProvider_.setIsLoading(true);
}

/**
* @param {!Element} newLink
* @param {string} newUri
* @param {string} oldUri
* @private
*/
handleStylesheetLoad_(newLink, newUri, oldUri) {
this.pendingRequests_.splice(this.pendingRequests_.indexOf(newUri), 1);
if (this.pendingRequests_.length === 0) {
this.toolbarProvider_.setIsLoading(false);
}

setTimeout(() => {
this.purgeOldStylesheets_(newLink);

// Remove the 'loading' attribute *after* purging old stylesheets to avoid purging this one.
newLink.removeAttribute(attrs.IS_LOADING);

this.logHotSwap_('swapped', oldUri, newUri, '!');
});
}

/**
* @param {!Element} newLink
* @private
*/
purgeOldStylesheets_(newLink) {
let oldLinks;

const getOldLinks = () => this.querySelectorAll_(`link[id="${newLink.id}"]:not([${attrs.IS_LOADING}])`);

while ((oldLinks = getOldLinks()).length > 0) {
oldLinks.forEach((oldLink) => {
// Link has already been detached from the DOM. I'm not sure what causes this to happen; I've only seen it in
// IE 11 and/or Edge so far, and only occasionally.
if (!oldLink.parentNode) {
return;
}
oldLink.parentNode.removeChild(oldLink);
});
}
}

/**
* Adds a timestamp to the given URI to force IE 11 and Edge to bypass the cache and request a fresh copy of the CSS.
* @param oldUri
* @return {string}
* @private
*/
bustCache_(oldUri) {
const newUri = oldUri
// Remove previous timestamp param (if present)
.replace(/[?&]timestamp=\d+(&|$)/, '')
// Remove trailing '?' or '&' char (if present)
.replace(/[?&]$/, '');
const separator = newUri.indexOf('?') === -1 ? '?' : '&';
return `${newUri}${separator}timestamp=${Date.now()}`;
}

/**
* @param {string} verb
* @param {string} oldUri
* @param {string} newUri
* @param {string} trailingPunctuation
* @private
*/
logHotSwap_(verb, oldUri, newUri, trailingPunctuation) {
const swapMessage = `"${oldUri}"${newUri ? ` with "${newUri}"` : ''}`;
console.log(`Hot ${verb} stylesheet ${swapMessage}${trailingPunctuation}`);
}

/**
* @param {!Document|!Element} root
* @return {!HotSwapper}
*/
static getInstance(root) {
// Yeah, I know, this is gross.
if (!root.demoHotSwapperRootMap_) {
/** @type {?Map<{key:*, value:*}>} @private */
root.demoHotSwapperRootMap_ = new Map();
}
let instance = root.demoHotSwapperRootMap_.get(root);
if (!instance) {
instance = HotSwapper.attachTo(root, ToolbarProvider.attachTo(root));
root.demoHotSwapperRootMap_.set(root, instance);
}
return instance;
}
}

/** @param {!Document|!Element} root */
export function init(root) {
HotSwapper.getInstance(root);
}
Loading