blob: d61caa0e16796295c79265ef722ba882b37f5122 [file] [log] [blame]
/*
* Copyright 2014 Google Inc.
*
* 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.
*/
// TODO(jud): Test on a wider range of browsers and restrict to only modern
// browsers. This uses a few modern web features, like css3 animations, that are
// not supported on opera mini for example.
goog.provide('mob.Nav');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.events.EventType');
goog.require('goog.labs.userAgent.browser');
goog.require('goog.string');
goog.require('goog.structs.Set');
// goog.style adds ~400 bytes when using getSize and getTransformedSize.
goog.require('goog.style');
goog.require('mob.NavPanel');
goog.require('mob.button.Dialer');
goog.require('mob.button.Map');
goog.require('mob.button.Menu');
goog.require('mob.util');
goog.require('mob.util.BeaconEvents');
goog.require('mob.util.ElementClass');
goog.require('mob.util.ElementId');
/**
* Create mobile navigation menus.
* @constructor
*/
mob.Nav = function() {
/**
* The header bar element inserted at the top of the page. This is inserted by
* C++.
* @private {!Element}
*/
this.headerBar_ = goog.dom.getRequiredElement(mob.util.ElementId.HEADER_BAR);
/**
* The style tag used to style the nav elements.
* @private {?Element}
*/
this.styleTag_ = null;
/**
* Spacer div element inserted at the top of the page to push the rest of the
* content down. This is inserted in C++.
* @private {!Element}
*/
this.spacerDiv_ = goog.dom.getRequiredElement(mob.util.ElementId.SPACER);
/**
* The span containing the logo.
* @private {?Element}
*/
this.logoSpan_ = null;
/**
* Menu button in the header bar. This can be null after configuration if no
* nav section was inserted server side.
* @private {?mob.button.Menu}
*/
this.menuButton_ = null;
/**
* @private {?mob.button.Dialer}
*/
this.dialer_ = null;
/**
* Map button in the header bar.
* @private {?mob.button.Map}
*/
this.mapButton_ = null;
/**
* Side nav panel. Only initialized if inserted by C++.
* @private {?mob.NavPanel}
*/
this.navPanel_ = null;
/**
* Tracks time since last scroll to figure out when scrolling is finished.
* Will be null when not scrolling.
* @private {?number}
*/
this.scrollTimer_ = null;
/**
* Tracks number of current touches to know when scrolling is finished.
* @private {number}
*/
this.currentTouches_ = 0;
/**
* Track's the header bar height so we can tell if it changes between redraws.
* -1 indicates that it has not been calculated before.
* @private {number}
*/
this.headerBarHeight_ = -1;
/**
* Tracks the elements which need to be adjusted after zooming to account for
* the offset of the spacer div. This includes fixed position elements, and
* absolute position elements rooted at the body.
* @private {!goog.structs.Set}
*/
this.elementsToOffset_ = new goog.structs.Set();
/**
* Tracks if the redrawNav function has been called yet or not, to enable
* slightly different behavior on first vs subsequent calls.
* @private {boolean}
*/
this.redrawNavCalled_ = false;
/**
* Tracks if we are on the stock android browser. Touch events are broken on
* old version of the android browser, so we have to provide some fallback
* functionality for them.
* @private {boolean}
*/
this.isAndroidBrowser_ = goog.labs.userAgent.browser.isAndroidBrowser();
/**
* Use position fixed on browsers that have broken scroll event behavior.
* @private {boolean}
*/
this.isIosWebview_ = ((window.navigator.userAgent.indexOf('CriOS') > -1) ||
(window.navigator.userAgent.indexOf('GSA') > -1) ||
goog.labs.userAgent.browser.isIosWebview());
};
/**
* The minimum font size required for the header bar to work. If the browser
* enforces a minimum font size larger than this then we don't display the
* header bar.
* TODO(jud): Set this to something higher and scale down the em values in
* mobilize.css
* @const @private {number}
*/
mob.Nav.MIN_FONT_SIZE_ = 1;
/**
* Do a pre-pass over all the elements on the page and find out those that
* need to add offset. The elements include
* (a) all elements with 'position' specified as 'fixed' and 'top' specified in
* pixels.
* (b) all elements with 'position' specified as 'absolute' or 'relative',
* and 'top' specified in pixels, with all ancestors up to document.body
* have 'static' 'position'.
*
* @param {!Element} element
* @param {boolean} fixedPositionOnly
* @private
*/
mob.Nav.prototype.findElementsToOffsetHelper_ = function(element,
fixedPositionOnly) {
if (!element.className ||
(element.className != mob.util.ElementId.PROGRESS_SCRIM &&
goog.isString(element.className) &&
!goog.string.startsWith(element.className, 'psmob-') &&
!goog.string.startsWith(element.id, 'psmob-'))) {
var style = window.getComputedStyle(element);
var position = style.getPropertyValue('position');
if (position != 'static') {
var top = mob.util.pixelValue(style.getPropertyValue('top'));
if (top != null && (position == 'fixed' ||
(!fixedPositionOnly && position == 'absolute'))) {
this.elementsToOffset_.add(element);
}
fixedPositionOnly = true;
}
for (var childElement = element.firstElementChild; childElement;
childElement = childElement.nextElementSibling) {
this.findElementsToOffsetHelper_(childElement, fixedPositionOnly);
}
}
};
/**
* Do a pre-pass over all the elements on the page and find out those that
* need to add offset.
*
* TODO(jud): This belongs in mobilize.js instead of mobilize_nav.js.
* @private
*/
mob.Nav.prototype.findElementsToOffset_ = function() {
if (window.document.body) {
this.findElementsToOffsetHelper_(window.document.body,
false /* search for elements with all allowed positions */);
}
};
/**
* Do a pre-pass over all the nodes on the page and clamp their z-index to
* 999997.
* TODO(jud): This belongs in mobilize.js instead of mobilize_nav.js.
* @private
*/
mob.Nav.prototype.clampZIndex_ = function() {
var elements = document.querySelectorAll('*');
for (var i = 0, element; element = elements[i]; i++) {
var id = element.id;
if (id && (id == mob.util.ElementId.PROGRESS_SCRIM ||
id == mob.util.ElementId.HEADER_BAR ||
id == mob.util.ElementId.NAV_PANEL)) {
continue;
}
var style = window.getComputedStyle(element);
// Set to 999997 because the click detector div is set to 999998 and the
// menu bar and nav panel are set to 999999. This function runs before those
// elements are added, so it won't modify their z-index.
if (style.getPropertyValue('z-index') >= 999998) {
mob.util.consoleLog(
'Element z-index exceeded 999998, setting to 999997.');
element.style.zIndex = 999997;
}
}
};
/**
* @private
*/
mob.Nav.prototype.redraw_ = function() {
this.redrawHeader_();
if (this.navPanel_) {
this.navPanel_.redraw(this.headerBar_.getBoundingClientRect().height);
}
};
/**
* Redraw the header after scrolling or zooming finishes.
* @private
*/
mob.Nav.prototype.redrawHeader_ = function() {
// We don't actually expect this to be called without headerBar_ being set,
// but getTransformedSize requires a non-null param, so this coerces the
// closure compiler into recognizing that.
if (!this.headerBar_) {
return;
}
// Use getTransformedSize to take into account the scale transformation.
var oldHeight =
this.headerBarHeight_ == -1 ?
Math.round(goog.style.getTransformedSize(this.headerBar_).height) :
this.headerBarHeight_;
var fontSize = mob.util.getZoomLevel();
this.headerBar_.style.fontSize = fontSize + 'px';
var width = window.innerWidth;
if (window.getComputedStyle(document.body).getPropertyValue('overflow-y') !=
'hidden') {
// Subtract the width of the scrollbar from the viewport size only if the
// scrollbars are visible (goog.style.getScrollbarWidth still returns the
// scrollbar size if they are hidden).
width -= goog.style.getScrollbarWidth();
}
this.headerBar_.style.width = (width / fontSize) + 'em';
// Restore visibility since the bar was hidden while scrolling and zooming.
goog.dom.classlist.remove(this.headerBar_, mob.util.ElementClass.HIDE);
var newHeight =
Math.round(goog.style.getTransformedSize(this.headerBar_).height);
// Update the size of the spacer div to take into account the changed relative
// size of the header. Changing the size of the spacer div will also move the
// rest of the content on the page. For example, when zooming in, we shrink
// the size of the header bar, which causes the page to move up slightly. To
// compensate, we adjust the scroll amount by the difference between the old
// and new sizes of the spacer div.
this.spacerDiv_.style.height = newHeight + 'px';
this.headerBarHeight_ = newHeight;
// Add offset to the elements which need to be moved. On the first run of this
// function, they are offset by the size of the spacer div. On subsequent
// runs, they are offset by the difference between the old and the new size of
// the spacer div.
var offsets = this.elementsToOffset_.getValues();
for (var i = 0; i < offsets.length; i++) {
var el = offsets[i];
var style = window.getComputedStyle(el);
var position = style.getPropertyValue('position');
var top = mob.util.pixelValue(style.getPropertyValue('top'));
if (position != 'static' && top != null) {
if (this.redrawNavCalled_) {
var oldTop = el.style.top;
oldTop = mob.util.pixelValue(el.style.top);
if (oldTop != null) {
el.style.top = String(oldTop + (newHeight - oldHeight)) + 'px';
}
} else {
var elTop = mob.util.boundingRect(el).top;
el.style.top = String(elTop + newHeight) + 'px';
}
}
}
// Calling scrollBy will trigger a scroll event, but we've already updated
// the size of everything, so set a flag that we should ignore the next
// scroll event. Due to rounding errors from getTransformedSize returning a
// float, newHeight and oldHeight can differ by 1 pixel, which will cause this
// routine to get stuck firing in a loop as it tries and fails to compensate
// for the 1 pixel difference.
if (this.redrawNavCalled_ && newHeight != oldHeight) {
window.scrollBy(0, (newHeight - oldHeight));
}
// Redraw the bar to the top of page by offsetting by the amount scrolled.
if (!this.isIosWebview_) {
this.headerBar_.style.top = window.scrollY + 'px';
this.headerBar_.style.left = window.scrollX + 'px';
}
this.redrawNavCalled_ = true;
};
/**
* Add events for capturing header bar resize and calling the appropriate redraw
* events after scrolling and zooming.
* @private
*/
mob.Nav.prototype.addHeaderBarResizeEvents_ = function() {
// Draw the header bar initially.
this.redraw_();
// Setup a 200ms delay to redraw the header and nav panel. The timer gets
// reset upon each touchend and scroll event to ensure that the redraws happen
// after scrolling and zooming are finished.
var resetScrollTimer = function() {
if (this.scrollTimer_ != null) {
window.clearTimeout(this.scrollTimer_);
this.scrollTimer_ = null;
}
this.scrollTimer_ = window.setTimeout(goog.bind(function() {
// Stock android browser has a longstanding bug where touchend events are
// not fired unless preventDefault is called in touchstart. However, doing
// so prevents scrolling (which is the default behavior). Since those
// events aren't fired, currentTouches_ does not get updated correctly. To
// workaround, we fallback to the slightly jankier behavior of just
// redrawing after a scroll event, even if there are potentially touches
// still happening.
// https://code.google.com/p/android/issues/detail?id=19827 for details on
// the bug in question.
if (this.isAndroidBrowser_ || this.currentTouches_ == 0) {
this.redraw_();
}
this.scrollTimer_ = null;
}, this), 200);
};
// Don't redraw the header bar unless there has not been a scroll event for 50
// ms and there are no touches currently on the screen. This keeps the
// redrawing from happening until scrolling is finished.
var scrollHandler = function(e) {
resetScrollTimer.call(this);
if (!this.navPanel_ || !this.navPanel_.isOpen()) {
goog.dom.classlist.add(this.headerBar_, mob.util.ElementClass.HIDE);
}
};
window.addEventListener(goog.events.EventType.SCROLL,
goog.bind(scrollHandler, this), false);
// Keep track of number of touches currently on the screen so that we don't
// redraw until scrolling and zooming is finished.
window.addEventListener(goog.events.EventType.TOUCHSTART,
goog.bind(function(e) {
this.currentTouches_ = e.touches.length;
}, this), false);
window.addEventListener(goog.events.EventType.TOUCHMOVE,
goog.bind(function(e) {
// The default android browser is unreliable about
// firing touch events if preventDefault is not
// called in touchstart (see note above). Therefore,
// we don't hide the nav panel here on the android
// browser, otherwise the bar is not redrawn if a
// user tries to scroll up past the top of the page,
// since neither a touchend event nor a scroll event
// fires to redraw the header.
if (!this.navPanel_ || !this.navPanel_.isOpen()) {
if (!this.isAndroidBrowser_) {
goog.dom.classlist.add(
this.headerBar_,
mob.util.ElementClass.HIDE);
}
} else {
e.preventDefault();
}
}, this), false);
window.addEventListener(goog.events.EventType.TOUCHEND,
goog.bind(function(e) {
this.currentTouches_ = e.touches.length;
// Redraw the header bar if there are no more
// current touches.
if (this.currentTouches_ == 0) {
resetScrollTimer.call(this);
}
}, this), false);
window.addEventListener(goog.events.EventType.ORIENTATIONCHANGE,
goog.bind(function() { this.redraw_(); }, this),
false);
window.addEventListener(goog.events.EventType.RESIZE, goog.bind(function() {
this.redraw_();
}, this), false);
};
/**
* Insert a header bar to the top of the page. For this goal, firstly we
* insert an empty div so all contents, except those with fixed position,
* are pushed down. Then we insert the header bar. The header bar may contain
* a hamburger icon, a logo image, and a call button.
* @param {!mob.util.ThemeData} themeData
* @private
*/
mob.Nav.prototype.addHeaderBar_ = function(themeData) {
// Move the header bar and spacer div back to the top of the body, in case
// some other JS on the page inserted some elements.
if (!document.getElementById(mob.util.ElementId.IFRAME)) {
document.body.insertBefore(this.spacerDiv_, document.body.childNodes[0]);
document.body.insertBefore(this.headerBar_, this.spacerDiv_);
}
if (window.psLabeledMode) {
goog.dom.classlist.add(this.headerBar_, mob.util.ElementClass.LABELED);
}
if (window.psConfigMode) {
goog.dom.classlist.add(this.headerBar_, mob.util.ElementClass.THEME_CONFIG);
}
if (this.isIosWebview_) {
goog.dom.classlist.add(this.headerBar_, mob.util.ElementClass.IOS_WEBVIEW);
}
var navPanelEl = document.getElementById(mob.util.ElementId.NAV_PANEL);
// Add menu button and nav panel.
if (!window.psLabeledMode && navPanelEl) {
this.navPanel_ = new mob.NavPanel(navPanelEl, themeData.menuBackColor);
this.menuButton_ =
new mob.button.Menu(themeData.menuFrontColor,
goog.bind(this.navPanel_.toggle, this.navPanel_));
this.headerBar_.appendChild(this.menuButton_.el);
}
// Add the logo.
// TODO(jmarantz): also consider having this element be an anchor
// in non-config mode, pointing either to the landing page or to
// the non-mobilized version.
if (themeData.logoUrl) {
this.logoSpan_ = document.createElement(goog.dom.TagName.SPAN);
this.logoSpan_.id = mob.util.ElementId.LOGO_SPAN;
var logoImg = document.createElement(goog.dom.TagName.IMG);
logoImg.src = themeData.logoUrl;
logoImg.id = mob.util.ElementId.LOGO_IMAGE;
this.logoSpan_.appendChild(logoImg);
this.headerBar_.appendChild(this.logoSpan_);
}
this.headerBar_.style.borderBottomColor =
mob.util.colorNumbersToString(themeData.menuFrontColor);
this.headerBar_.style.backgroundColor =
mob.util.colorNumbersToString(themeData.menuBackColor);
// Add call button.
if (window.psPhoneNumber) {
this.dialer_ = new mob.button.Dialer(
themeData.menuFrontColor, window.psPhoneNumber, window.psConversionId,
window.psPhoneConversionLabel);
this.headerBar_.appendChild(this.dialer_.el);
}
// Add the map button.
if (window.psMapLocation) {
this.mapButton_ =
new mob.button.Map(themeData.menuFrontColor, window.psMapLocation,
window.psConversionId, window.psMapConversionLabel);
this.headerBar_.appendChild(this.mapButton_.el);
}
// If we are in labeled mode or only 1 button is configured, then show the
// text next to it.
if (window.psLabeledMode || (this.dialer_ && !this.mapButton_) ||
(!this.dialer_ && this.mapButton_)) {
goog.dom.classlist.add(this.headerBar_,
mob.util.ElementClass.SHOW_BUTTON_TEXT);
}
this.addHeaderBarResizeEvents_();
this.addThemeColor_(themeData);
};
/**
* Insert a style tag at the end of the head with the theme colors. The member
* bool useDetectedThemeColor controls whether we use the color detected from
* mob_logo.js, or a predefined color.
* @param {!mob.util.ThemeData} themeData
* @private
*/
mob.Nav.prototype.addThemeColor_ = function(themeData) {
// Remove any prior style block.
if (this.styleTag_) {
this.styleTag_.parentNode.removeChild(this.styleTag_);
}
var backgroundColor = mob.util.colorNumbersToString(themeData.menuBackColor);
var color = mob.util.colorNumbersToString(themeData.menuFrontColor);
var css = '#' + mob.util.ElementId.HEADER_BAR + ' { background-color: ' +
backgroundColor + '; }\n' +
'#' + mob.util.ElementId.HEADER_BAR + ' * ' +
' { color: ' + color + '; }\n' +
'#' + mob.util.ElementId.NAV_PANEL + ' { background-color: ' +
color + '; }\n' +
'#' + mob.util.ElementId.NAV_PANEL + ' * ' +
' { color: ' + backgroundColor + '; }\n';
this.styleTag_ = document.createElement(goog.dom.TagName.STYLE);
this.styleTag_.type = 'text/css';
this.styleTag_.appendChild(document.createTextNode(css));
document.head.appendChild(this.styleTag_);
};
/**
* Check if the browser has a minimum font size set. This can cause issues
* because font-size and em units are used to scale the header bar.
* @private
* @return {boolean}
*/
mob.Nav.prototype.isMinimumFontSizeSet_ = function() {
// TODO(jud): Insert this div in c++ to prevent forcing a style recalculation
// here.
var testDiv = document.createElement(goog.dom.TagName.DIV);
document.body.appendChild(testDiv);
testDiv.style.fontSize = '1px';
var fontSize = window.getComputedStyle(testDiv).getPropertyValue('font-size');
fontSize = mob.util.pixelValue(fontSize);
document.body.removeChild(testDiv);
return (!fontSize || fontSize > mob.Nav.MIN_FONT_SIZE_);
};
/**
* Main entry point of nav mobilization. Should be called when logo detection is
* finished.
* @param {!mob.util.ThemeData} themeData
*/
mob.Nav.prototype.run = function(themeData) {
if (this.isMinimumFontSizeSet_()) {
return;
}
// Don't insert the header bar inside of an iframe.
if (mob.util.inFriendlyIframe()) {
return;
}
this.clampZIndex_();
this.findElementsToOffset_();
this.addHeaderBar_(themeData);
mob.util.sendBeaconEvent(mob.util.BeaconEvents.NAV_DONE);
window.addEventListener(goog.events.EventType.LOAD,
goog.bind(this.redraw_, this));
};
/**
* Updates header bar using the theme data.
* @param {!mob.util.ThemeData} themeData
*/
mob.Nav.prototype.updateTheme = function(themeData) {
// For now we just remove the existing header bar and spacer div and recreate
// them. This could be done more efficiently by updating just the style block
// and logo image but since this is only used for debug and config it should
// suffice for the time being.
this.headerBar_.remove();
this.spacerDiv_.remove();
this.spacerDiv_ = document.createElement(goog.dom.TagName.DIV);
this.spacerDiv_.id = mob.util.ElementId.SPACER;
this.headerBar_ = document.createElement(goog.dom.TagName.HEADER);
this.headerBar_.id = mob.util.ElementId.HEADER_BAR;
this.addHeaderBar_(themeData);
};