UNPKG

jsaction

Version:

Google's event delegation library

1,355 lines (1,208 loc) 53.4 kB
// Copyright 2005 Google Inc. All Rights Reserved. /** * * @fileoverview Implements the local event handling contract. This * allows DOM objects in a container that enters into this contract to * define event handlers which are executed in a local context. * * One EventContract instance can manage the contract for multiple * containers, which are added using the addContainer() method. * * Events can be registered using the addEvent() method. * * A Dispatcher is added using the dispatchTo() method. Until there is * a dispatcher, events are queued. The idea is that the EventContract * class is inlined in the HTML of the top level page and instantiated * right after the start of <body>. The Dispatcher class is contained * in the external deferred js, and instantiated and registered with * EventContract when the external javascript in the page loads. The * external javascript will also register the jsaction handlers, which * then pick up the queued events at the time of registration. * * Since this class is meant to be inlined in the main page HTML, the * size of the binary compiled from this file MUST be kept as small as * possible and thus its dependencies to a minimum. */ goog.provide('jsaction.EventContract'); goog.provide('jsaction.EventContractContainer'); goog.require('goog.dom.TagName'); goog.require('jsaction.Attribute'); goog.require('jsaction.Cache'); goog.require('jsaction.Char'); goog.require('jsaction.EventType'); goog.require('jsaction.Property'); goog.require('jsaction.createMouseEvent'); goog.require('jsaction.domGenerator'); goog.require('jsaction.event'); /** * EventContract intercepts events in the bubbling phase at the * boundary of a container element, and maps them to generic actions * which are specified using the custom jsaction attribute in * HTML. Behavior of the application is then specified in terms of * handler for such actions, cf. jsaction.Dispatcher in dispatcher.js. * * This has several benefits: (1) No DOM event handlers need to be * registered on the specific elements in the UI. (2) The set of * events that the application has to handle can be specified in terms * of the semantics of the application, rather than in terms of DOM * events. (3) Invocation of handlers can be delayed and handlers can * be delay loaded in a generic way. * * @constructor */ jsaction.EventContract = function() { /** * A list of functions. Each function will initialize a newly * registered contract for one event. See addContainer(). * * @type {!Array.<!jsaction.ContainerInitializerFunction>} * @private */ this.installers_ = []; /** * The containers signed up for this event contract. See addContainer(). * * @type {!Array.<!jsaction.EventContractContainer>} * @private */ this.containers_ = []; /** * The list of containers that are children of an existing container. If * STOP_PROPAGATION is false then we do not install event listeners on these * (since that would cause the event to be triggered more than once). We do * want to keep track of these containers such that we properly handle * additions/removals. * If STOP_PROPAGATION is true it is safe to add event listeners on all the * containers. * @type {!Array.<!jsaction.EventContractContainer>} * @private */ this.nestedContainers_ = []; /** * The DOM events which this contract covers. Used to prevent double * registration of event types. The value of the map is the * internally created DOM event handler function that handles the * DOM events. See addEvent(). * * @type {!Object.<string, !jsaction.EventHandlerFunction>} * @private */ this.events_ = {}; /** * The dispatcher function. Events are passed to this function for * handling once it was set using the dispatchTo() method. Usually * the dispatcher is the bound dispatch() method of a * jsaction.Dispatcher instance. This is done because the function * is passed from another jsbinary, so passing the instance and * invoking the method here would require to leave the method * unobfuscated. * * @type {?function((!jsaction.EventInfo|!Array.<!jsaction.EventInfo>), * boolean=)} * @private */ this.dispatcher_ = null; /** * The queue of events. Events are queued while there is no * dispatcher set. * @type {Array.<!jsaction.EventInfo>} * @private */ this.queue_ = []; if (jsaction.EventContract.CUSTOM_EVENT_SUPPORT) { this.addEvent(jsaction.EventType.CUSTOM); } }; /** * @define {boolean} Controls the use of event.path logic for the dom * walking in createEventInfo_. */ goog.define('jsaction.EventContract.USE_EVENT_PATH', false); /** * Whether the user agent is running on iOS. * @type {boolean} * @private */ jsaction.EventContract.isIos_ = typeof navigator != 'undefined' && /iPhone|iPad|iPod/.test(navigator.userAgent); /** * @define {boolean} Support for jsnamespace attribute. This flag can be * overriden in a build rule to trim down the EventContract's binary size. */ goog.define('jsaction.EventContract.JSNAMESPACE_SUPPORT', true); /** * @define {boolean} Support for accessible click actions. This flag can be * overriden in a build rule. */ goog.define('jsaction.EventContract.A11Y_CLICK_SUPPORT', false); /** * @define {boolean} Support for the non-bubbling mouseenter and mouseleave * events. This flag can be overridden in a build rule. */ goog.define('jsaction.EventContract.MOUSE_SPECIAL_SUPPORT', false); /** * @define {boolean} Simulate click events based on touch events for browsers * that have a 300ms delay before they send the click event. This is * currently EXPERIMENTAL. */ goog.define('jsaction.EventContract.FAST_CLICK_SUPPORT', false); /** * @define {boolean} Call stopPropagation on handled events. When integrating * with non-jsaction event handler based code, you will likely want to turn * this flag off. While most event handlers will continue to work, jsaction * binds focus and blur events in the capture phase and thus with * stopPropagation, none of your non-jsaction-handlers will ever see it. */ goog.define('jsaction.EventContract.STOP_PROPAGATION', true); /** * @define {boolean} Support for custom events, which are type * jsaction.EventType.CUSTOM. These are native DOM events with an * additional type field and an optional payload. */ goog.define('jsaction.EventContract.CUSTOM_EVENT_SUPPORT', false); /** * Specifies a click jsaction event type triggered by an Enter/Space DOM * keypress. * @private {string} * @const */ jsaction.EventContract.CLICKKEY_ = 'clickkey'; /** * Helper function to trim whitespace from the beginning and the end * of the string. This deliberately doesn't use the closure equivalent * to keep dependencies small. * * @param {string} str Input string. * @return {string} Trimmed string. * @private */ jsaction.EventContract.stringTrim_ = String.prototype.trim ? function(str) { return str.trim(); } : function(str) { var trimmedLeft = str.replace(/^\s+/, ''); return trimmedLeft.replace(/\s+$/, ''); }; /** * This regular expression matches a semicolon. * @type {RegExp} * @private * @const */ jsaction.EventContract.REGEXP_SEMICOLON_ = /\s*;\s*/; /** * The default event type. * @type {string} * @private */ jsaction.EventContract.defaultEventType_ = jsaction.EventType.CLICK; /** * Information about an element that received a touchstart event * that we might want to translate into a click event once a touchend * event arrives. * * - node is the target element of the event. * - x and y are clientX and clientY of the event, respectively. * * The fields of this Object are unquoted. * * This object is reset when either touchend arrives within a short period of * time or when "fast click" is canceled due to touchmove or expiration. See * "resetFastClickNode_" method for more detail. * * @private {?{node: !Element, x: number, y: number}} */ jsaction.EventContract.fastClickNode_; /** * The last emitted touchend event. It's used to ignore subsequent mouse * events when requested by "_preventMouseEvents". * @private {?Event} */ jsaction.EventContract.preventingMouseEvents_ = null; /** * A timer that we schedule after a touchstart. If the timer fires before the * touchend event, the press is considered a long-press that does not get * translated into a click. * @private {number} */ jsaction.EventContract.fastClickTimeout_; /** * Gets the default event type. * @return {string} The default event type. */ jsaction.EventContract.getDefaultEventType = function() { return jsaction.EventContract.defaultEventType_; }; /** * Sets a new default event type. * @param {string} eventType The new default event type. */ jsaction.EventContract.setDefaultEventType = function(eventType) { jsaction.EventContract.defaultEventType_ = eventType; }; /** * Returns a function that handles events on a container and invokes a local * event handler (bound using the actions map) on the source node or any of * its ancestors up to the container to which the returned event handler * belongs. The local event handler is passed an ActionFlow which allows * access to the node, event, and values defined on the node. If there are no * jsaction handlers bound that can handle this event, the flow representing * the event is stored in a queue for replaying at a later time. * * @param {!jsaction.EventContract} eventContract The EventContract * instance to create this handler for. * @param {string} eventType The type of the event - e.g. 'click'. * Note that event.type can differ from eventType. In some browsers (e.g. * Firefox) the event handling code registers handlers for 'focusin' and * 'focusout' to handle 'focus' and 'blur' respectively. In those cases, * event.type might be 'focus', but eventType will be 'focusin'. * @return {jsaction.EventHandlerFunction} The DOM event handler to * use for the given event type on all containers. * @private */ jsaction.EventContract.eventHandler_ = function(eventContract, eventType) { /** * See description above. * @param {!Event} e Event. * @this {!Element} */ var handler = function(e) { var container = this; // Store eventType's value in a local variable so that multiple calls do not // modify the shared eventType variable. var eventTypeForDispatch = eventType; if (jsaction.EventContract.CUSTOM_EVENT_SUPPORT && eventTypeForDispatch == jsaction.EventType.CUSTOM) { var detail = e['detail']; // For custom events, use a secondary dispatch based on the internal // custom type of the event. if (!detail || !detail['_type']) { // This should never happen. return; } eventTypeForDispatch = detail['_type']; } var eventInfo = jsaction.EventContract.createEventInfo_( eventTypeForDispatch, e, container); if (eventContract.dispatcher_) { var globalEventInfo = jsaction.EventContract.createEventInfoInternal_( eventInfo['eventType'], eventInfo['event'], eventInfo['targetElement'], eventInfo['action'], eventInfo['actionElement'], eventInfo['timeStamp']); // In some cases, createEventInfo_() will rewrite click events to // clickonly. Revert back to a regular click, otherwise we won't be able // to execute global event handlers registered on click events. if (globalEventInfo['eventType'] == jsaction.EventType.CLICKONLY) { globalEventInfo['eventType'] = jsaction.EventType.CLICK; } eventContract.dispatcher_( globalEventInfo, /* dispatch global event */ true); } // Return early if no action element found while walking up the DOM tree. if (!eventInfo['actionElement']) { return; } if (jsaction.EventContract.STOP_PROPAGATION) { if (jsaction.event.isGecko && (eventInfo['targetElement'].tagName == goog.dom.TagName.INPUT || eventInfo['targetElement'].tagName == goog.dom.TagName.TEXTAREA) && (eventInfo['eventType'] == jsaction.EventType.FOCUS)) { // Do nothing since stopping propagation a focus event on an input // element in Firefox makes the text cursor disappear: // https://bugzilla.mozilla.org/show_bug.cgi?id=509684 } else { // Since we found a jsaction, prevent other handlers from seeing // this event. jsaction.event.stopPropagation(e); } } // Prevent browser from following <a> node links if a jsaction is // present. Note that the targetElement may be a child of an anchor that has // a jsaction attached. For that reason, we need to check the actionElement // rather than the targetElement. if (eventInfo['actionElement'].tagName == goog.dom.TagName.A && (eventInfo['eventType'] == jsaction.EventType.CLICK || eventInfo['eventType'] == jsaction.EventType.CLICKMOD)) { jsaction.event.preventDefault(e); } if (eventContract.dispatcher_) { eventContract.dispatcher_(eventInfo); } else { var copiedEvent = jsaction.event.maybeCopyEvent(e); // The event is queued since there is no dispatcher registered // yet. Potentially make a copy of the event in order to extend its // life. The copy will later be used when attempting to replay. eventInfo['event'] = copiedEvent; eventContract.queue_.push(eventInfo); } jsaction.EventContract.afterEventHandler_(eventInfo); }; return handler; }; /** * Post-processes event. Called after event has been sent to the handler. * @param {!jsaction.EventInfo} eventInfo * @private */ jsaction.EventContract.afterEventHandler_ = function(eventInfo) { // Setup sweeper if mouse events have been canceled. if (eventInfo.event.type == jsaction.EventType.TOUCHEND && jsaction.event.isMouseEventsPrevented(eventInfo.event)) { jsaction.EventContract.preventingMouseEvents_ = /** @type {!Event} */ ( jsaction.event.recreateTouchEventAsClick(eventInfo.event)); } }; /** * Searches for a jsaction that the DOM event maps to and creates an * object containing event information used for dispatching by * jsaction.Dispatcher. The dispatch information returned consists of * the event type, target element, action and the Event instance * supplied by the DOM. The jsaction for the DOM event is the first * jsaction attribute above the target Node of the event, and below * the container Node, that specifies a jsaction for the event * type. If no such jsaction is found, the actionElement properties is null. * * @param {string} eventType The type of the event, e.g. 'click', as * specified by event contract. This may differ from the DOM event * type, because event contract may use more generic event types. * @param {!Event} e The Event instance received by the container from * the DOM. * @param {!Node} container The container which limits the search for * jsactions which can handle the event. * @return {jsaction.EventInfo} The event info object. If its actionElement * property is null, no jsaction was found above the target Node of the * event. * @private */ jsaction.EventContract.createEventInfo_ = function(eventType, e, container) { // We distinguish modified and plain clicks in order to support the // default browser behavior of modified clicks on links; usually to // open the URL of the link in new tab or new window on ctrl/cmd // click. A DOM 'click' event is mapped to the jsaction 'click' // event iff there is no modifier present on the event. If there is // a modifier, it's mapped to 'clickmod' instead. // // It's allowed to omit the event in the jsaction attribute. In that // case, 'click' is assumed. Thus the following two are equivalent: // // <a href="someurl" jsaction="gna.fu"> // <a href="someurl" jsaction="click:gna.fu"> // // For unmodified clicks, EventContract invokes the jsaction // 'gna.fu'. For modified clicks, EventContract won't find a // suitable action and and leave the event to be handled by the // browser. // // In order to also invoke a jsaction handler for a modifier click, // 'clickmod' needs to be used: // // <a href="someurl" jsaction="clickmod:gna.fu"> // // EventContract invokes the jsaction 'gna.fu' for modified // clicks. Unmodified clicks are left to the browser. // // In order to set up the event contract to handle both clickonly and // clickmod, only addEvent(jsaction.EventType.CLICK) is necessary. // // In order to set up the event contract to handle click, // addEvent() is necessary for CLICK, KEYDOWN, and KEYPRESS event types. If // the jsaction.EventContract.A11Y_CLICK_SUPPORT flag is turned on, addEvent() // will set up the appropriate key event handler automatically. if (eventType == jsaction.EventType.CLICK && jsaction.event.isModifiedClickEvent(e)) { eventType = jsaction.EventType.CLICKMOD; } else if (jsaction.EventContract.A11Y_CLICK_SUPPORT && jsaction.event.isActionKeyEvent(e)) { eventType = jsaction.EventContract.CLICKKEY_; } var target = /** @type {!Element} */(e.srcElement || e.target); var eventInfo = jsaction.EventContract.createEventInfoInternal_( eventType, e, target, '', null); // NOTE(user): In order to avoid complicating the code that calculates the // event's path, we need a common interface to iterating over event.path or // walking the DOM. We use the generator pattern here, as generating the // path array ahead of time for DOM walks will result in degraded // performance. /** @type {jsaction.ActionInfo} */ var actionInfo; // NOTE(user): This is a work around some issues with custom dispatchers. var element; if (jsaction.EventContract.USE_EVENT_PATH) { var generator = jsaction.domGenerator.getGenerator( e, target, /** @type {!Element} */(container)); for (var node; node = generator.next();) { element = node; actionInfo = jsaction.EventContract.getAction_( element, eventType, e, container); eventInfo = jsaction.EventContract.createEventInfoInternal_( actionInfo.eventType, actionInfo.event || e, target, actionInfo.action || '', element, eventInfo['timeStamp']); // TODO(user): If we can get rid of the break on actionInfo.ignore // these loops can collapse down to one and the contents can live in // a function. // Stop walking the DOM prematurely if we will ignore this event. This is // used solely for fastbutton's implementation. if (actionInfo.ignore || // An event is handled by at most one jsaction. Thus we stop at // the first matching jsaction specified in a jsaction attribute // up the ancestor chain of the event target node. actionInfo.action) { break; } } } else { for (var node = target; node && node != container; // Walk to the parent node, unless the node has a different owner in // which case we walk to the owner. node = node[jsaction.Property.OWNER] || node.parentNode) { element = node; actionInfo = jsaction.EventContract.getAction_( element, eventType, e, container); // Stop walking the DOM prematurely if we will ignore this event. This is // used solely for fastbutton's implementation. if (actionInfo.ignore || // An event is handled by at most one jsaction. Thus we stop at // the first matching jsaction specified in a jsaction attribute // up the ancestor chain of the event target node. actionInfo.action) { break; } } if (actionInfo) { eventInfo = jsaction.EventContract.createEventInfoInternal_( actionInfo.eventType, actionInfo.event || e, target, actionInfo.action || '', element, eventInfo['timeStamp']); } } // A touchend is "enhanced" to support mouse-events canceling. if (eventInfo && eventInfo['eventType'] == jsaction.EventType.TOUCHEND) { jsaction.event.addPreventMouseEventsSupport(eventInfo['event']); } if (actionInfo && actionInfo.action) { // Prevent scrolling if the Space key was pressed and prevent the browser's // default action for native HTML controls. if (jsaction.EventContract.A11Y_CLICK_SUPPORT && eventType == jsaction.EventContract.CLICKKEY_ && (jsaction.event.isSpaceKeyEvent(e) || jsaction.event.shouldCallPreventDefaultOnNativeHtmlControl(e))) { jsaction.event.preventDefault(e); } // We attempt to handle the mouseenter/mouseleave events here by // detecting whether the mouseover/mouseout events correspond to // entering/leaving an element. if (jsaction.EventContract.MOUSE_SPECIAL_SUPPORT && (eventType == jsaction.EventType.MOUSEENTER || eventType == jsaction.EventType.MOUSELEAVE)) { // We attempt to handle the mouseenter/mouseleave events here by // detecting whether the mouseover/mouseout events correspond to // entering/leaving an element. if (jsaction.event.isMouseSpecialEvent(e, eventType, element)) { // If both mouseover/mouseout and mouseenter/mouseleave events are // enabled, two separate handlers for mouseover/mouseout are // registered. Both handlers will see the same event instance // so we create a copy to avoid interfering with the dispatching of // the mouseover/mouseout event. var copiedEvent = jsaction.event.createMouseSpecialEvent( e, /** @type {!Element} */ (element)); eventInfo['event'] = /** @type {!Event} */ (copiedEvent); // Since the mouseenter/mouseleave events do not bubble, the target // of the event is technically the node on which the jsaction is // specified (the actionElement). eventInfo['targetElement'] = element; } else { eventInfo['action'] = ''; eventInfo['actionElement'] = null; } } return eventInfo; } // Reset action-related properties of the current eventInfo, to ensure we // won't dispatch a non-existing action. eventInfo['action'] = ''; eventInfo['actionElement'] = null; return eventInfo; }; /** * @param {string} eventType * @param {!Event} e * @param {!Element} targetElement * @param {string} action * @param {Element} actionElement * @param {number=} opt_timeStamp * @return {jsaction.EventInfo} * @private */ jsaction.EventContract.createEventInfoInternal_ = function( eventType, e, targetElement, action, actionElement, opt_timeStamp) { // Event#timeStamp is broken on Firefox for synthetic events. See // https://bugzilla.mozilla.org/show_bug.cgi?id=238041 for details. Since // Firefox marks Event#timeStamp as read-only, the only workaround is to // expose a timestamp directly in eventInfo, to be consistent across all // browsers. return /** @type {jsaction.EventInfo} */ ({ 'eventType': eventType, 'event': e, 'targetElement': targetElement, 'action': action, 'actionElement': actionElement, 'timeStamp': opt_timeStamp || goog.now() }); }; /** * Accesses the event handler attribute value of a DOM node. It guards * against weird situations (described in the body) that occur in * connection with nodes that are removed from their document. * @param {!Element} node The DOM node. * @param {string} attribute The name of the attribute to access. * @return {?string} The attribute value if it was found, null * otherwise. * @private */ jsaction.EventContract.getAttr_ = function(node, attribute) { var value = null; // NOTE(user): Nodes in IE do not always have a getAttribute // method defined. This is the case where sourceElement has in // fact been removed from the DOM before eventContract begins // handling - where a parentNode does not have getAttribute // defined. // NOTE(ruilopes): We must use the 'in' operator instead of the regular dot // notation, since the latter fails in IE8 if the getAttribute method is not // defined. See b/7139109. if ('getAttribute' in node) { value = node.getAttribute(attribute); } return value; }; /** * Since maps from event to action are immutable we can use a single map * to represent the empty map. * @private @const {!Object.<string, string>} */ jsaction.EventContract.EMPTY_ACTION_MAP_ = {}; /** * Accesses the jsaction map on a node and retrieves the name of the * action the given event is mapped to, if any. It parses the * attribute value and stores it in a property on the node for * subsequent retrieval without re-parsing and re-accessing the * attribute. In order to fully qualify jsaction names using a * namespace, the DOM is searched starting at the current node and * going through ancestor nodes until a jsnamespace attribute is * found. * * @param {!Element} node The DOM node to retrieve the jsaction map * from. * @param {string} eventType The type of the event for which to * retrieve the action. * @param {!Event} event The current browser event. * @param {!Node} container The node which limits the namespace lookup * for a jsaction name. The container node itself will not be * searched. * @return {jsaction.ActionInfo} The action info. * @private */ jsaction.EventContract.getAction_ = function(node, eventType, event, container) { var actionMap = jsaction.Cache.get(node); if (!actionMap) { var attvalue = jsaction.EventContract.getAttr_( node, jsaction.Attribute.JSACTION); if (!attvalue) { actionMap = jsaction.EventContract.EMPTY_ACTION_MAP_; jsaction.Cache.set(node, actionMap); } else { actionMap = jsaction.Cache.getParsed(attvalue); if (!actionMap) { actionMap = {}; var values = attvalue.split(jsaction.EventContract.REGEXP_SEMICOLON_); for (var i = 0, I = values ? values.length : 0; i < I; i++) { var value = values[i]; if (!value) { continue; } var colon = value.indexOf(jsaction.Char.EVENT_ACTION_SEPARATOR); var hasColon = colon != -1; var type = hasColon ? jsaction.EventContract.stringTrim_(value.substr(0, colon)) : jsaction.EventContract.defaultEventType_; var action = hasColon ? jsaction.EventContract.stringTrim_( value.substr(colon + 1)) : value; actionMap[type] = action; } jsaction.Cache.setParsed(attvalue, actionMap); } // If namespace support is active we need to augment the (potentially // cached) jsaction mapping with the namespace. if (jsaction.EventContract.JSNAMESPACE_SUPPORT) { var noNs = actionMap; actionMap = {}; for (var type in noNs) { actionMap[type] = jsaction.EventContract.getQualifiedName_( noNs[type], node, container); } } jsaction.Cache.set(node, actionMap); } } if (jsaction.EventContract.A11Y_CLICK_SUPPORT) { if (eventType == jsaction.EventContract.CLICKKEY_) { // A 'click' triggered by a DOM keypress should be mapped to the 'click' // jsaction. eventType = jsaction.EventType.CLICK; } else if (eventType == jsaction.EventType.CLICK && !actionMap[jsaction.EventType.CLICK]) { // A 'click' triggered by a DOM click should be mapped to the 'click' // jsaction, if available, or else fallback to the 'clickonly' jsaction. // If 'click' and 'clickonly' jsactions are used together, 'click' will // prevail. eventType = jsaction.EventType.CLICKONLY; } } var overrideEvent = null; if (jsaction.EventContract.FAST_CLICK_SUPPORT && // Don't want fast click behavior? Just bind clickonly instead. actionMap[jsaction.EventType.CLICK]) { var fastEvent = jsaction.EventContract.getFastClickEvent_(node, event, actionMap); if (!fastEvent) { // Null means to stop looking for further events, as the logic event // has already been handled or the event started a sequence that may // eventually lead to a logic click event. return { eventType: eventType, action: '', event: null, ignore: true }; } else if (fastEvent != event) { overrideEvent = fastEvent; eventType = fastEvent.type; } } // An empty action indicates that no jsaction attribute was found in the given // DOM node. var actionName = actionMap[eventType] || ''; return { eventType: eventType, action: actionName, event: overrideEvent, ignore: false }; }; /** * Returns the qualified jsaction name, i.e. the name of the jsaction * including the namespace part before the dot. If the given jsaction * name doesn't already contain the namespace, the function iterates * over ancestor nodes until a jsnamespace attribute is found, and * uses the value of that attribute as the namespace. * * @param {string} name The jsaction name to resolve the namespace of. * @param {Element} start The node from which to start searching for a * jsnamespace attribute. * @param {Node} container The node which limits the search for a * jsnamespace attribute. This node will be searched. * @return {string} The qualified name of the jsaction. If no * namespace is found, returns the unqualified name in case it * exists in the global namespace. * @private */ jsaction.EventContract.getQualifiedName_ = function(name, start, container) { if (jsaction.EventContract.JSNAMESPACE_SUPPORT) { if (jsaction.EventContract.isQualifiedName_(name)) { return name; } for (var node = start; node; node = node.parentNode) { var ns = jsaction.EventContract.getNamespace_( /** @type {!Element} */(node)); if (ns) { return ns + jsaction.Char.NAMESPACE_ACTION_SEPARATOR + name; } // If this node is the container, stop. if (node == container) { break; } } } return name; }; /** * Converts a sequence of touchstart and touchend events into a click event * and ignores a subsequent click event (within 400ms). * * This method returns the original or a synthesized event instance, or null if * the event should be ignored. The original event indicates that the original * event should proceed as planned. If the event should be ignored (e.g. to * issue a new event later), the returned value is null. However, if the * "fast click" event is determined, a newly synthesized event instance is * returned. The "click" event has to be synthesized to imitate an actual * "click" event based on "touchend". This includes filling in type, target, * clientX/Y, etc, which are expected from a "click" event. The original Event * instance cannot simply be modified, because the DOM Event Spec defines Event * properties to be immutable, and some browsers (specifically Safari in iOS/8) * enforce this. * * @param {!Element} node The current node with a jsaction annotation. * @param {!Event} event The current browser event. * @param {!Object.<string, string>} actionMap * @return {Event} * @private */ jsaction.EventContract.getFastClickEvent_ = function(node, event, actionMap) { if (event.type == jsaction.EventType.CLICK) { return event; } if (event.targetTouches && event.targetTouches.length > 1) { // Click emulation does not make sense for multi touch. return event; } var fastClickNode = jsaction.EventContract.fastClickNode_; var target = event.target; if (target) { // Don't do anything special for clicks on elements with elaborate built in // click and focus behavior. if (jsaction.EventContract.isInput_(target)) { return event; } } var touch = jsaction.event.getTouchData(event); // When a touchstart is fired, remember the action node in a global variable. // When a subsequent touchend arrives, it'll be interpreted as a click. if (event.type == jsaction.EventType.TOUCHSTART && // If the jsaction binds touchstart or touchend explicitly, we don't do // anything special with it. !actionMap[jsaction.EventType.TOUCHSTART] && !actionMap[jsaction.EventType.TOUCHEND]) { jsaction.EventContract.fastClickNode_ = { node: node, x: touch ? touch.clientX : 0, y: touch ? touch.clientY : 0}; jsaction.EventContract.preventingMouseEvents_ = null; clearTimeout(jsaction.EventContract.fastClickTimeout_); // If touchend doesn't arrive within a reasonable amount of time, this is // a long click and not a click, so we throw away and will ignore // a later touchend. jsaction.EventContract.fastClickTimeout_ = setTimeout( jsaction.EventContract.resetFastClickNode_, 400); return null; } // If a touchend was fired on what had a previous touchstart, count the event // as a click. else if (event.type == jsaction.EventType.TOUCHEND && fastClickNode && fastClickNode.node == node) { // If the touchend is more than 4px Manhattan away from the touchstart event // don't consider this a click even when on the same element. This is // necessary when dragging an element and mousemove events are cancelled. if (!event.defaultPrevented && !(touch && (Math.abs(touch.clientX - fastClickNode.x) + Math.abs(touch.clientY - fastClickNode.y)) > 4)) { var newEvent = /** @type {!Event} */ (jsaction.event. recreateTouchEventAsClick(event)); jsaction.EventContract.preventingMouseEvents_ = newEvent; // Cancel "touchend" and send the emulated "click" event. event.stopPropagation(); event.preventDefault(); var clickEvent = jsaction.createMouseEvent(newEvent); clickEvent['_fastclick'] = true; newEvent.target.dispatchEvent(clickEvent); if (!clickEvent.defaultPrevented) { // Remove the virtual keyboard since it's the default "touchend" // behavior that we cancelled above. if (document.activeElement && document.activeElement != clickEvent.target && jsaction.EventContract.isInput_(document.activeElement)) { try { document.activeElement.blur(); } catch (e) { // ignore } } // Reset selection as well. This normally done on "mousedown", but // we cancel mouse events. try { window.getSelection().removeAllRanges(); } catch (e) { // ignore } } return null; } else { jsaction.EventContract.resetFastClickNode_(); } } // Touchmove is fired when the user scrolls. In this case a previous // touchstart is ignored. else if (event.type == jsaction.EventType.TOUCHMOVE && fastClickNode) { // Ignore jitters: iOS often sends +/- 2px touchmove events. Thus we will // ignore any moves with the Manhattan distance 4 pixels or less. if (touch && (Math.abs(touch.clientX - fastClickNode.x) + Math.abs(touch.clientY - fastClickNode.y)) > 4) { jsaction.EventContract.resetFastClickNode_(); } } return event; }; /** * Returns true if the specified element is an input. * @param {!EventTarget} target * @return {boolean} * @private */ jsaction.EventContract.isInput_ = function(target) { var tagName = target.tagName || ''; return (tagName == 'TEXTAREA' || tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'OPTION'); }; /** * Cancels the expectation that there might come a touchend to after a * touchstart, so we can synthesize a click. * @private */ jsaction.EventContract.resetFastClickNode_ = function() { jsaction.EventContract.fastClickNode_ = null; }; /** * On mobile browsers, touchend is typically followed by an emulated sequence of * mouse events. In "fastclick" emulation and similar use cases these events are * no longer necessary and could lead to duplicate event processing. Instead, * the handler can instruct to cancel mouse events following "touchend" event by * using the "_preventMouseEvents" method. * @param {!Event} event * @private */ jsaction.EventContract.sweepupPreventedMouseEvents_ = function(event) { if (event['_fastclick']) { // It's the "fastclick" we issued - proceed uninterrupted. return; } var fastClickEvent = jsaction.EventContract.preventingMouseEvents_; if (!fastClickEvent) { // No recent "fastclick" - proceed uninterrupted. return; } // The mouse event has to arrive for a previously issued "fastclick" event // within a short period of time, 800 milliseconds in this case. This value // comes from the fact that after TOUCHEND iOS Safari typically issues // MOUSEENTER, MOUSEOVER, MOUSEMOVE, MOUSEDOWN, MOUSEUP and finally CLICK // event. The tests show that iOS issues these events one at a time with // about 50-60 milliseconds in between, which sums up to about ~350-400 // milliseconds. // // Tests on Firefox 32 have shown up to 800+ milliseconds from TOUCHEND to the // final CLICK event being emitted. // // Increasing this value is benign. It will not prevent double clicks since // any subsequent TOUCHSTART will cancel this sweep. if (goog.now() - fastClickEvent.timeStamp > 800) { jsaction.EventContract.preventingMouseEvents_ = null; return; } // The simplest case is when the target of both events is the same. However, // it's not always the case, as when the content is scrolled or when // overlays are shown quickly after click. In the latter case, we have to // measure the distance between the events. var isSameTarget = fastClickEvent.target == event.target; // Similar to "touchend" sometimes there can be a drift of the click event. // In tests it never was more than 2px in either direction, thus we // check for 4px Manhattan distance. var isNear = (Math.abs(event.clientX - fastClickEvent.clientX) + Math.abs(event.clientY - fastClickEvent.clientY)) <= 4; // If neither condition is true all mouse-events canceling for all subsequent // mouse events is canceled. if (!isSameTarget && !isNear) { jsaction.EventContract.preventingMouseEvents_ = null; return; } // We stop propagation and cancel event to avoid elements receiving the // event twice. event.stopPropagation(); event.preventDefault(); // No mouse events expected after click - stop monitoring. if (event.type == jsaction.EventType.CLICK) { jsaction.EventContract.preventingMouseEvents_ = null; } }; if (jsaction.EventContract.JSNAMESPACE_SUPPORT) { /** * Checks if a jsaction name contains a namespace part. * @param {string} name The name of a jsaction. * @return {boolean} Whether the name contains a namespace part. * @private */ jsaction.EventContract.isQualifiedName_ = function(name) { return name.indexOf(jsaction.Char.NAMESPACE_ACTION_SEPARATOR) >= 0; }; /** * Returns the value of the jsnamespace attribute of the given node. * Also caches the value for subsequent lookups. * @param {!Element} node The node whose jsnamespace attribute is being * asked for. * @return {?string} The value of the jsnamespace attribute, or null if not * found. * @private */ jsaction.EventContract.getNamespace_ = function(node) { var jsnamespace = jsaction.Cache.getNamespace(node); // Only query for the attribute if it has not been queried for // before. jsaction.EventContract.getAttr_() returns null if an // attribute is not present. Thus, jsnamespace is string|null if // the query took place in the past, or undefined if the query did // not take place. if (!goog.isDef(jsnamespace)) { jsnamespace = jsaction.EventContract.getAttr_(node, jsaction.Attribute.JSNAMESPACE); jsaction.Cache.setNamespace(node, jsnamespace); } return jsnamespace; }; } /** * Factory for container installer functions. The returned function * will install the given handler for the event given by name here on * the container passed to the returned function. It is used to * register all currently known events on a newly registered * container. * * @param {string} name The name of the event. * @param {jsaction.EventHandlerFunction} handler An event handler. * @return {jsaction.ContainerInitializerFunction} A function that, when * applied to an Element, installs the given event handler for the * event type given by name. * @private */ jsaction.EventContract.containerHandlerInstaller_ = function(name, handler) { /** * @param {!Element} div The container to install this handler on. * @return {jsaction.EventHandlerInfo} The event name and the * handler installed by the function. */ var installer = function(div) { return jsaction.event.addEventListener(div, name, handler); }; return installer; }; /** * Enables jsaction handlers to be called for the event type given by * name. * * If the event is already registered, this does nothing. * * @param {string} name Event name. * @param {string=} opt_prefixedName If supplied, this event is used in * the actual browser event registration instead of the name that is * exposed to jsaction. Use this if you e.g. want users to be able * to subscribe to jsaction="transitionEnd:foo" while the underlying * event is webkitTransitionEnd in one browser and mozTransitionEnd * in another. */ jsaction.EventContract.prototype.addEvent = function(name, opt_prefixedName) { if (this.events_.hasOwnProperty(name)) { return; } if (!jsaction.EventContract.MOUSE_SPECIAL_SUPPORT && (name == jsaction.EventType.MOUSEENTER || name == jsaction.EventType.MOUSELEAVE)) { return; } var handler = jsaction.EventContract.eventHandler_(this, name); // Install the callback which handles events on the container. var installer = jsaction.EventContract.containerHandlerInstaller_( opt_prefixedName || name, handler); // Store the callback to allow us to replay events. this.events_[name] = handler; this.installers_.push(installer); for (var i = 0; i < this.containers_.length; ++i) { this.containers_[i].installHandler(installer); } // Automatically install a keypress/keydown event handler if support for // accessible clicks is turned on. if (jsaction.EventContract.A11Y_CLICK_SUPPORT && name == jsaction.EventType.CLICK) { this.addEvent(jsaction.EventType.KEYDOWN); } if (jsaction.EventContract.FAST_CLICK_SUPPORT && name == jsaction.EventType.CLICK) { this.initializeFastClick_(); } }; /** * Add events needed for fast-click support. * @private */ jsaction.EventContract.prototype.initializeFastClick_ = function() { this.addEvent(jsaction.EventType.TOUCHSTART); this.addEvent(jsaction.EventType.TOUCHEND); this.addEvent(jsaction.EventType.TOUCHMOVE); // We need to capture CLICK events to cancel clicks that were already // issued based on TOUCHEND. The only reason for this handler is to work // around an issue with iOS Safari where a CLICK event sometimes is issued // even though the TOUCHEND has been canceled. // This is ignored on IE8 which doesn't have touch support. if (document.addEventListener) { document.addEventListener(jsaction.EventType.CLICK, jsaction.EventContract.sweepupPreventedMouseEvents_, true); document.addEventListener(jsaction.EventType.MOUSEUP, jsaction.EventContract.sweepupPreventedMouseEvents_, true); document.addEventListener(jsaction.EventType.MOUSEDOWN, jsaction.EventContract.sweepupPreventedMouseEvents_, true); } }; /** * Returns the event handler function for a given event type. * @param {string} name Event name. * @return {jsaction.EventHandlerFunction|undefined} The event handler * function or undefined if it does not exist. */ jsaction.EventContract.prototype.handler = function(name) { return this.events_[name]; }; /** * Signs the event contract for a new container. All registered events * are enabled for this container too. Containers have to be kept disjoint, * so if the newly added container is a parent/child of existing containers, * they will be merged. * * The caller of addContainer can keep a reference to this if it desires * to remove the container later. * * @param {!Element} div The container element. Usually a DIV, but not * constrained to. * @return {!jsaction.EventContractContainer} The container object that was * created. */ jsaction.EventContract.prototype.addContainer = function(div) { var container = new jsaction.EventContractContainer(div); if (!jsaction.EventContract.STOP_PROPAGATION) { if (this.hasContainerFor_(div)) { // This container has an ancestor that is already a contract container. // Don't install event listeners on it when STOP_PROPAGATION is false // in order to prevent an event from being handled multiple times. We // still want to keep track of it in order to be able to correctly // add/remove containers. this.nestedContainers_.push(container); return container; } this.setUpContainer_(container); this.containers_.push(container); this.updateNestedContainers_(); } else { this.setUpContainer_(container); this.containers_.push(container); } return container; }; /** * Updates the list of nested containers after an add/remove operation. Only * containers that are not children of other containers are placed in the * containers list (and have event listeners on them). This is done in order to * prevent events from being handled multiple times when STOP_PROPAGATION is * false. * @private */ jsaction.EventContract.prototype.updateNestedContainers_ = function() { var allContainers = this.nestedContainers_.concat(this.containers_); var newNestedContainers = []; var newContainers = []; for (var i = 0; i < this.containers_.length; ++i) { var container = this.containers_[i]; if (jsaction.EventContractContainer.isNested_(container, allContainers)) { newNestedContainers.push(container); // Remove the event listeners from the nested container. container.cleanUp(); } else { newContainers.push(container); } } for (var i = 0; i < this.nestedContainers_.length; ++i) { var container = this.nestedContainers_[i]; if (jsaction.EventContractContainer.isNested_(container, allContainers)) { newNestedContainers.push(container); } else { newContainers.push(container); // The container is no longer nested, add event listeners on it. this.setUpContainer_(container); } } this.containers_ = newContainers; this.nestedContainers_ = newNestedContainers; }; /** * Adds the event listeners on the new container. * @param {!jsaction.EventContractContainer} container The newly added * container. * @private */ jsaction.EventContract.prototype.setUpContainer_ = function(container) { var div = container.div; // In iOS, event bubbling doesn't happen automatically in any DOM element, // unless it has an onclick attribute or DOM event handler attached to it. // This breaks JsAction in some cases. See "Making Elements Clickable" section // at http://goo.gl/2VoGnB. // // A workaround for this issue is to change the CSS cursor style to 'pointer' // for the container element, which magically turns on event bubbling. This // solution is described in the comments section at http://goo.gl/6pEO1z. // // We use a navigator.userAgent check here as this problem is present both on // Mobile Safari and thin WebKit wrappers, such as Chrome for iOS. if (jsaction.EventContract.isIos_) { div.style.cursor = 'pointer'; } for (var i = 0; i < this.installers_.length; ++i) { container.installHandler(this.installers_[i]); } }; /** * Tests whether this EventContract already has a container that is a parent of * the div sent as a parameter. * @param {Element} div The element for which we need to test if there already * is a container for it. * @return {boolean} True if there already is such a registered container, * false otherwise. * @private */ jsaction.EventContract.prototype.hasContainerFor_ = function(div) { for (var i = 0; i < this.containers_.length; i++) { if (this.containers_[i].containsNode(div)) { return true; } } return false; }; /** * Removes an already-added container from the contract. * * @param {jsaction.EventContractContainer} container The container object to * remove. */ jsaction.EventContract.prototype.removeContainer = function(container) { container.cleanUp(); var removed = false; for (var i = 0; i < this.containers_.length; ++i) { if (this.containers_[i] === container) { this.containers_.splice(i, 1); removed = true; break; } } if (!removed) { for (var i = 0; i < this.nestedContainers_.length; ++i) { if (this.nestedContainers_[i] === container) { this.nestedContainers_.splice(i, 1); break; } } } if (!jsaction.EventContract.STOP_PROPAGATION) { this.updateNestedContainers_(); } }; /** * Register a dispatcher function. Event info of each event mapped to * a jsaction is passed for handling to this callback. The queued * events are passed as well to the dispatcher for later replaying * once the dispatcher is registered. Clears the event queue to null. * * @param {function((!jsaction.EventInfo|!Array.<!jsaction.EventInfo>), *