Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(core:) Move action resolution into a standalone class.
This refactor will enable moving this functionality into the dispatcher.
- Loading branch information
1 parent
617bc33
commit ee52ed3
Showing
3 changed files
with
377 additions
and
645 deletions.
There are no files selected for viewing
352 changes: 352 additions & 0 deletions
352
packages/core/primitives/event-dispatch/src/action_resolver.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,352 @@ | ||
import {EventContract} from './eventcontract'; | ||
import {EventInfo} from './event_info'; | ||
import {EventType} from './event_type'; | ||
import {Property} from './property'; | ||
import * as a11yClick from './a11y_click'; | ||
import * as eventInfoLib from './event_info'; | ||
import * as eventLib from './event'; | ||
import * as cache from './cache'; | ||
import {Attribute} from './attribute'; | ||
import {Char} from './char'; | ||
|
||
/** | ||
* Since maps from event to action are immutable we can use a single map | ||
* to represent the empty map. | ||
*/ | ||
const EMPTY_ACTION_MAP: {[key: string]: string} = {}; | ||
|
||
/** | ||
* This regular expression matches a semicolon. | ||
*/ | ||
const REGEXP_SEMICOLON = /\s*;\s*/; | ||
|
||
/** If no event type is defined, defaults to `click`. */ | ||
const DEFAULT_EVENT_TYPE: string = EventType.CLICK; | ||
|
||
/** Resolves actions for Events. */ | ||
export class ActionResolver { | ||
private a11yClickSupport: boolean = false; | ||
private readonly customEventSupport: boolean; | ||
private readonly syntheticMouseEventSupport: boolean; | ||
|
||
private updateEventInfoForA11yClick?: (eventInfo: eventInfoLib.EventInfo) => void = undefined; | ||
|
||
private preventDefaultForA11yClick?: (eventInfo: eventInfoLib.EventInfo) => void = undefined; | ||
|
||
private populateClickOnlyAction?: ( | ||
actionElement: Element, | ||
eventInfo: eventInfoLib.EventInfo, | ||
actionMap: {[key: string]: string}, | ||
) => void = undefined; | ||
|
||
constructor({ | ||
customEventSupport = false, | ||
syntheticMouseEventSupport = false, | ||
}: { | ||
customEventSupport?: boolean; | ||
syntheticMouseEventSupport?: boolean; | ||
} = {}) { | ||
this.customEventSupport = customEventSupport; | ||
this.syntheticMouseEventSupport = syntheticMouseEventSupport; | ||
} | ||
|
||
resolve(eventInfo: EventInfo) { | ||
if (this.customEventSupport) { | ||
if (eventInfoLib.getEventType(eventInfo) === EventType.CUSTOM) { | ||
const detail = (eventInfoLib.getEvent(eventInfo) as CustomEvent).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; | ||
} | ||
eventInfoLib.setEventType(eventInfo, detail['_type']); | ||
} | ||
} | ||
|
||
this.populateAction(eventInfo); | ||
} | ||
|
||
/** | ||
* Searches for a jsaction that the DOM event maps to and creates an | ||
* object containing event information used for dispatching by | ||
* jsaction.Dispatcher. This method populates the `action` and `actionElement` | ||
* fields of the EventInfo object passed in by finding 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, then action is undefined. | ||
* | ||
* @param eventInfo `EventInfo` to set `action` and `actionElement` if an | ||
* action is found on any `Element` in the path of the `Event`. | ||
*/ | ||
private populateAction(eventInfo: eventInfoLib.EventInfo) { | ||
// 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 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(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 | ||
// a11y click support is enabled, addEvent() will set up the appropriate key | ||
// event handler automatically. | ||
if ( | ||
eventInfoLib.getEventType(eventInfo) === EventType.CLICK && | ||
eventLib.isModifiedClickEvent(eventInfoLib.getEvent(eventInfo)) | ||
) { | ||
eventInfoLib.setEventType(eventInfo, EventType.CLICKMOD); | ||
} else if (this.a11yClickSupport) { | ||
this.updateEventInfoForA11yClick!(eventInfo); | ||
} | ||
|
||
// Walk to the parent node, unless the node has a different owner in | ||
// which case we walk to the owner. Attempt to walk to host of a | ||
// shadow root if needed. | ||
let actionElement: Element | null = eventInfoLib.getTargetElement(eventInfo); | ||
while (actionElement && actionElement !== eventInfoLib.getContainer(eventInfo)) { | ||
this.populateActionOnElement(actionElement, eventInfo); | ||
|
||
if (eventInfoLib.getAction(eventInfo)) { | ||
// 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. | ||
break; | ||
} | ||
if (actionElement[Property.OWNER]) { | ||
actionElement = actionElement[Property.OWNER] as Element; | ||
continue; | ||
} | ||
if (actionElement.parentNode?.nodeName !== '#document-fragment') { | ||
actionElement = actionElement.parentNode as Element | null; | ||
} else { | ||
actionElement = (actionElement.parentNode as ShadowRoot | null)?.host ?? null; | ||
} | ||
} | ||
|
||
const action = eventInfoLib.getAction(eventInfo); | ||
if (!action) { | ||
// No action found. | ||
return; | ||
} | ||
|
||
if (this.a11yClickSupport) { | ||
this.preventDefaultForA11yClick!(eventInfo); | ||
} | ||
|
||
// We attempt to handle the mouseenter/mouseleave events here by | ||
// detecting whether the mouseover/mouseout events correspond to | ||
// entering/leaving an element. | ||
if (this.syntheticMouseEventSupport) { | ||
if ( | ||
eventInfoLib.getEventType(eventInfo) === EventType.MOUSEENTER || | ||
eventInfoLib.getEventType(eventInfo) === EventType.MOUSELEAVE || | ||
eventInfoLib.getEventType(eventInfo) === EventType.POINTERENTER || | ||
eventInfoLib.getEventType(eventInfo) === EventType.POINTERLEAVE | ||
) { | ||
// We attempt to handle the mouseenter/mouseleave events here by | ||
// detecting whether the mouseover/mouseout events correspond to | ||
// entering/leaving an element. | ||
if ( | ||
eventLib.isMouseSpecialEvent( | ||
eventInfoLib.getEvent(eventInfo), | ||
eventInfoLib.getEventType(eventInfo), | ||
eventInfoLib.getActionElement(action), | ||
) | ||
) { | ||
// 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. | ||
const copiedEvent = eventLib.createMouseSpecialEvent( | ||
eventInfoLib.getEvent(eventInfo), | ||
eventInfoLib.getActionElement(action), | ||
); | ||
eventInfoLib.setEvent(eventInfo, copiedEvent); | ||
// Since the mouseenter/mouseleave events do not bubble, the target | ||
// of the event is technically the `actionElement` (the node with the | ||
// `jsaction` attribute) | ||
eventInfoLib.setTargetElement(eventInfo, eventInfoLib.getActionElement(action)); | ||
} else { | ||
eventInfoLib.unsetAction(eventInfo); | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* 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 actionElement The DOM node to retrieve the jsaction map from. | ||
* @param eventInfo `EventInfo` to set `action` and `actionElement` if an | ||
* action is found on the `actionElement`. | ||
*/ | ||
private populateActionOnElement(actionElement: Element, eventInfo: eventInfoLib.EventInfo) { | ||
const actionMap = this.parseActions(actionElement, eventInfoLib.getContainer(eventInfo)); | ||
|
||
const actionName = actionMap[eventInfoLib.getEventType(eventInfo)]; | ||
if (actionName !== undefined) { | ||
eventInfoLib.setAction(eventInfo, actionName, actionElement); | ||
} | ||
|
||
if (this.a11yClickSupport) { | ||
this.populateClickOnlyAction!(actionElement, eventInfo, actionMap); | ||
} | ||
} | ||
|
||
/** | ||
* Parses and caches an element's jsaction element into a map. | ||
* | ||
* This is primarily for internal use. | ||
* | ||
* @param actionElement The DOM node to retrieve the jsaction map from. | ||
* @param container The node which limits the namespace lookup for a jsaction | ||
* name. The container node itself will not be searched. | ||
* @return Map from event to qualified name of the jsaction bound to it. | ||
*/ | ||
private parseActions(actionElement: Element, container: Node): {[key: string]: string} { | ||
let actionMap: {[key: string]: string} | undefined = cache.get(actionElement); | ||
if (!actionMap) { | ||
const jsactionAttribute = actionElement.getAttribute(Attribute.JSACTION); | ||
if (!jsactionAttribute) { | ||
actionMap = EMPTY_ACTION_MAP; | ||
cache.set(actionElement, actionMap); | ||
} else { | ||
actionMap = cache.getParsed(jsactionAttribute); | ||
if (!actionMap) { | ||
actionMap = {}; | ||
const values = jsactionAttribute.split(REGEXP_SEMICOLON); | ||
for (let idx = 0; idx < values.length; idx++) { | ||
const value = values[idx]; | ||
if (!value) { | ||
continue; | ||
} | ||
const colon = value.indexOf(Char.EVENT_ACTION_SEPARATOR); | ||
const hasColon = colon !== -1; | ||
const type = hasColon ? value.substr(0, colon).trim() : DEFAULT_EVENT_TYPE; | ||
const action = hasColon ? value.substr(colon + 1).trim() : value; | ||
actionMap[type] = action; | ||
} | ||
cache.setParsed(jsactionAttribute, actionMap); | ||
} | ||
// If namespace support is active we need to augment the (potentially | ||
// cached) jsaction mapping with the namespace. | ||
if (EventContract.JSNAMESPACE_SUPPORT) { | ||
const noNs = actionMap; | ||
actionMap = {}; | ||
for (const type in noNs) { | ||
actionMap[type] = getFullyQualifiedAction(noNs[type], actionElement, container); | ||
} | ||
} | ||
cache.set(actionElement, actionMap); | ||
} | ||
} | ||
return actionMap; | ||
} | ||
|
||
addA11yClickSupport( | ||
updateEventInfoForA11yClick: typeof a11yClick.updateEventInfoForA11yClick, | ||
preventDefaultForA11yClick: typeof a11yClick.preventDefaultForA11yClick, | ||
populateClickOnlyAction: typeof a11yClick.populateClickOnlyAction, | ||
) { | ||
this.a11yClickSupport = true; | ||
this.updateEventInfoForA11yClick = updateEventInfoForA11yClick; | ||
this.preventDefaultForA11yClick = preventDefaultForA11yClick; | ||
this.populateClickOnlyAction = populateClickOnlyAction; | ||
} | ||
} | ||
|
||
/** | ||
* Returns the fully qualified jsaction action. 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 action The jsaction action to resolve. | ||
* @param start The node from which to start searching for a jsnamespace | ||
* attribute. | ||
* @param container The node which limits the search for a jsnamespace | ||
* attribute. This node will be searched. | ||
* @return The fully qualified name of the jsaction. If no namespace is found, | ||
* returns the unqualified name in case it exists in the global namespace. | ||
*/ | ||
function getFullyQualifiedAction(action: string, start: Element, container: Node): string { | ||
if (EventContract.JSNAMESPACE_SUPPORT) { | ||
if (isNamespacedAction(action)) { | ||
return action; | ||
} | ||
|
||
let node: Node | null = start; | ||
while (node) { | ||
const namespace = getNamespaceFromElement(node as Element); | ||
if (namespace) { | ||
return namespace + Char.NAMESPACE_ACTION_SEPARATOR + action; | ||
} | ||
|
||
// If this node is the container, stop. | ||
if (node === container) { | ||
break; | ||
} | ||
|
||
node = node.parentNode; | ||
} | ||
} | ||
|
||
return action; | ||
} | ||
|
||
/** | ||
* Checks if a jsaction action contains a namespace part. | ||
*/ | ||
function isNamespacedAction(action: string): boolean { | ||
return action.indexOf(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 The node whose jsnamespace attribute is being asked for. | ||
* @return The value of the jsnamespace attribute, or null if not found. | ||
*/ | ||
function getNamespaceFromElement(element: Element): string | null { | ||
let namespace = cache.getNamespace(element); | ||
// Only query for the attribute if it has not been queried for | ||
// before. getAttribute() returns null if an attribute is not present. Thus, | ||
// namespace is string|null if the query took place in the past, or | ||
// undefined if the query did not take place. | ||
if (namespace === undefined) { | ||
namespace = element.getAttribute(Attribute.JSNAMESPACE); | ||
cache.setNamespace(element, namespace); | ||
} | ||
return namespace; | ||
} |
Oops, something went wrong.