src/lib/strategies/FocusOnlyKeyEventStrategy.js
import AbstractKeyEventStrategy from './AbstractKeyEventStrategy';
import KeyEventType from '../../const/KeyEventType';
import describeKeyEventType from '../../helpers/logging/describeKeyEventType';
import getKeyName from '../../helpers/resolving-handlers/getKeyName';
import isCmdKey from '../../helpers/parsing-key-maps/isCmdKey';
import describeKeyEvent from '../../helpers/logging/describeKeyEvent';
import EventResponse from '../../const/EventResponse';
import KeyEventState from '../../const/KeyEventState';
import stateFromEvent from '../../helpers/parsing-key-maps/stateFromEvent';
import EventPropagator from '../listening/EventPropagator';
import FocusOnlyKeyEventSimulator from '../simulation/FocusOnlyKeyEventSimulator';
import FocusTree from '../listening/FocusTree';
import FocusOnlyLogger from '../logging/FocusOnlyLogger';
import KeyCombinationIterator from '../listening/KeyCombinationIterator';
/**
* Defines behaviour for dealing with key maps defined in focus-only HotKey components
* @class
*/
class FocusOnlyKeyEventStrategy extends AbstractKeyEventStrategy {
/********************************************************************************
* Init & Reset
********************************************************************************/
constructor(options = {}, keyEventManager) {
/******************************************************************************
* Set state that DOES get cleared on each new focus tree
******************************************************************************/
super(options, keyEventManager);
this.logger = new FocusOnlyLogger(options.logLevel || 'warn', this);
this.eventPropagator.logger = this.logger;
/*****************************************************************************
* State that doesn't get cleared on each new focus tree
*****************************************************************************/
/**
* Unique identifier given to each focus tree - when the focus in the browser
* changes, and a different tree of elements are focused, a new id is allocated
* @typedef {number} FocusTreeId
*/
/**
* Counter to keep track of what focus tree ID should be allocated next
* @type {FocusTreeId}
*/
this.focusTree = new FocusTree();
this._simulator = new FocusOnlyKeyEventSimulator(this);
}
/**
* Clears the internal state, wiping any history of key events and registered handlers
* so they have no effect on the next tree of focused HotKeys components
* @private
*/
_reset() {
super._reset();
if (this._simulator) {
this._simulator.clear();
}
/**
* Increase the unique ID associated with each unique focus tree
* @type {number}
*/
if (this.focusTree) {
this.focusTree.new();
}
this.eventPropagator = new EventPropagator(this.componentList);
this.eventPropagator.logger = this.logger;
}
/********************************************************************************
* Registering key maps and handlers
********************************************************************************/
/**
* Registers the actions and handlers of a HotKeys component that has gained focus
* @param {ComponentId} componentId - Id of the component that the keyMap belongs to
* @param {KeyMap} actionNameToKeyMap - Map of actions to key expressions
* @param {HandlersMap} actionNameToHandlersMap - Map of actions to handler functions
* @param {Object} options Hash of options that configure how the actions
* and handlers are associated and called.
* @returns {FocusTreeId|undefined} The current focus tree's ID or undefined if the
* the <tt>componentId</tt> has already been registered (shouldn't normally
* occur).
*/
enableHotKeys(componentId, actionNameToKeyMap = {}, actionNameToHandlersMap = {}, options) {
if (this.resetOnNextFocus) {
/**
* We know components have just lost focus or keymaps have already been built,
* meaning we are either anticipating a new set of components to be focused or
* we are receiving notice of a component being focused when we aren't expecting it.
* In either case, the internal state needs to be reset.
*/
this._reset();
this.resetOnNextFocus = false;
}
if (this.componentList.containsId(componentId)) {
/**
* The <tt>componentId</tt> has already been registered - this occurs when the
* same component has somehow managed to be focused twice, without being blurred
* in between.
*
* @see https://github.com/greena13/react-hotkeys/issues/173
*/
return;
}
this._addComponent(
componentId, actionNameToKeyMap, actionNameToHandlersMap, 'Focused', options
);
return this.focusTree.id;
}
/**
* Handles when a HotKeys component that is in focus updates its props and changes
* either the keyMap or handlers prop value
* @param {FocusTreeId} focusTreeId - The ID of the focus tree the component is part of.
* Used to identify (and ignore) stale updates.
* @param {ComponentId} componentId - The component index of the component to
* update
* @param {KeyMap} keyMap - Map of key sequences to action names
* @param {HandlersMap} handlersMap - Map of action names to handler
* functions
* @param {Object} options Hash of options that configure how the actions
* and handlers are associated and called.
*/
updateEnabledHotKeys(focusTreeId, componentId, keyMap = {}, handlersMap = {}, options) {
if (this.focusTree.isNewerThan(focusTreeId) || !this.componentList.containsId(componentId)) {
return;
}
this._updateComponent(componentId, keyMap, handlersMap, options);
}
/**
* Handles when a component loses focus by resetting the internal state, ready to
* receive the next tree of focused HotKeys components
* @param {FocusTreeId} focusTreeId - Id of focus tree component thinks it's
* apart of
* @param {ComponentId} componentId - Index of component that is blurring
* @returns {boolean} Whether the component still has event propagation yet to handle
*/
disableHotKeys(focusTreeId, componentId){
this.resetOnNextFocus = true;
const outstandingEventPropagation = this.eventPropagator.isPendingPropagation();
this.logger.debug(
`${this.logger.keyEventPrefix(componentId, {focusTreeId})}`,
`Lost focus${outstandingEventPropagation ? ' (Key event has yet to propagate through it)' : '' }.`
);
return outstandingEventPropagation;
}
/********************************************************************************
* Recording key events
********************************************************************************/
/**
* @typedef {KeyboardEvent} SyntheticKeyboardEvent
* @property {KeyboardEvent} nativeEvent The native event the SyntheticEvent is wrapping
* @property {function} persist
*/
/**
* Records a keydown keyboard event and matches it against the list of pre-registered
* event handlers, calling the first matching handler with the highest priority if
* one exists.
*
* This method is called many times as a keyboard event bubbles up through the React
* render tree. The event is only registered the first time it is seen and results
* of some calculations are cached. The event is matched against the handlers registered
* at each component level, to ensure the proper handler declaration scoping.
* @param {SyntheticKeyboardEvent} event - Event containing the key name and state
* @param {FocusTreeId} focusTreeId - Id of focus tree component thinks it's apart of
* @param {ComponentId} componentId - The id of the component that is currently handling
* the keyboard event as it bubbles towards the document root.
* @param {Object} options - Hash of options that configure how the event is handled.
* @returns {boolean} Whether the event was discarded because it was part of an old focus tree
*/
handleKeyDown(event, focusTreeId, componentId, options = {}) {
const key = getKeyName(event);
if (this.focusTree.isNewerThan(focusTreeId)) {
this.logger.logIgnoredKeyEvent(
event, key, KeyEventType.keydown,
`it had an old focus tree id: ${focusTreeId}`,
componentId
);
this.eventPropagator.ignoreEvent(event);
return true;
} else if (this._isIgnoringRepeatedEvent(event, key, KeyEventType.keydown, componentId)) {
return false;
}
if (this.eventPropagator.startNewPropagationStep(componentId, event, key, KeyEventType.keydown)) {
const responseAction = this._howToHandleKeyEvent(
event, focusTreeId, componentId, key, options, KeyEventType.keydown
);
if (responseAction === EventResponse.handled) {
this._recordKeyDown(event, key, componentId);
this._callHandlerIfActionNotHandled(event, key, KeyEventType.keydown, componentId, focusTreeId);
}
this._simulator.handleKeyPressSimulation({event, key, focusTreeId, componentId, options});
this.eventPropagator.finishPropagationStep();
}
return false;
}
_howToHandleKeyEvent(event, focusTreeId, componentId, key, options, keyEventType){
if (this.eventPropagator.isFirstPropagationStep()) {
if (options.ignoreEventsCondition(event) && this.eventPropagator.ignoreEvent(event)) {
return this._eventIsToBeIgnored(event, componentId, key, keyEventType);
}
this.logger.debug(
this.logger.keyEventPrefix(componentId),
`New ${describeKeyEvent(event, key, keyEventType)} event.`
);
this.currentCombination.resolveModifierFlagDiscrepancies(event, key, keyEventType);
} else if (this.eventPropagator.isIgnoringEvent()) {
return this._eventIsToBeIgnored(event, componentId, key, keyEventType);
}
return EventResponse.handled;
}
_eventIsToBeIgnored(event, componentId, key, keyEventType){
this.logger.logIgnoredKeyEvent(event, key, keyEventType, `ignoreEventsFilter rejected it`, componentId);
return EventResponse.ignored;
}
/**
* Records a keypress keyboard event and matches it against the list of pre-registered
* event handlers, calling the first matching handler with the highest priority if
* one exists.
*
* This method is called many times as a keyboard event bubbles up through the React
* render tree. The event is only registered the first time it is seen and results
* of some calculations are cached. The event is matched against the handlers registered
* at each component level, to ensure the proper handler declaration scoping.
* @param {SyntheticKeyboardEvent} event - Event containing the key name and state
* @param {FocusTreeId} focusTreeId Id - of focus tree component thinks it's apart of
* @param {ComponentId} componentId - The index of the component that is currently handling
* the keyboard event as it bubbles towards the document root.
* @param {Object} options - Hash of options that configure how the event
* is handled.
* @returns {boolean} Whether the HotKeys component should discard its current focus
* tree Id, because it belongs to an old focus tree.
*/
handleKeyPress(event, focusTreeId, componentId, options) {
const key = getKeyName(event);
if (this._isIgnoringRepeatedEvent(event, key, KeyEventType.keypress, componentId)) {
return false;
} else if (this.currentCombination.isKeyPressSimulated(key)) {
this._ignoreAlreadySimulatedEvent(event, key, KeyEventType.keypress, componentId);
return false;
}
const shouldDiscardFocusTreeId = this.focusTree.isNewerThan(focusTreeId);
if (this.eventPropagator.startNewPropagationStep(componentId, event, key, KeyEventType.keypress)) {
/**
* We first decide if the keypress event should be handled (to ensure the correct
* order of logging statements)
*/
const responseAction = this._howToHandleKeyEvent(event,
focusTreeId, componentId, key, options, KeyEventType.keypress
);
if (this.eventPropagator.isFirstPropagationStep(componentId) && this.currentCombination.isKeyIncluded(key)) {
this._recordNewKeyInCombination(key, KeyEventType.keypress, stateFromEvent(event), componentId);
}
/**
* We attempt to find a handler of the event, only if it has not already been
* handled and should not be ignored
*/
if (responseAction === EventResponse.handled) {
this._callHandlerIfActionNotHandled(
event, key, KeyEventType.keypress, componentId, focusTreeId
);
}
this.eventPropagator.finishPropagationStep();
}
return shouldDiscardFocusTreeId;
}
/**
* Records a keyup keyboard event and matches it against the list of pre-registered
* event handlers, calling the first matching handler with the highest priority if
* one exists.
*
* This method is called many times as a keyboard event bubbles up through the React
* render tree. The event is only registered the first time it is seen and results
* of some calculations are cached. The event is matched against the handlers registered
* at each component level, to ensure the proper handler declaration scoping.
* @param {SyntheticKeyboardEvent} event Event containing the key name and state
* @param {FocusTreeId} focusTreeId Id of focus tree component thinks it's apart of
* @param {ComponentId} componentId The index of the component that is currently handling
* the keyboard event as it bubbles towards the document root.
* @param {Object} options Hash of options that configure how the event
* is handled.
* @returns {boolean} Whether HotKeys component should discard its current focusTreeId
* because it's stale (part of an old focus tree)
*/
handleKeyUp(event, focusTreeId, componentId, options) {
const key = getKeyName(event);
if (this.currentCombination.isKeyUpSimulated(key)) {
this._ignoreAlreadySimulatedEvent(event, key, KeyEventType.keyup, componentId);
return false;
}
const shouldDiscardFocusId = this.focusTree.isNewerThan(focusTreeId);
const propagator = this.eventPropagator;
if (propagator.startNewPropagationStep(componentId, event, key, KeyEventType.keyup)) {
/**
* We first decide if the keyup event should be handled (to ensure the correct
* order of logging statements)
*/
const responseAction = this._howToHandleKeyEvent(event,
focusTreeId, componentId, key, options, KeyEventType.keyup
);
/**
* We then add the keyup to our current combination - regardless of whether
* it's to be handled or not. We need to do this to ensure that if a handler
* function changes focus to a context that ignored events, the keyup event
* is not lost (leaving react hotkeys thinking the key is still pressed).
*/
if (propagator.isFirstPropagationStep(componentId) && this.currentCombination.isKeyIncluded(key)) {
this._recordNewKeyInCombination(key, KeyEventType.keyup, stateFromEvent(event), componentId);
}
/**
* We attempt to find a handler of the event, only if it has not already been
* handled and should not be ignored
*/
if (responseAction === EventResponse.handled) {
this._callHandlerIfActionNotHandled(event, key, KeyEventType.keyup, componentId, focusTreeId);
}
/**
* We simulate any hidden keyup events hidden by the command key, regardless
* of whether the event should be ignored or not
*/
this._simulateKeyUpEventsHiddenByCmd(event, key, focusTreeId, componentId, options);
propagator.finishPropagationStep();
}
return shouldDiscardFocusId;
}
_ignoreAlreadySimulatedEvent(event, key, eventType, componentId) {
this.logger.logAlreadySimulatedEvent(event, key, eventType, componentId);
this.eventPropagator.ignoreEvent(event);
}
/**
* Closes any hanging key combinations that have not received the key event indicated
* by recordIndex.
* @param {KeyName} keyName The name of the key whose state should be updated if it
* is currently set to keydown or keypress.
* @param {KeyEventType} recordIndex Index of key event to move the key state
* up to.
*/
closeHangingKeyCombination(keyName, recordIndex) {
const currentCombination = this.currentCombination;
if (currentCombination.isKeyIncluded(keyName) &&
!currentCombination.isEventTriggered(keyName, recordIndex)) {
/**
* If the key is in the current combination and recorded as still being pressed
* down (as either keydown or keypress), then we update the state
* to keypress or keyup (depending on the value of recordIndex).
*/
currentCombination.setKeyState(keyName, recordIndex, KeyEventState.simulated);
}
}
_simulateKeyUpEventsHiddenByCmd(event, key, focusTreeId, componentId, options) {
if (isCmdKey(key)) {
const iterator = new KeyCombinationIterator(this.currentCombination);
iterator.forEachKey((keyName) => {
if (isCmdKey(keyName)) {
return;
}
this._simulator.handleKeyUpSimulation({event, key: keyName, focusTreeId, componentId, options});
});
}
}
stopEventPropagation(event, componentId) {
if (this.eventPropagator.stop(event)) {
this.logger.debug(
this.logger.keyEventPrefix(componentId),
'Stopping further event propagation.'
);
}
}
/********************************************************************************
* Event simulation
********************************************************************************/
simulatePendingKeyPressEvents() {
this._simulator.simulatePendingKeyPressEvents();
}
simulatePendingKeyUpEvents() {
this._simulator.simulatePendingKeyUpEvents();
}
shouldSimulateEventsImmediately() {
return !this.keyEventManager.isGlobalListenersBound();
}
/********************************************************************************
* Matching and calling handlers
********************************************************************************/
/**
* Calls the first handler that matches the current key event if the action has not
* already been handled in a more deeply nested component
* @param {SyntheticKeyboardEvent} event Keyboard event object to be passed to the handler
* @param {NormalizedKeyName} keyName Normalized key name
* @param {KeyEventType} keyEventType The record index of the current key event type
* @param {FocusTreeId} focusTreeId Id of focus tree component thinks it's apart of
* @param {ComponentId} componentId Index of the component that is currently handling
* the keyboard event
* @private
*/
_callHandlerIfActionNotHandled(event, keyName, keyEventType, componentId, focusTreeId) {
const eventName = describeKeyEventType(keyEventType);
const combinationName = this._describeCurrentCombination();
if (!this.componentList.anyActionsForEventType(keyEventType)) {
this.logger.logIgnoredEvent(`'${combinationName}' ${eventName}`, `it doesn't have any ${eventName} handlers`, componentId);
return;
}
if (this.eventPropagator.isHandled()) {
this.logger.logIgnoredEvent(`'${combinationName}' ${eventName}`, 'it has already been handled', componentId);
} else {
this.logger.verbose(
this.logger.keyEventPrefix(componentId, {focusTreeId}),
`Attempting to find action matching '${combinationName}' ${eventName} . . .`
);
const { previousPosition } = this.eventPropagator;
const componentPosition = this.componentList.getPositionById(componentId);
const handlerWasCalled =
this.actionResolver.callClosestMatchingHandler(
event, keyName, keyEventType, componentPosition,
previousPosition === -1 ? 0 : previousPosition
);
if (handlerWasCalled) {
this.eventPropagator.setHandled();
}
}
}
}
export default FocusOnlyKeyEventStrategy;