From ee52ed345f25a95e586e57eea0a641aeab5bde8f Mon Sep 17 00:00:00 2001 From: Tom Wilkinson Date: Wed, 24 Apr 2024 18:07:03 -0500 Subject: [PATCH] refactor(core:) Move action resolution into a standalone class. This refactor will enable moving this functionality into the dispatcher. --- .../event-dispatch/src/action_resolver.ts | 352 ++++++++++++++ .../event-dispatch/src/eventcontract.ts | 450 +----------------- .../event-dispatch/test/eventcontract_test.ts | 220 --------- 3 files changed, 377 insertions(+), 645 deletions(-) create mode 100644 packages/core/primitives/event-dispatch/src/action_resolver.ts diff --git a/packages/core/primitives/event-dispatch/src/action_resolver.ts b/packages/core/primitives/event-dispatch/src/action_resolver.ts new file mode 100644 index 00000000000000..a8299ac786842a --- /dev/null +++ b/packages/core/primitives/event-dispatch/src/action_resolver.ts @@ -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: + // + // + // + // + // 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: + // + // + // + // 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; +} diff --git a/packages/core/primitives/event-dispatch/src/eventcontract.ts b/packages/core/primitives/event-dispatch/src/eventcontract.ts index f0af135ce118d2..eca1eeb25dc64e 100644 --- a/packages/core/primitives/event-dispatch/src/eventcontract.ts +++ b/packages/core/primitives/event-dispatch/src/eventcontract.ts @@ -31,10 +31,7 @@ */ import * as a11yClickLib from './a11y_click'; -import {Attribute as AccessibilityAttribute} from './accessibility'; -import {Attribute} from './attribute'; -import * as cache from './cache'; -import {Char} from './char'; +import {ActionResolver} from './action_resolver'; import {EarlyJsactionData} from './earlyeventcontract'; import * as eventLib from './event'; import {EventContractContainerManager} from './event_contract_container'; @@ -82,19 +79,6 @@ export type Dispatcher = ( */ type EventHandler = (eventType: string, event: Event, container: Element) => void; -const DEFAULT_EVENT_TYPE: string = EventType.CLICK; - -/** - * 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*/; - /** * EventContract intercepts events in the bubbling phase at the * boundary of a container element, and maps them to generic actions @@ -119,6 +103,11 @@ export class EventContract implements UnrenamedEventContract { private containerManager: EventContractContainerManager | null; + private readonly actionResolver = new ActionResolver({ + customEventSupport: EventContract.CUSTOM_EVENT_SUPPORT, + syntheticMouseEventSupport: EventContract.MOUSE_SPECIAL_SUPPORT, + }); + /** * The DOM events which this contract covers. Used to prevent double * registration of event types. The value of the map is the @@ -145,20 +134,8 @@ export class EventContract implements UnrenamedEventContract { */ private queuedEventInfos: eventInfoLib.EventInfo[] | null = []; - /** Whether a11y click support has been loaded or not. */ - private hasA11yClickSupport = false; /** Whether to add an a11y click listener. */ - private addA11yClickListener = EventContract.A11Y_SUPPORT_IN_DISPATCHER; - - 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; + private addA11yClickListener = false; ecaacs?: ( updateEventInfoForA11yClick: typeof a11yClickLib.updateEventInfoForA11yClick, @@ -193,249 +170,37 @@ export class EventContract implements UnrenamedEventContract { * @param allowRehandling Used in the case of a11y click casting to prevent * us from trying to rehandle in an infinite loop. */ - private handleEventInfo(eventInfo: eventInfoLib.EventInfo, allowRehandling = true) { + private handleEventInfo(eventInfo: eventInfoLib.EventInfo) { if (!this.dispatcher) { // All events are queued when the dispatcher isn't yet loaded. eventInfoLib.setIsReplay(eventInfo, true); this.queuedEventInfos?.push(eventInfo); } - if ( - EventContract.CUSTOM_EVENT_SUPPORT && - 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); - - if ( - this.dispatcher && - !eventInfoLib.getEvent(eventInfo)[AccessibilityAttribute.SKIP_GLOBAL_DISPATCH] - ) { - const globalEventInfo: eventInfoLib.EventInfo = eventInfoLib.cloneEventInfo(eventInfo); - - // In some cases, `populateAction` 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 (eventInfoLib.getEventType(globalEventInfo) === EventType.CLICKONLY) { - eventInfoLib.setEventType(globalEventInfo, EventType.CLICK); - } + this.actionResolver.resolve(eventInfo); - this.dispatcher(globalEventInfo, /* dispatch global event */ true); - } - - const action = eventInfoLib.getAction(eventInfo); - if (!action && !checkDispatcherForA11yClick(eventInfo)) { + if (!this.dispatcher) { return; } + const globalEventInfo: eventInfoLib.EventInfo = eventInfoLib.cloneEventInfo(eventInfo); - if (this.dispatcher) { - if ( - action && - shouldPreventDefaultBeforeDispatching(eventInfoLib.getActionElement(action), eventInfo) - ) { - eventLib.preventDefault(eventInfoLib.getEvent(eventInfo)); - } - - const unresolvedEventInfo = this.dispatcher(eventInfo); - if (unresolvedEventInfo && allowRehandling) { - // The dispatcher only returns an event for MAYBE_CLICK_EVENT_TYPE - // events that can't be casted to a click. We run it through the - // handler again to find keydown actions for it. - this.handleEventInfo(unresolvedEventInfo, /* allowRehandling= */ false); - return; - } - } - } - - /** - * 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: - // - // - // - // - // 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: - // - // - // - // 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.hasA11yClickSupport) { - this.updateEventInfoForA11yClick!(eventInfo); - } else if ( - EventContract.A11Y_SUPPORT_IN_DISPATCHER && - eventInfoLib.getEventType(eventInfo) === EventType.KEYDOWN && - !eventInfoLib.getEvent(eventInfo)[AccessibilityAttribute.SKIP_A11Y_CHECK] - ) { - // We use a string literal as this value needs to be referenced in the - // dispatcher's binary. - eventInfoLib.setEventType(eventInfo, AccessibilityAttribute.MAYBE_CLICK_EVENT_TYPE); + // In some cases, `populateAction` 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 (eventInfoLib.getEventType(globalEventInfo) === EventType.CLICKONLY) { + eventInfoLib.setEventType(globalEventInfo, EventType.CLICK); } - // 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; - } - } + this.dispatcher(globalEventInfo, /* dispatch global event */ true); const action = eventInfoLib.getAction(eventInfo); if (!action) { - // No action found. return; } - - if (this.hasA11yClickSupport) { - 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 ( - EventContract.MOUSE_SPECIAL_SUPPORT && - (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 = parseActions(actionElement, eventInfoLib.getContainer(eventInfo)); - - const actionName = actionMap[eventInfoLib.getEventType(eventInfo)]; - if (actionName !== undefined) { - eventInfoLib.setAction(eventInfo, actionName, actionElement); + if (shouldPreventDefaultBeforeDispatching(eventInfoLib.getActionElement(action), eventInfo)) { + eventLib.preventDefault(eventInfoLib.getEvent(eventInfo)); } - if (this.hasA11yClickSupport) { - this.populateClickOnlyAction!(actionElement, eventInfo, actionMap); - } - if (EventContract.A11Y_SUPPORT_IN_DISPATCHER) { - if ( - eventInfoLib.getEventType(eventInfo) === AccessibilityAttribute.MAYBE_CLICK_EVENT_TYPE && - actionMap[EventType.CLICK] !== undefined - ) { - // We'll take the first CLICK action we find and have the dispatcher - // check if the keydown event can be used as a CLICK. If not, the - // dispatcher will retrigger the event so that we can find a keydown - // event instead. - // When we get MAYBE_CLICK_EVENT_TYPE as an eventType, we want to - // retrieve the action corresponding to CLICK, but still keep the - // eventInfoLib.getEventType(eventInfo, ) as MAYBE_CLICK_EVENT_TYPE. The - // dispatcher uses this event type to determine if it should get the - // handler for the action. - eventInfoLib.setAction(eventInfo, actionMap[EventType.CLICK], actionElement); - } else { - a11yClickLib.populateClickOnlyAction(actionElement, eventInfo, actionMap); - } - } + this.dispatcher(eventInfo); } /** @@ -626,10 +391,11 @@ export class EventContract implements UnrenamedEventContract { populateClickOnlyAction: typeof a11yClickLib.populateClickOnlyAction, ) { this.addA11yClickListener = true; - this.hasA11yClickSupport = true; - this.updateEventInfoForA11yClick = updateEventInfoForA11yClick; - this.preventDefaultForA11yClick = preventDefaultForA11yClick; - this.populateClickOnlyAction = populateClickOnlyAction; + this.actionResolver.addA11yClickSupport( + updateEventInfoForA11yClick, + preventDefaultForA11yClick, + populateClickOnlyAction, + ); } } @@ -647,17 +413,6 @@ export function addDeferredA11yClickSupport(eventContract: EventContract) { ); } -/** - * Determines whether or not the `EventContract` needs to check with the - * dispatcher even if there's no action. - */ -function checkDispatcherForA11yClick(eventInfo: eventInfoLib.EventInfo): boolean { - return ( - EventContract.A11Y_SUPPORT_IN_DISPATCHER && - eventInfoLib.getEventType(eventInfo) === AccessibilityAttribute.MAYBE_CLICK_EVENT_TYPE - ); -} - /** * Returns true if the default action of this event should be prevented before * this event is dispatched. @@ -676,158 +431,3 @@ function shouldPreventDefaultBeforeDispatching( eventInfoLib.getEventType(eventInfo) === EventType.CLICKMOD) ); } - -/** - * 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. - */ -export function parseActions(actionElement: Element, container: Node): {[key: string]: string} { - let actionMap: {[key: string]: string} | undefined = cache.get(actionElement); - if (!actionMap) { - const jsactionAttribute = getAttr(actionElement, 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 ? stringTrim(value.substr(0, colon)) : DEFAULT_EVENT_TYPE; - const action = hasColon ? stringTrim(value.substr(colon + 1)) : 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; -} - -/** - * 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. getAttr() 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 = getAttr(element, Attribute.JSNAMESPACE); - cache.setNamespace(element, namespace); - } - return namespace; -} - -/** - * 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 The DOM element. - * @param attribute The name of the attribute to access. - * @return The attribute value if it was found, null otherwise. - */ -function getAttr(element: Element, attribute: string): string | null { - let value = null; - // NOTE: 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: 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 element) { - value = element.getAttribute(attribute); - } - return value; -} - -/** - * 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 str Input string. - * @return Trimmed string. - */ -function stringTrim(str: string): string { - if (typeof String.prototype.trim === 'function') { - return str.trim(); - } - - const trimmedLeft = str.replace(/^\s+/, ''); - return trimmedLeft.replace(/\s+$/, ''); -} diff --git a/packages/core/primitives/event-dispatch/test/eventcontract_test.ts b/packages/core/primitives/event-dispatch/test/eventcontract_test.ts index 47efd77914322d..b70a17cf07945b 100644 --- a/packages/core/primitives/event-dispatch/test/eventcontract_test.ts +++ b/packages/core/primitives/event-dispatch/test/eventcontract_test.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ -import {Attribute as AccessibilityAttribute} from '../src/accessibility'; import * as cache from '../src/cache'; import {fireCustomEvent} from '../src/custom_events'; import {stopPropagation} from '../src/dispatcher'; @@ -287,7 +286,6 @@ function dispatchKeyboardEvent( altKey = false, shiftKey = false, metaKey = false, - skipA11yCheck = false, }: { type?: string; key?: string; @@ -296,7 +294,6 @@ function dispatchKeyboardEvent( altKey?: boolean; shiftKey?: boolean; metaKey?: boolean; - skipA11yCheck?: boolean; } = {}, ) { // createEvent/initKeyboardEvent is used to support IE11 @@ -318,9 +315,6 @@ function dispatchKeyboardEvent( // This is necessary as Chrome does not respect the key parameter in // `initKeyboardEvent`. Object.defineProperty(event, 'key', {value: key}); - if (skipA11yCheck) { - event[AccessibilityAttribute.SKIP_A11Y_CHECK] = true; - } spyOn(event, 'preventDefault').and.callThrough(); target.dispatchEvent(event); return event; @@ -330,7 +324,6 @@ describe('EventContract', () => { beforeEach(() => { safeElement.setInnerHtml(document.body, testonlyHtml(domContent)); EventContract.A11Y_CLICK_SUPPORT = false; - EventContract.A11Y_SUPPORT_IN_DISPATCHER = false; EventContract.MOUSE_SPECIAL_SUPPORT = false; EventContract.CUSTOM_EVENT_SUPPORT = false; @@ -748,64 +741,6 @@ describe('EventContract', () => { expect(eventInfoWrapper.getAction()).toBeUndefined(); }); - it('re-dispatches if dispatcher returns an `EventInfo`', () => { - const container = getRequiredElementById('a11y-click-keydown-container'); - const actionElement = getRequiredElementById('a11y-click-keydown-action-element'); - const targetElement = getRequiredElementById('a11y-click-keydown-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click'], - dispatcher, - }); - - dispatcher.and.callFake((eventInfo: EventInfo) => { - const eventInfoWrapper = new EventInfoWrapper(eventInfo); - if (eventInfoWrapper.getEventType() === 'click') { - eventInfoWrapper.setEventType('keydown'); - eventInfoWrapper.setAction(undefined); - return eventInfoWrapper.eventInfo; - } - return; - }); - - const clickEvent = dispatchMouseEvent(targetElement); - - expect(dispatcher).toHaveBeenCalledTimes(4); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe('keydown'); - expect(eventInfoWrapper.getEvent()).toBe(clickEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleKeydown'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - - it('re-dispatches only once if dispatcher returns an `EventInfo`', () => { - const container = getRequiredElementById('click-container'); - const actionElement = getRequiredElementById('click-action-element'); - const targetElement = getRequiredElementById('click-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click'], - dispatcher, - }); - - dispatcher.and.callFake((eventInfo: EventInfo) => eventInfo); - - const clickEvent = dispatchMouseEvent(targetElement); - - expect(dispatcher).toHaveBeenCalledTimes(4); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe('click'); - expect(eventInfoWrapper.getEvent()).toBe(clickEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - it('dispatches event from shadow dom', () => { const container = getRequiredElementById('shadow-dom-container'); const actionElement = getRequiredElementById('shadow-dom-action-element'); @@ -1157,161 +1092,6 @@ describe('EventContract', () => { }); }); - describe('a11y click in dispatcher', () => { - beforeEach(() => { - EventContract.A11Y_SUPPORT_IN_DISPATCHER = true; - }); - - it('dispatches a11y keydown as maybe click event', () => { - const container = getRequiredElementById('a11y-click-container'); - const actionElement = getRequiredElementById('a11y-click-action-element'); - const targetElement = getRequiredElementById('a11y-click-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click'], - dispatcher, - }); - - const keydownEvent = dispatchKeyboardEvent(targetElement, {key: 'Enter'}); - - expect(dispatcher).toHaveBeenCalledTimes(2); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe(AccessibilityAttribute.MAYBE_CLICK_EVENT_TYPE); - expect(eventInfoWrapper.getEvent()).toBe(keydownEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - - it('dispatches non-a11y keydown as maybe click event', () => { - const container = getRequiredElementById('a11y-click-keydown-container'); - const actionElement = getRequiredElementById('a11y-click-keydown-action-element'); - const targetElement = getRequiredElementById('a11y-click-keydown-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click', 'keydown'], - dispatcher, - }); - - // Pressing the 'a' key is not an a11y click. - const keydownEvent = dispatchKeyboardEvent(targetElement, {key: 'a'}); - - expect(dispatcher).toHaveBeenCalledTimes(2); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe(AccessibilityAttribute.MAYBE_CLICK_EVENT_TYPE); - expect(eventInfoWrapper.getEvent()).toBe(keydownEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - - it('dispatches a11y keydown with SKIP_A11Y_CHECK as click', () => { - const container = getRequiredElementById('a11y-click-keydown-container'); - const actionElement = getRequiredElementById('a11y-click-keydown-action-element'); - const targetElement = getRequiredElementById('a11y-click-keydown-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click', 'keydown'], - dispatcher, - }); - - const keydownEvent = dispatchKeyboardEvent(targetElement, { - key: 'Enter', - skipA11yCheck: true, - }); - - expect(dispatcher).toHaveBeenCalledTimes(2); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe('keydown'); - expect(eventInfoWrapper.getEvent()).toBe(keydownEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleKeydown'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - - it('does not prevent default for enter key on anchor child', () => { - const container = getRequiredElementById('a11y-anchor-click-container'); - const actionElement = getRequiredElementById('a11y-anchor-click-action-element'); - const targetElement = getRequiredElementById('a11y-anchor-click-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click'], - dispatcher, - }); - - const keydownEvent = dispatchKeyboardEvent(targetElement, {key: 'Enter'}); - - expect(dispatcher).toHaveBeenCalledTimes(2); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe(AccessibilityAttribute.MAYBE_CLICK_EVENT_TYPE); - expect(eventInfoWrapper.getEvent()).toBe(keydownEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - - expect(keydownEvent.defaultPrevented).toBe(false); - }); - - it('dispatches clickonly event', () => { - const container = getRequiredElementById('a11y-clickonly-container'); - const actionElement = getRequiredElementById('a11y-clickonly-action-element'); - const targetElement = getRequiredElementById('a11y-clickonly-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - exportAddA11yClickSupport: true, - eventTypes: ['click'], - dispatcher, - }); - - const clickEvent = dispatchMouseEvent(targetElement); - - expect(dispatcher).toHaveBeenCalledTimes(2); - - expect(dispatcher).toHaveBeenCalledTimes(2); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe('clickonly'); - expect(eventInfoWrapper.getEvent()).toBe(clickEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClickOnly'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - - it('dispatches click event to click handler rather than clickonly', () => { - const container = getRequiredElementById('a11y-click-clickonly-container'); - const actionElement = getRequiredElementById('a11y-click-clickonly-action-element'); - const targetElement = getRequiredElementById('a11y-click-clickonly-target-element'); - - const dispatcher = jasmine.createSpy('dispatcher'); - createEventContract({ - eventContractContainerManager: new EventContractContainer(container), - eventTypes: ['click'], - dispatcher, - }); - - const clickEvent = dispatchMouseEvent(targetElement); - - expect(dispatcher).toHaveBeenCalledTimes(2); - - expect(dispatcher).toHaveBeenCalledTimes(2); - const eventInfoWrapper = getLastDispatchedEventInfoWrapper(dispatcher); - expect(eventInfoWrapper.getEventType()).toBe('click'); - expect(eventInfoWrapper.getEvent()).toBe(clickEvent); - expect(eventInfoWrapper.getTargetElement()).toBe(targetElement); - expect(eventInfoWrapper.getAction()?.name).toBe('handleClick'); - expect(eventInfoWrapper.getAction()?.element).toBe(actionElement); - }); - }); - describe('a11y click support deferred', () => { it('dispatches keydown as click event', () => { const container = getRequiredElementById('a11y-click-container');