Skip to content

Commit

Permalink
refactor(core): Move action resolution into a standalone class.
Browse files Browse the repository at this point in the history
This refactor will enable moving this functionality into the dispatcher.
  • Loading branch information
tbondwilkinson committed Apr 30, 2024
1 parent f307e95 commit 1b79ac0
Show file tree
Hide file tree
Showing 2 changed files with 376 additions and 358 deletions.
363 changes: 363 additions & 0 deletions packages/core/primitives/event-dispatch/src/action_resolver.ts
@@ -0,0 +1,363 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Attribute} from './attribute';
import {Char} from './char';
import {EventInfo} from './event_info';
import {EventType} from './event_type';
import {Property} from './property';
import * as a11yClick from './a11y_click';
import * as cache from './cache';
import * as eventInfoLib from './event_info';
import * as eventLib from './event';

/**
* 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 jsnamespaceSupport: 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,
jsnamespaceSupport = false,
syntheticMouseEventSupport = false,
}: {
customEventSupport?: boolean;
jsnamespaceSupport?: boolean;
syntheticMouseEventSupport?: boolean;
} = {}) {
this.customEventSupport = customEventSupport;
this.jsnamespaceSupport = jsnamespaceSupport;
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)) {
if (actionElement.nodeType === Node.ELEMENT_NODE) {
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 (this.jsnamespaceSupport) {
const noNs = actionMap;
actionMap = {};
for (const type in noNs) {
actionMap[type] = this.getFullyQualifiedAction(noNs[type], actionElement, container);
}
}
cache.set(actionElement, actionMap);
}
}
return actionMap;
}

/**
* 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.
*/
private getFullyQualifiedAction(action: string, start: Element, container: Node): string {
if (isNamespacedAction(action)) {
return action;
}

let node: Node | null = start;
while (node && node.nodeType === Node.ELEMENT_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;
}

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;
}
}

/**
* 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;
}

0 comments on commit 1b79ac0

Please sign in to comment.