blob: 615840a8500f19a5885069072347d725bf7d1aa6 [file] [log] [blame]
// Copyright 2011 The Closure Library Authors. 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.
/**
* @fileoverview Class for showing simple modal popup.
* @author chrishenry@google.com (Chris Henry)
*/
goog.provide('goog.ui.ModalPopup');
goog.require('goog.Timer');
goog.require('goog.a11y.aria');
goog.require('goog.a11y.aria.State');
goog.require('goog.asserts');
goog.require('goog.dom');
goog.require('goog.dom.TagName');
goog.require('goog.dom.classlist');
goog.require('goog.dom.iframe');
goog.require('goog.events');
goog.require('goog.events.EventType');
goog.require('goog.events.FocusHandler');
goog.require('goog.fx.Transition');
goog.require('goog.string');
goog.require('goog.style');
goog.require('goog.ui.Component');
goog.require('goog.ui.PopupBase');
goog.require('goog.userAgent');
/**
* Base class for modal popup UI components. This can also be used as
* a standalone component to render a modal popup with an empty div.
*
* WARNING: goog.ui.ModalPopup is only guaranteed to work when it is rendered
* directly in the 'body' element.
*
* The Html structure of the modal popup is:
* <pre>
* Element Function Class-name, goog-modalpopup = default
* ----------------------------------------------------------------------------
* - iframe Iframe mask goog-modalpopup-bg
* - div Background mask goog-modalpopup-bg
* - div Modal popup area goog-modalpopup
* - span Tab catcher
* </pre>
* @constructor
* @param {boolean=} opt_useIframeMask Work around windowed controls z-index
* issue by using an iframe instead of a div for bg element.
* @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper; see {@link
* goog.ui.Component} for semantics.
* @extends {goog.ui.Component}
*/
goog.ui.ModalPopup = function(opt_useIframeMask, opt_domHelper) {
goog.ui.ModalPopup.base(this, 'constructor', opt_domHelper);
/**
* Whether the modal popup should use an iframe as the background
* element to work around z-order issues.
* @type {boolean}
* @private
*/
this.useIframeMask_ = !!opt_useIframeMask;
/**
* The element that had focus before the popup was displayed.
* @type {Element}
* @private
*/
this.lastFocus_ = null;
};
goog.inherits(goog.ui.ModalPopup, goog.ui.Component);
goog.tagUnsealableClass(goog.ui.ModalPopup);
/**
* Focus handler. It will be initialized in enterDocument.
* @type {goog.events.FocusHandler}
* @private
*/
goog.ui.ModalPopup.prototype.focusHandler_ = null;
/**
* Whether the modal popup is visible.
* @type {boolean}
* @private
*/
goog.ui.ModalPopup.prototype.visible_ = false;
/**
* Element for the background which obscures the UI and blocks events.
* @type {Element}
* @private
*/
goog.ui.ModalPopup.prototype.bgEl_ = null;
/**
* Iframe element that is only used for IE as a workaround to keep select-type
* elements from burning through background.
* @type {Element}
* @private
*/
goog.ui.ModalPopup.prototype.bgIframeEl_ = null;
/**
* Element used to catch focus and prevent the user from tabbing out
* of the popup.
* @type {Element}
* @private
*/
goog.ui.ModalPopup.prototype.tabCatcherElement_ = null;
/**
* Whether the modal popup is in the process of wrapping focus from the top of
* the popup to the last tabbable element.
* @type {boolean}
* @private
*/
goog.ui.ModalPopup.prototype.backwardTabWrapInProgress_ = false;
/**
* Transition to show the popup.
* @type {goog.fx.Transition}
* @private
*/
goog.ui.ModalPopup.prototype.popupShowTransition_;
/**
* Transition to hide the popup.
* @type {goog.fx.Transition}
* @private
*/
goog.ui.ModalPopup.prototype.popupHideTransition_;
/**
* Transition to show the background.
* @type {goog.fx.Transition}
* @private
*/
goog.ui.ModalPopup.prototype.bgShowTransition_;
/**
* Transition to hide the background.
* @type {goog.fx.Transition}
* @private
*/
goog.ui.ModalPopup.prototype.bgHideTransition_;
/**
* The elements set to aria-hidden when the popup was made visible.
* @type {Array<!Element>}
* @private
*/
goog.ui.ModalPopup.prototype.hiddenElements_;
/**
* @return {string} Base CSS class for this component.
* @protected
*/
goog.ui.ModalPopup.prototype.getCssClass = function() {
return goog.getCssName('goog-modalpopup');
};
/**
* Returns the background iframe mask element, if any.
* @return {Element} The background iframe mask element, may return
* null/undefined if the modal popup does not use iframe mask.
*/
goog.ui.ModalPopup.prototype.getBackgroundIframe = function() {
return this.bgIframeEl_;
};
/**
* Returns the background mask element.
* @return {Element} The background mask element.
*/
goog.ui.ModalPopup.prototype.getBackgroundElement = function() {
return this.bgEl_;
};
/**
* Creates the initial DOM representation for the modal popup.
* @override
*/
goog.ui.ModalPopup.prototype.createDom = function() {
// Create the modal popup element, and make sure it's hidden.
goog.ui.ModalPopup.base(this, 'createDom');
var element = this.getElement();
goog.asserts.assert(element);
var allClasses = goog.string.trim(this.getCssClass()).split(' ');
goog.dom.classlist.addAll(element, allClasses);
goog.dom.setFocusableTabIndex(element, true);
goog.style.setElementShown(element, false);
// Manages the DOM for background mask elements.
this.manageBackgroundDom_();
this.createTabCatcher_();
};
/**
* Creates and disposes of the DOM for background mask elements.
* @private
*/
goog.ui.ModalPopup.prototype.manageBackgroundDom_ = function() {
if (this.useIframeMask_ && !this.bgIframeEl_) {
// IE renders the iframe on top of the select elements while still
// respecting the z-index of the other elements on the page. See
// http://support.microsoft.com/kb/177378 for more information.
// Flash and other controls behave in similar ways for other browsers
this.bgIframeEl_ = goog.dom.iframe.createBlank(this.getDomHelper());
this.bgIframeEl_.className = goog.getCssName(this.getCssClass(), 'bg');
goog.style.setElementShown(this.bgIframeEl_, false);
goog.style.setOpacity(this.bgIframeEl_, 0);
}
// Create the backgound mask, initialize its opacity, and make sure it's
// hidden.
if (!this.bgEl_) {
this.bgEl_ = this.getDomHelper().createDom(
'div', goog.getCssName(this.getCssClass(), 'bg'));
goog.style.setElementShown(this.bgEl_, false);
}
};
/**
* Creates the tab catcher element.
* @private
*/
goog.ui.ModalPopup.prototype.createTabCatcher_ = function() {
// Creates tab catcher element.
if (!this.tabCatcherElement_) {
this.tabCatcherElement_ = this.getDomHelper().createElement('span');
goog.style.setElementShown(this.tabCatcherElement_, false);
goog.dom.setFocusableTabIndex(this.tabCatcherElement_, true);
this.tabCatcherElement_.style.position = 'absolute';
}
};
/**
* Allow a shift-tab from the top of the modal popup to the last tabbable
* element by moving focus to the tab catcher. This should be called after
* catching a wrapping shift-tab event and before allowing it to propagate, so
* that focus will land on the last tabbable element before the tab catcher.
* @protected
*/
goog.ui.ModalPopup.prototype.setupBackwardTabWrap = function() {
this.backwardTabWrapInProgress_ = true;
try {
this.tabCatcherElement_.focus();
} catch (e) {
// Swallow this. IE can throw an error if the element can not be focused.
}
// Reset the flag on a timer in case anything goes wrong with the followup
// event.
goog.Timer.callOnce(this.resetBackwardTabWrap_, 0, this);
};
/**
* Resets the backward tab wrap flag.
* @private
*/
goog.ui.ModalPopup.prototype.resetBackwardTabWrap_ = function() {
this.backwardTabWrapInProgress_ = false;
};
/**
* Renders the background mask.
* @private
*/
goog.ui.ModalPopup.prototype.renderBackground_ = function() {
goog.asserts.assert(!!this.bgEl_, 'Background element must not be null.');
if (this.bgIframeEl_) {
goog.dom.insertSiblingBefore(this.bgIframeEl_, this.getElement());
}
goog.dom.insertSiblingBefore(this.bgEl_, this.getElement());
};
/** @override */
goog.ui.ModalPopup.prototype.canDecorate = function(element) {
// Assume we can decorate any DIV.
return !!element && element.tagName == goog.dom.TagName.DIV;
};
/** @override */
goog.ui.ModalPopup.prototype.decorateInternal = function(element) {
// Decorate the modal popup area element.
goog.ui.ModalPopup.base(this, 'decorateInternal', element);
var allClasses = goog.string.trim(this.getCssClass()).split(' ');
goog.dom.classlist.addAll(
goog.asserts.assert(this.getElement()),
allClasses);
// Create the background mask...
this.manageBackgroundDom_();
this.createTabCatcher_();
// Make sure the decorated modal popup is focusable and hidden.
goog.dom.setFocusableTabIndex(this.getElement(), true);
goog.style.setElementShown(this.getElement(), false);
};
/** @override */
goog.ui.ModalPopup.prototype.enterDocument = function() {
this.renderBackground_();
goog.ui.ModalPopup.base(this, 'enterDocument');
goog.dom.insertSiblingAfter(this.tabCatcherElement_, this.getElement());
this.focusHandler_ = new goog.events.FocusHandler(
this.getDomHelper().getDocument());
// We need to watch the entire document so that we can detect when the
// focus is moved out of this modal popup.
this.getHandler().listen(
this.focusHandler_, goog.events.FocusHandler.EventType.FOCUSIN,
this.onFocus);
this.setA11YDetectBackground(false);
};
/** @override */
goog.ui.ModalPopup.prototype.exitDocument = function() {
if (this.isVisible()) {
this.setVisible(false);
}
goog.dispose(this.focusHandler_);
goog.ui.ModalPopup.base(this, 'exitDocument');
goog.dom.removeNode(this.bgIframeEl_);
goog.dom.removeNode(this.bgEl_);
goog.dom.removeNode(this.tabCatcherElement_);
};
/**
* Sets the visibility of the modal popup box and focus to the popup.
* @param {boolean} visible Whether the modal popup should be visible.
*/
goog.ui.ModalPopup.prototype.setVisible = function(visible) {
goog.asserts.assert(
this.isInDocument(), 'ModalPopup must be rendered first.');
if (visible == this.visible_) {
return;
}
if (this.popupShowTransition_) this.popupShowTransition_.stop();
if (this.bgShowTransition_) this.bgShowTransition_.stop();
if (this.popupHideTransition_) this.popupHideTransition_.stop();
if (this.bgHideTransition_) this.bgHideTransition_.stop();
if (this.isInDocument()) {
this.setA11YDetectBackground(visible);
}
if (visible) {
this.show_();
} else {
this.hide_();
}
};
/**
* Sets aria-hidden on the rest of the page to restrict screen reader focus.
* Top-level elements with an explicit aria-hidden state are not altered.
* @param {boolean} hide Whether to hide or show the rest of the page.
* @protected
*/
goog.ui.ModalPopup.prototype.setA11YDetectBackground = function(hide) {
if (hide) {
if (!this.hiddenElements_) {
this.hiddenElements_ = [];
}
var dom = this.getDomHelper();
var topLevelChildren = dom.getChildren(dom.getDocument().body);
for (var i = 0; i < topLevelChildren.length; i++) {
var child = topLevelChildren[i];
if (child != this.getElementStrict() &&
!goog.a11y.aria.getState(child, goog.a11y.aria.State.HIDDEN)) {
goog.a11y.aria.setState(child, goog.a11y.aria.State.HIDDEN, true);
this.hiddenElements_.push(child);
}
}
} else if (this.hiddenElements_) {
for (var i = 0; i < this.hiddenElements_.length; i++) {
goog.a11y.aria.removeState(
this.hiddenElements_[i], goog.a11y.aria.State.HIDDEN);
}
this.hiddenElements_ = null;
}
};
/**
* Sets the transitions to show and hide the popup and background.
* @param {!goog.fx.Transition} popupShowTransition Transition to show the
* popup.
* @param {!goog.fx.Transition} popupHideTransition Transition to hide the
* popup.
* @param {!goog.fx.Transition} bgShowTransition Transition to show
* the background.
* @param {!goog.fx.Transition} bgHideTransition Transition to hide
* the background.
*/
goog.ui.ModalPopup.prototype.setTransition = function(popupShowTransition,
popupHideTransition, bgShowTransition, bgHideTransition) {
this.popupShowTransition_ = popupShowTransition;
this.popupHideTransition_ = popupHideTransition;
this.bgShowTransition_ = bgShowTransition;
this.bgHideTransition_ = bgHideTransition;
};
/**
* Shows the popup.
* @private
*/
goog.ui.ModalPopup.prototype.show_ = function() {
if (!this.dispatchEvent(goog.ui.PopupBase.EventType.BEFORE_SHOW)) {
return;
}
try {
this.lastFocus_ = this.getDomHelper().getDocument().activeElement;
} catch (e) {
// Focus-related actions often throw exceptions.
// Sample past issue: https://bugzilla.mozilla.org/show_bug.cgi?id=656283
}
this.resizeBackground_();
this.reposition();
// Listen for keyboard and resize events while the modal popup is visible.
this.getHandler().listen(
this.getDomHelper().getWindow(), goog.events.EventType.RESIZE,
this.resizeBackground_);
this.showPopupElement_(true);
this.focus();
this.visible_ = true;
if (this.popupShowTransition_ && this.bgShowTransition_) {
goog.events.listenOnce(
/** @type {!goog.events.EventTarget} */ (this.popupShowTransition_),
goog.fx.Transition.EventType.END, this.onShow, false, this);
this.bgShowTransition_.play();
this.popupShowTransition_.play();
} else {
this.onShow();
}
};
/**
* Hides the popup.
* @private
*/
goog.ui.ModalPopup.prototype.hide_ = function() {
if (!this.dispatchEvent(goog.ui.PopupBase.EventType.BEFORE_HIDE)) {
return;
}
// Stop listening for keyboard and resize events while the modal
// popup is hidden.
this.getHandler().unlisten(
this.getDomHelper().getWindow(), goog.events.EventType.RESIZE,
this.resizeBackground_);
// Set visibility to hidden even if there is a transition. This
// reduces complexity in subclasses who may want to override
// setVisible (such as goog.ui.Dialog).
this.visible_ = false;
if (this.popupHideTransition_ && this.bgHideTransition_) {
goog.events.listenOnce(
/** @type {!goog.events.EventTarget} */ (this.popupHideTransition_),
goog.fx.Transition.EventType.END, this.onHide, false, this);
this.bgHideTransition_.play();
// The transition whose END event you are listening to must be played last
// to prevent errors when disposing on hide event, which occur on browsers
// that do not support CSS3 transitions.
this.popupHideTransition_.play();
} else {
this.onHide();
}
this.returnFocus_();
};
/**
* Attempts to return the focus back to the element that had it before the popup
* was opened.
* @private
*/
goog.ui.ModalPopup.prototype.returnFocus_ = function() {
try {
var dom = this.getDomHelper();
var body = dom.getDocument().body;
var active = dom.getDocument().activeElement || body;
if (!this.lastFocus_ || this.lastFocus_ == body) {
this.lastFocus_ = null;
return;
}
// We only want to move the focus if we actually have it, i.e.:
// - if we immediately hid the popup the focus should have moved to the
// body element
// - if there is a hiding transition in progress the focus would still be
// within the dialog and it is safe to move it if the current focused
// element is a child of the dialog
if (active == body || dom.contains(this.getElement(), active)) {
this.lastFocus_.focus();
}
} catch (e) {
// Swallow this. IE can throw an error if the element can not be focused.
}
// Explicitly want to null this out even if there was an error focusing to
// avoid bleed over between dialog invocations.
this.lastFocus_ = null;
};
/**
* Shows or hides the popup element.
* @param {boolean} visible Shows the popup element if true, hides if false.
* @private
*/
goog.ui.ModalPopup.prototype.showPopupElement_ = function(visible) {
if (this.bgIframeEl_) {
goog.style.setElementShown(this.bgIframeEl_, visible);
}
if (this.bgEl_) {
goog.style.setElementShown(this.bgEl_, visible);
}
goog.style.setElementShown(this.getElement(), visible);
goog.style.setElementShown(this.tabCatcherElement_, visible);
};
/**
* Called after the popup is shown. If there is a transition, this
* will be called after the transition completed or stopped.
* @protected
*/
goog.ui.ModalPopup.prototype.onShow = function() {
this.dispatchEvent(goog.ui.PopupBase.EventType.SHOW);
};
/**
* Called after the popup is hidden. If there is a transition, this
* will be called after the transition completed or stopped.
* @protected
*/
goog.ui.ModalPopup.prototype.onHide = function() {
this.showPopupElement_(false);
this.dispatchEvent(goog.ui.PopupBase.EventType.HIDE);
};
/**
* @return {boolean} Whether the modal popup is visible.
*/
goog.ui.ModalPopup.prototype.isVisible = function() {
return this.visible_;
};
/**
* Focuses on the modal popup.
*/
goog.ui.ModalPopup.prototype.focus = function() {
this.focusElement_();
};
/**
* Make the background element the size of the document.
*
* NOTE(user): We must hide the background element before measuring the
* document, otherwise the size of the background will stop the document from
* shrinking to fit a smaller window. This does cause a slight flicker in Linux
* browsers, but should not be a common scenario.
* @private
*/
goog.ui.ModalPopup.prototype.resizeBackground_ = function() {
if (this.bgIframeEl_) {
goog.style.setElementShown(this.bgIframeEl_, false);
}
if (this.bgEl_) {
goog.style.setElementShown(this.bgEl_, false);
}
var doc = this.getDomHelper().getDocument();
var win = goog.dom.getWindow(doc) || window;
// Take the max of document height and view height, in case the document does
// not fill the viewport. Read from both the body element and the html element
// to account for browser differences in treatment of absolutely-positioned
// content.
var viewSize = goog.dom.getViewportSize(win);
var w = Math.max(viewSize.width,
Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth));
var h = Math.max(viewSize.height,
Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight));
if (this.bgIframeEl_) {
goog.style.setElementShown(this.bgIframeEl_, true);
goog.style.setSize(this.bgIframeEl_, w, h);
}
if (this.bgEl_) {
goog.style.setElementShown(this.bgEl_, true);
goog.style.setSize(this.bgEl_, w, h);
}
};
/**
* Centers the modal popup in the viewport, taking scrolling into account.
*/
goog.ui.ModalPopup.prototype.reposition = function() {
// TODO(chrishenry): Make this use goog.positioning as in goog.ui.PopupBase?
// Get the current viewport to obtain the scroll offset.
var doc = this.getDomHelper().getDocument();
var win = goog.dom.getWindow(doc) || window;
if (goog.style.getComputedPosition(this.getElement()) == 'fixed') {
var x = 0;
var y = 0;
} else {
var scroll = this.getDomHelper().getDocumentScroll();
var x = scroll.x;
var y = scroll.y;
}
var popupSize = goog.style.getSize(this.getElement());
var viewSize = goog.dom.getViewportSize(win);
// Make sure left and top are non-negatives.
var left = Math.max(x + viewSize.width / 2 - popupSize.width / 2, 0);
var top = Math.max(y + viewSize.height / 2 - popupSize.height / 2, 0);
goog.style.setPosition(this.getElement(), left, top);
// We place the tab catcher at the same position as the dialog to
// prevent IE from scrolling when users try to tab out of the dialog.
goog.style.setPosition(this.tabCatcherElement_, left, top);
};
/**
* Handles focus events. Makes sure that if the user tabs past the
* elements in the modal popup, the focus wraps back to the beginning, and that
* if the user shift-tabs past the front of the modal popup, focus wraps around
* to the end.
* @param {goog.events.BrowserEvent} e Browser's event object.
* @protected
*/
goog.ui.ModalPopup.prototype.onFocus = function(e) {
if (this.backwardTabWrapInProgress_) {
this.resetBackwardTabWrap_();
} else if (e.target == this.tabCatcherElement_) {
goog.Timer.callOnce(this.focusElement_, 0, this);
}
};
/**
* Returns the magic tab catcher element used to detect when the user has
* rolled focus off of the popup content. It is automatically created during
* the createDom method() and can be used by subclasses to implement custom
* tab-loop behavior.
* @return {Element} The tab catcher element.
* @protected
*/
goog.ui.ModalPopup.prototype.getTabCatcherElement = function() {
return this.tabCatcherElement_;
};
/**
* Moves the focus to the modal popup.
* @private
*/
goog.ui.ModalPopup.prototype.focusElement_ = function() {
try {
if (goog.userAgent.IE) {
// In IE, we must first focus on the body or else focussing on a
// sub-element will not work.
this.getDomHelper().getDocument().body.focus();
}
this.getElement().focus();
} catch (e) {
// Swallow this. IE can throw an error if the element can not be focused.
}
};
/** @override */
goog.ui.ModalPopup.prototype.disposeInternal = function() {
goog.dispose(this.popupShowTransition_);
this.popupShowTransition_ = null;
goog.dispose(this.popupHideTransition_);
this.popupHideTransition_ = null;
goog.dispose(this.bgShowTransition_);
this.bgShowTransition_ = null;
goog.dispose(this.bgHideTransition_);
this.bgHideTransition_ = null;
goog.ui.ModalPopup.base(this, 'disposeInternal');
};