diff --git a/src/js/big-play-button.js b/src/js/big-play-button.js index 57702a290..79f6648a7 100644 --- a/src/js/big-play-button.js +++ b/src/js/big-play-button.js @@ -47,8 +47,7 @@ class BigPlayButton extends Button { // exit early if clicked via the mouse if (this.mouseused_ && event.clientX && event.clientY) { silencePromise(playPromise); - // call handleFocus manually to get hotkeys working - this.player_.handleFocus({}); + this.player_.tech(true).focus(); return; } @@ -56,7 +55,7 @@ class BigPlayButton extends Button { const playToggle = cb && cb.getChild('playToggle'); if (!playToggle) { - this.player_.focus(); + this.player_.tech(true).focus(); return; } @@ -69,10 +68,10 @@ class BigPlayButton extends Button { } } - handleKeyPress(event) { + handleKeyDown(event) { this.mouseused_ = false; - super.handleKeyPress(event); + super.handleKeyDown(event); } handleMouseDown(event) { diff --git a/src/js/button.js b/src/js/button.js index 0b20ae585..bbfd2f14d 100644 --- a/src/js/button.js +++ b/src/js/button.js @@ -104,13 +104,20 @@ class Button extends ClickableComponent { * * @listens keydown */ - handleKeyPress(event) { - // Ignore Space or Enter key operation, which is handled by the browser for a button. - if (!(keycode.isEventKey(event, 'Space') || keycode.isEventKey(event, 'Enter'))) { + handleKeyDown(event) { - // Pass keypress handling up for unsupported keys - super.handleKeyPress(event); + // Ignore Space or Enter key operation, which is handled by the browser for + // a button - though not for its super class, ClickableComponent. Also, + // prevent the event from propagating through the DOM and triggering Player + // hotkeys. We do not preventDefault here because we _want_ the browser to + // handle it. + if (keycode.isEventKey(event, 'Space') || keycode.isEventKey(event, 'Enter')) { + event.stopPropagation(); + return; } + + // Pass keypress handling up for unsupported keys + super.handleKeyDown(event); } } diff --git a/src/js/clickable-component.js b/src/js/clickable-component.js index 00423f0c8..fd770f28e 100644 --- a/src/js/clickable-component.js +++ b/src/js/clickable-component.js @@ -3,16 +3,13 @@ */ import Component from './component'; import * as Dom from './utils/dom.js'; -import * as Events from './utils/events.js'; -import * as Fn from './utils/fn.js'; import log from './utils/log.js'; -import document from 'global/document'; import {assign} from './utils/obj'; import keycode from 'keycode'; /** - * Clickable Component which is clickable or keyboard actionable, - * but is not a native HTML button. + * Component which is clickable or keyboard actionable, but is not a + * native HTML button. * * @extends Component */ @@ -36,7 +33,7 @@ class ClickableComponent extends Component { } /** - * Create the `Component`s DOM element. + * Create the `ClickableComponent`s DOM element. * * @param {string} [tag=div] * The element's node type. @@ -83,7 +80,7 @@ class ClickableComponent extends Component { } /** - * Create a control text element on this `Component` + * Create a control text element on this `ClickableComponent` * * @param {Element} [el] * Parent element for the control text. @@ -109,7 +106,7 @@ class ClickableComponent extends Component { } /** - * Get or set the localize text to use for the controls on the `Component`. + * Get or set the localize text to use for the controls on the `ClickableComponent`. * * @param {string} [text] * Control text for element. @@ -146,7 +143,7 @@ class ClickableComponent extends Component { } /** - * Enable this `Component`s element. + * Enable this `ClickableComponent` */ enable() { if (!this.enabled_) { @@ -157,13 +154,12 @@ class ClickableComponent extends Component { this.el_.setAttribute('tabIndex', this.tabIndex_); } this.on(['tap', 'click'], this.handleClick); - this.on('focus', this.handleFocus); - this.on('blur', this.handleBlur); + this.on('keydown', this.handleKeyDown); } } /** - * Disable this `Component`s element. + * Disable this `ClickableComponent` */ disable() { this.enabled_ = false; @@ -173,27 +169,15 @@ class ClickableComponent extends Component { this.el_.removeAttribute('tabIndex'); } this.off(['tap', 'click'], this.handleClick); - this.off('focus', this.handleFocus); - this.off('blur', this.handleBlur); + this.off('keydown', this.handleKeyDown); } /** - * This gets called when a `ClickableComponent` gets: - * - Clicked (via the `click` event, listening starts in the constructor) - * - Tapped (via the `tap` event, listening starts in the constructor) - * - The following things happen in order: - * 1. {@link ClickableComponent#handleFocus} is called via a `focus` event on the - * `ClickableComponent`. - * 2. {@link ClickableComponent#handleFocus} adds a listener for `keydown` on using - * {@link ClickableComponent#handleKeyPress}. - * 3. `ClickableComponent` has not had a `blur` event (`blur` means that focus was lost). The user presses - * the space or enter key. - * 4. {@link ClickableComponent#handleKeyPress} calls this function with the `keydown` - * event as a parameter. + * Event handler that is called when a `ClickableComponent` receives a + * `click` or `tap` event. * * @param {EventTarget~Event} event - * The `keydown`, `tap`, or `click` event that caused this function to be - * called. + * The `tap` or `click` event that caused this function to be called. * * @listens tap * @listens click @@ -202,52 +186,31 @@ class ClickableComponent extends Component { handleClick(event) {} /** - * This gets called when a `ClickableComponent` gains focus via a `focus` event. - * Turns on listening for `keydown` events. When they happen it - * calls `this.handleKeyPress`. + * Event handler that is called when a `ClickableComponent` receives a + * `keydown` event. * - * @param {EventTarget~Event} event - * The `focus` event that caused this function to be called. - * - * @listens focus - */ - handleFocus(event) { - Events.on(document, 'keydown', Fn.bind(this, this.handleKeyPress)); - } - - /** - * Called when this ClickableComponent has focus and a key gets pressed down. By - * default it will call `this.handleClick` when the key is space or enter. + * By default, if the key is Space or Enter, it will trigger a `click` event. * * @param {EventTarget~Event} event * The `keydown` event that caused this function to be called. * * @listens keydown */ - handleKeyPress(event) { - // Support Space or Enter key operation to fire a click event + handleKeyDown(event) { + + // Support Space or Enter key operation to fire a click event. Also, + // prevent the event from propagating through the DOM and triggering + // Player hotkeys. if (keycode.isEventKey(event, 'Space') || keycode.isEventKey(event, 'Enter')) { event.preventDefault(); + event.stopPropagation(); this.trigger('click'); } else { // Pass keypress handling up for unsupported keys - super.handleKeyPress(event); + super.handleKeyDown(event); } } - - /** - * Called when a `ClickableComponent` loses focus. Turns off the listener for - * `keydown` events. Which Stops `this.handleKeyPress` from getting called. - * - * @param {EventTarget~Event} event - * The `blur` event that caused this function to be called. - * - * @listens blur - */ - handleBlur(event) { - Events.off(document, 'keydown', Fn.bind(this, this.handleKeyPress)); - } } Component.registerComponent('ClickableComponent', ClickableComponent); diff --git a/src/js/close-button.js b/src/js/close-button.js index f097b886f..0e40bc491 100644 --- a/src/js/close-button.js +++ b/src/js/close-button.js @@ -36,25 +36,10 @@ class CloseButton extends Button { return `vjs-close-button ${super.buildCSSClass()}`; } - /** - * This gets called when a `CloseButton` has focus and `keydown` is triggered via a key - * press. - * - * @param {EventTarget~Event} event - * The event that caused this function to get called. - * - * @listens keydown - */ - handleKeyPress(event) { - // Override the default `Button` behavior, and don't pass the keypress event - // up to the player because this button is part of a `ModalDialog`, which - // doesn't pass keypresses to the player either. - } - /** * This gets called when a `CloseButton` gets clicked. See - * {@link ClickableComponent#handleClick} for more information on when this will be - * triggered + * {@link ClickableComponent#handleClick} for more information on when + * this will be triggered * * @param {EventTarget~Event} event * The `keydown`, `tap`, or `click` event that caused this function to be diff --git a/src/js/component.js b/src/js/component.js index b9fd11272..0319e76fa 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -1078,18 +1078,35 @@ class Component { } /** - * When this Component receives a keydown event which it does not process, + * When this Component receives a `keydown` event which it does not process, * it passes the event to the Player for handling. * * @param {EventTarget~Event} event * The `keydown` event that caused this function to be called. */ - handleKeyPress(event) { + handleKeyDown(event) { if (this.player_) { - this.player_.handleKeyPress(event); + + // We only stop propagation here because we want unhandled events to fall + // back to the browser. + event.stopPropagation(); + this.player_.handleKeyDown(event); } } + /** + * Many components used to have a `handleKeyPress` method, which was poorly + * named because it listened to a `keydown` event. This method name now + * delegates to `handleKeyDown`. This means anyone calling `handleKeyPress` + * will not see their method calls stop working. + * + * @param {EventTarget~Event} event + * The event that caused this function to be called. + */ + handleKeyPress(event) { + this.handleKeyDown(event); + } + /** * Emit a 'tap' events when touch event support gets detected. This gets used to * support toggling the controls through a tap on the video. They get enabled diff --git a/src/js/control-bar/progress-control/seek-bar.js b/src/js/control-bar/progress-control/seek-bar.js index 2c5395b3c..ea19ff76a 100644 --- a/src/js/control-bar/progress-control/seek-bar.js +++ b/src/js/control-bar/progress-control/seek-bar.js @@ -407,30 +407,36 @@ class SeekBar extends Slider { * * @listens keydown */ - handleKeyPress(event) { + handleKeyDown(event) { if (keycode.isEventKey(event, 'Space') || keycode.isEventKey(event, 'Enter')) { event.preventDefault(); + event.stopPropagation(); this.handleAction(event); } else if (keycode.isEventKey(event, 'Home')) { event.preventDefault(); + event.stopPropagation(); this.player_.currentTime(0); } else if (keycode.isEventKey(event, 'End')) { event.preventDefault(); + event.stopPropagation(); this.player_.currentTime(this.player_.duration()); } else if (/^[0-9]$/.test(keycode(event))) { event.preventDefault(); + event.stopPropagation(); const gotoFraction = (keycode.codes[keycode(event)] - keycode.codes['0']) * 10.0 / 100.0; this.player_.currentTime(this.player_.duration() * gotoFraction); } else if (keycode.isEventKey(event, 'PgDn')) { event.preventDefault(); + event.stopPropagation(); this.player_.currentTime(this.player_.currentTime() - (STEP_SECONDS * PAGE_KEY_MULTIPLIER)); } else if (keycode.isEventKey(event, 'PgUp')) { event.preventDefault(); + event.stopPropagation(); this.player_.currentTime(this.player_.currentTime() + (STEP_SECONDS * PAGE_KEY_MULTIPLIER)); } else { - // Pass keypress handling up for unsupported keys - super.handleKeyPress(event); + // Pass keydown handling up for unsupported keys + super.handleKeyDown(event); } } } diff --git a/src/js/menu/menu-button.js b/src/js/menu/menu-button.js index c41e4e619..7b7868930 100644 --- a/src/js/menu/menu-button.js +++ b/src/js/menu/menu-button.js @@ -5,11 +5,8 @@ import Button from '../button.js'; import Component from '../component.js'; import Menu from './menu.js'; import * as Dom from '../utils/dom.js'; -import * as Fn from '../utils/fn.js'; -import * as Events from '../utils/events.js'; import toTitleCase from '../utils/to-title-case.js'; import { IS_IOS } from '../utils/browser.js'; -import document from 'global/document'; import keycode from 'keycode'; /** @@ -50,12 +47,12 @@ class MenuButton extends Component { this.on(this.menuButton_, 'tap', this.handleClick); this.on(this.menuButton_, 'click', this.handleClick); - this.on(this.menuButton_, 'focus', this.handleFocus); - this.on(this.menuButton_, 'blur', this.handleBlur); + this.on(this.menuButton_, 'keydown', this.handleKeyDown); this.on(this.menuButton_, 'mouseenter', () => { this.menu.show(); }); - this.on('keydown', this.handleSubmenuKeyPress); + + this.on('keydown', this.handleSubmenuKeyDown); } /** @@ -246,48 +243,23 @@ class MenuButton extends Component { this.menuButton_.blur(); } - /** - * This gets called when a `MenuButton` gains focus via a `focus` event. - * Turns on listening for `keydown` events. When they happen it - * calls `this.handleKeyPress`. - * - * @param {EventTarget~Event} event - * The `focus` event that caused this function to be called. - * - * @listens focus - */ - handleFocus() { - Events.on(document, 'keydown', Fn.bind(this, this.handleKeyPress)); - } - - /** - * Called when a `MenuButton` loses focus. Turns off the listener for - * `keydown` events. Which Stops `this.handleKeyPress` from getting called. - * - * @param {EventTarget~Event} event - * The `blur` event that caused this function to be called. - * - * @listens blur - */ - handleBlur() { - Events.off(document, 'keydown', Fn.bind(this, this.handleKeyPress)); - } - /** * Handle tab, escape, down arrow, and up arrow keys for `MenuButton`. See - * {@link ClickableComponent#handleKeyPress} for instances where this is called. + * {@link ClickableComponent#handleKeyDown} for instances where this is called. * * @param {EventTarget~Event} event * The `keydown` event that caused this function to be called. * * @listens keydown */ - handleKeyPress(event) { + handleKeyDown(event) { + // Escape or Tab unpress the 'button' if (keycode.isEventKey(event, 'Esc') || keycode.isEventKey(event, 'Tab')) { if (this.buttonPressed_) { this.unpressButton(); } + // Don't preventDefault for Tab key - we still want to lose focus if (!keycode.isEventKey(event, 'Tab')) { event.preventDefault(); @@ -300,14 +272,21 @@ class MenuButton extends Component { event.preventDefault(); this.pressButton(); } - } else { - // NOTE: This is a special case where we don't pass unhandled - // keypress events up to the Component handler, because it is - // just entending the keypress handling of the actual `Button` - // in the `MenuButton` which already passes unused keys up. } } + /** + * This method name now delegates to `handleSubmenuKeyDown`. This means + * anyone calling `handleSubmenuKeyPress` will not see their method calls + * stop working. + * + * @param {EventTarget~Event} event + * The event that caused this function to be called. + */ + handleSubmenuKeyPress(event) { + this.handleSubmenuKeyDown(event); + } + /** * Handle a `keydown` event on a sub-menu. The listener for this is added in * the constructor. @@ -317,7 +296,7 @@ class MenuButton extends Component { * * @listens keydown */ - handleSubmenuKeyPress(event) { + handleSubmenuKeyDown(event) { // Escape or Tab unpress the 'button' if (keycode.isEventKey(event, 'Esc') || keycode.isEventKey(event, 'Tab')) { if (this.buttonPressed_) { @@ -331,8 +310,8 @@ class MenuButton extends Component { } } else { // NOTE: This is a special case where we don't pass unhandled - // keypress events up to the Component handler, because it is - // just entending the keypress handling of the `MenuItem` + // keydown events up to the Component handler, because it is + // just entending the keydown handling of the `MenuItem` // in the `Menu` which already passes unused keys up. } } diff --git a/src/js/menu/menu-item.js b/src/js/menu/menu-item.js index 05a26f4f2..c34622f10 100644 --- a/src/js/menu/menu-item.js +++ b/src/js/menu/menu-item.js @@ -72,17 +72,17 @@ class MenuItem extends ClickableComponent { /** * Ignore keys which are used by the menu, but pass any other ones up. See - * {@link ClickableComponent#handleKeyPress} for instances where this is called. + * {@link ClickableComponent#handleKeyDown} for instances where this is called. * * @param {EventTarget~Event} event * The `keydown` event that caused this function to be called. * * @listens keydown */ - handleKeyPress(event) { + handleKeyDown(event) { if (!MenuKeys.some((key) => keycode.isEventKey(event, key))) { - // Pass keypress handling up for unused keys - super.handleKeyPress(event); + // Pass keydown handling up for unused keys + super.handleKeyDown(event); } } diff --git a/src/js/menu/menu.js b/src/js/menu/menu.js index e40fba0c2..0e469453e 100644 --- a/src/js/menu/menu.js +++ b/src/js/menu/menu.js @@ -35,7 +35,7 @@ class Menu extends Component { this.focusedChild_ = -1; - this.on('keydown', this.handleKeyPress); + this.on('keydown', this.handleKeyDown); // All the menu item instances share the same blur handler provided by the menu container. this.boundHandleBlur_ = Fn.bind(this, this.handleBlur); @@ -211,22 +211,19 @@ class Menu extends Component { * * @listens keydown */ - handleKeyPress(event) { + handleKeyDown(event) { + // Left and Down Arrows if (keycode.isEventKey(event, 'Left') || keycode.isEventKey(event, 'Down')) { event.preventDefault(); + event.stopPropagation(); this.stepForward(); // Up and Right Arrows } else if (keycode.isEventKey(event, 'Right') || keycode.isEventKey(event, 'Up')) { event.preventDefault(); + event.stopPropagation(); this.stepBack(); - } else { - // NOTE: This is a special case where we don't pass unhandled - // keypress events up to the Component handler, because this - // is just adding a keypress handler on top of the MenuItem's - // existing keypress handler, which already handles passing keypress - // events up. } } diff --git a/src/js/modal-dialog.js b/src/js/modal-dialog.js index c3589a742..ac2771f83 100644 --- a/src/js/modal-dialog.js +++ b/src/js/modal-dialog.js @@ -2,7 +2,6 @@ * @file modal-dialog.js */ import * as Dom from './utils/dom'; -import * as Fn from './utils/fn'; import Component from './component'; import window from 'global/window'; import document from 'global/document'; @@ -119,21 +118,6 @@ class ModalDialog extends Component { return `${MODAL_CLASS_NAME} vjs-hidden ${super.buildCSSClass()}`; } - /** - * Handles `keydown` events on the document, looking for ESC, which closes - * the modal. - * - * @param {EventTarget~Event} event - * The keypress that triggered this event. - * - * @listens keydown - */ - handleKeyPress(event) { - if (keycode.isEventKey(event, 'Escape') && this.closeable()) { - this.close(); - } - } - /** * Returns the label string for this modal. Primarily used for accessibility. * @@ -195,9 +179,7 @@ class ModalDialog extends Component { player.pause(); } - if (this.closeable()) { - this.on(this.el_.ownerDocument, 'keydown', Fn.bind(this, this.handleKeyPress)); - } + this.on('keydown', this.handleKeyDown); // Hide controls and note if they were enabled. this.hadControls_ = player.controls(); @@ -260,9 +242,7 @@ class ModalDialog extends Component { player.play(); } - if (this.closeable()) { - this.off(this.el_.ownerDocument, 'keydown', Fn.bind(this, this.handleKeyPress)); - } + this.off('keydown', this.handleKeyDown); if (this.hadControls_) { player.controls(true); @@ -444,8 +424,6 @@ class ModalDialog extends Component { this.previouslyActiveEl_ = activeEl; this.focus(); - - this.on(document, 'keydown', this.handleKeyDown); } } @@ -459,8 +437,6 @@ class ModalDialog extends Component { this.previouslyActiveEl_.focus(); this.previouslyActiveEl_ = null; } - - this.off(document, 'keydown', this.handleKeyDown); } /** @@ -469,6 +445,16 @@ class ModalDialog extends Component { * @listens keydown */ handleKeyDown(event) { + + // Do not allow keydowns to reach out of the modal dialog. + event.stopPropagation(); + + if (keycode.isEventKey(event, 'Escape') && this.closeable()) { + event.preventDefault(); + this.close(); + return; + } + // exit early if it isn't a tab key if (!keycode.isEventKey(event, 'Tab')) { return; diff --git a/src/js/player.js b/src/js/player.js index e6244840e..e9091bed6 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -354,7 +354,6 @@ class Player extends Component { // Create bound methods for document listeners. this.boundDocumentFullscreenChange_ = Fn.bind(this, this.documentFullscreenChange_); this.boundFullWindowOnEscKey_ = Fn.bind(this, this.fullWindowOnEscKey); - this.boundHandleKeyPress_ = Fn.bind(this, this.handleKeyPress); // create logger this.log = createLogger(this.id_); @@ -532,9 +531,8 @@ class Player extends Component { this.reportUserActivity(); this.one('play', this.listenForUserActivity_); - this.on('focus', this.handleFocus); - this.on('blur', this.handleBlur); this.on('stageclick', this.handleStageClick_); + this.on('keydown', this.handleKeyDown); this.breakpoints(this.options_.breakpoints); this.responsive(this.options_.responsive); @@ -563,7 +561,6 @@ class Player extends Component { // Make sure all player-specific document listeners are unbound. This is Events.off(document, FullscreenApi.fullscreenchange, this.boundDocumentFullscreenChange_); Events.off(document, 'keydown', this.boundFullWindowOnEscKey_); - Events.off(document, 'keydown', this.boundHandleKeyPress_); if (this.styleEl_ && this.styleEl_.parentNode) { this.styleEl_.parentNode.removeChild(this.styleEl_); @@ -2804,35 +2801,6 @@ class Player extends Component { this.trigger('exitFullWindow'); } - /** - * This gets called when a `Player` gains focus via a `focus` event. - * Turns on listening for `keydown` events. When they happen it - * calls `this.handleKeyPress`. - * - * @param {EventTarget~Event} event - * The `focus` event that caused this function to be called. - * - * @listens focus - */ - handleFocus(event) { - // call off first to make sure we don't keep adding keydown handlers - Events.off(document, 'keydown', this.boundHandleKeyPress_); - Events.on(document, 'keydown', this.boundHandleKeyPress_); - } - - /** - * Called when a `Player` loses focus. Turns off the listener for - * `keydown` events. Which Stops `this.handleKeyPress` from getting called. - * - * @param {EventTarget~Event} event - * The `blur` event that caused this function to be called. - * - * @listens blur - */ - handleBlur(event) { - Events.off(document, 'keydown', this.boundHandleKeyPress_); - } - /** * Called when this Player has focus and a key gets pressed down, or when * any Component of this player receives a key press that it doesn't handle. @@ -2844,19 +2812,49 @@ class Player extends Component { * * @listens keydown */ - handleKeyPress(event) { + handleKeyDown(event) { + const {userActions} = this.options_; - if (this.options_.userActions && this.options_.userActions.hotkeys && (this.options_.userActions.hotkeys !== false)) { + // Bail out if hotkeys are not configured. + if (!userActions || !userActions.hotkeys) { + return; + } - if (typeof this.options_.userActions.hotkeys === 'function') { + // Function that determines whether or not to exclude an element from + // hotkeys handling. + const excludeElement = (el) => { + const tagName = el.tagName.toLowerCase(); - this.options_.userActions.hotkeys.call(this, event); + // These tags will be excluded entirely. + const excludedTags = ['textarea']; - } else { - - this.handleHotkeys(event); + // Inputs matching these types will still trigger hotkey handling as + // they are not text inputs. + const allowedInputTypes = [ + 'button', + 'checkbox', + 'hidden', + 'radio', + 'reset', + 'submit' + ]; + if (tagName === 'input') { + return allowedInputTypes.indexOf(el.type) === -1; } + + return excludedTags.indexOf(tagName) !== -1; + }; + + // Bail out if the user is focused on an interactive form element. + if (excludeElement(this.el_.ownerDocument.activeElement)) { + return; + } + + if (typeof userActions.hotkeys === 'function') { + userActions.hotkeys.call(this, event); + } else { + this.handleHotkeys(event); } } @@ -2882,8 +2880,8 @@ class Player extends Component { } = hotkeys; if (fullscreenKey.call(this, event)) { - event.preventDefault(); + event.stopPropagation(); const FSToggle = Component.getComponent('FullscreenToggle'); @@ -2892,16 +2890,16 @@ class Player extends Component { } } else if (muteKey.call(this, event)) { - event.preventDefault(); + event.stopPropagation(); const MuteToggle = Component.getComponent('MuteToggle'); MuteToggle.prototype.handleClick.call(this); } else if (playPauseKey.call(this, event)) { - event.preventDefault(); + event.stopPropagation(); const PlayToggle = Component.getComponent('PlayToggle'); diff --git a/src/js/poster-image.js b/src/js/poster-image.js index 010d995ca..da19927bb 100644 --- a/src/js/poster-image.js +++ b/src/js/poster-image.js @@ -112,14 +112,13 @@ class PosterImage extends ClickableComponent { return; } + this.player_.tech(true).focus(); + if (this.player_.paused()) { silencePromise(this.player_.play()); } else { this.player_.pause(); } - - // call handleFocus manually to get hotkeys working - this.player_.handleFocus({}); } } diff --git a/src/js/slider/slider.js b/src/js/slider/slider.js index 55587a41e..3df300362 100644 --- a/src/js/slider/slider.js +++ b/src/js/slider/slider.js @@ -56,8 +56,7 @@ class Slider extends Component { this.on('mousedown', this.handleMouseDown); this.on('touchstart', this.handleMouseDown); - this.on('focus', this.handleFocus); - this.on('blur', this.handleBlur); + this.on('keydown', this.handleKeyDown); this.on('click', this.handleClick); this.on(this.player_, 'controlsvisible', this.update); @@ -83,8 +82,7 @@ class Slider extends Component { this.off('mousedown', this.handleMouseDown); this.off('touchstart', this.handleMouseDown); - this.off('focus', this.handleFocus); - this.off('blur', this.handleBlur); + this.off('keydown', this.handleKeyDown); this.off('click', this.handleClick); this.off(this.player_, 'controlsvisible', this.update); this.off(doc, 'mousemove', this.handleMouseMove); @@ -293,18 +291,6 @@ class Slider extends Component { return position.x; } - /** - * Handle a `focus` event on this `Slider`. - * - * @param {EventTarget~Event} event - * The `focus` event that caused this function to run. - * - * @listens focus - */ - handleFocus() { - this.on(this.bar.el_.ownerDocument, 'keydown', this.handleKeyPress); - } - /** * Handle a `keydown` event on the `Slider`. Watches for left, rigth, up, and down * arrow keys. This function will only be called when the slider has focus. See @@ -315,36 +301,26 @@ class Slider extends Component { * * @listens keydown */ - handleKeyPress(event) { + handleKeyDown(event) { + // Left and Down Arrows if (keycode.isEventKey(event, 'Left') || keycode.isEventKey(event, 'Down')) { event.preventDefault(); + event.stopPropagation(); this.stepBack(); // Up and Right Arrows } else if (keycode.isEventKey(event, 'Right') || keycode.isEventKey(event, 'Up')) { event.preventDefault(); + event.stopPropagation(); this.stepForward(); } else { - // Pass keypress handling up for unsupported keys - super.handleKeyPress(event); + // Pass keydown handling up for unsupported keys + super.handleKeyDown(event); } } - /** - * Handle a `blur` event on this `Slider`. - * - * @param {EventTarget~Event} event - * The `blur` event that caused this function to run. - * - * @listens blur - */ - - handleBlur() { - this.off(this.bar.el_.ownerDocument, 'keydown', this.handleKeyPress); - } - /** * Listener for click events on slider, used to prevent clicks * from bubbling up to parent elements like button menus. @@ -353,7 +329,7 @@ class Slider extends Component { * Event that caused this object to run */ handleClick(event) { - event.stopImmediatePropagation(); + event.stopPropagation(); event.preventDefault(); } diff --git a/src/js/tracks/text-track-settings.js b/src/js/tracks/text-track-settings.js index 9f6795484..93fc94ab3 100644 --- a/src/js/tracks/text-track-settings.js +++ b/src/js/tracks/text-track-settings.js @@ -2,7 +2,6 @@ * @file text-track-settings.js */ import window from 'global/window'; -import document from 'global/document'; import Component from '../component'; import ModalDialog from '../modal-dialog'; import {createEl} from '../utils/dom'; @@ -590,7 +589,6 @@ class TextTrackSettings extends ModalDialog { */ conditionalBlur_() { this.previouslyActiveEl_ = null; - this.off(document, 'keydown', this.handleKeyDown); const cb = this.player_.controlBar; const subsCapsBtn = cb && cb.subsCapsButton; diff --git a/test/unit/menu.test.js b/test/unit/menu.test.js index 72e68c713..857830f20 100644 --- a/test/unit/menu.test.js +++ b/test/unit/menu.test.js @@ -146,35 +146,25 @@ QUnit.test('should remove old event listeners when the menu item adds to the new // `Menu`.`children` will be called when triggering blur event on the menu item. const menuChildrenSpy = sinon.spy(watchedMenu, 'children'); - // The number of blur listeners is two because `ClickableComponent` - // adds the blur event listener during the construction and + assert.strictEqual(eventData.handlers.blur.length, 1, 'the number of blur listeners is one'); + + // The number of click listeners is two because `ClickableComponent` + // adds the click event listener during the construction and // `MenuItem` inherits from `ClickableComponent`. - assert.strictEqual(eventData.handlers.blur.length, 2, 'the number of blur listeners is two'); - // Same reason mentioned above. assert.strictEqual(eventData.handlers.click.length, 2, 'the number of click listeners is two'); - const blurListenerAddedByMenu = eventData.handlers.blur[1]; const clickListenerAddedByMenu = eventData.handlers.click[1]; - assert.strictEqual( - typeof blurListenerAddedByMenu.calledOnce, - 'undefined', - 'previous blur listener wrapped in the spy should be removed' - ); - assert.strictEqual( typeof clickListenerAddedByMenu.calledOnce, 'undefined', 'previous click listener wrapped in the spy should be removed' ); - const blurListenerSpy = eventData.handlers.blur[1] = sinon.spy(blurListenerAddedByMenu); const clickListenerSpy = eventData.handlers.click[1] = sinon.spy(clickListenerAddedByMenu); TestHelpers.triggerDomEvent(menuItem.el(), 'blur'); - assert.ok(blurListenerSpy.calledOnce, 'blur event listener should be called'); - assert.strictEqual(blurListenerSpy.getCall(0).args[0].target, menuItem.el(), 'event target should be the `menuItem`'); assert.ok(menuChildrenSpy.calledOnce, '`watchedMenu`.`children` has been called'); TestHelpers.triggerDomEvent(menuItem.el(), 'click'); diff --git a/test/unit/modal-dialog.test.js b/test/unit/modal-dialog.test.js index 82d8c61fe..9d6550a42 100644 --- a/test/unit/modal-dialog.test.js +++ b/test/unit/modal-dialog.test.js @@ -5,7 +5,11 @@ import ModalDialog from '../../src/js/modal-dialog'; import * as Dom from '../../src/js/utils/dom'; import TestHelpers from './test-helpers'; -const ESC = 27; +const getMockEscapeEvent = () => ({ + which: 27, + preventDefault() {}, + stopPropagation() {} +}); QUnit.module('ModalDialog', { @@ -55,6 +59,8 @@ const tabTestHelper = function(assert, player) { shiftKey: shift, preventDefault() { prevented = true; + }, + stopPropagation() { } }); @@ -208,12 +214,12 @@ QUnit.test('pressing ESC triggers close(), but only when the modal is opened', f const spy = sinon.spy(); this.modal.on('modalclose', spy); - this.modal.handleKeyPress({which: ESC}); + this.modal.handleKeyDown(getMockEscapeEvent()); assert.expect(2); assert.strictEqual(spy.callCount, 0, 'ESC did not close the closed modal'); this.modal.open(); - this.modal.handleKeyPress({which: ESC}); + this.modal.handleKeyDown(getMockEscapeEvent()); assert.strictEqual(spy.callCount, 1, 'ESC closed the now-opened modal'); }); @@ -392,7 +398,7 @@ QUnit.test('closeable()', function(assert) { assert.notOk(this.modal.getChild('closeButton'), 'the close button is no longer a child of the modal'); assert.notOk(initialCloseButton.el(), 'the initial close button was disposed'); - this.modal.handleKeyPress({which: ESC}); + this.modal.handleKeyDown(getMockEscapeEvent()); assert.ok(this.modal.opened(), 'the modal was not closed by the ESC key'); this.modal.close(); @@ -406,7 +412,7 @@ QUnit.test('closeable()', function(assert) { assert.notOk(this.modal.opened(), 'the modal was closed by the new close button'); this.modal.open(); - this.modal.handleKeyPress({which: ESC}); + this.modal.handleKeyDown(getMockEscapeEvent()); assert.notOk(this.modal.opened(), 'the modal was closed by the ESC key'); }); @@ -494,7 +500,7 @@ QUnit.test('"uncloseable" option', function(assert) { assert.notOk(modal.getChild('closeButton'), 'the close button is not present'); modal.open(); - modal.handleKeyPress({which: ESC}); + modal.handleKeyDown(getMockEscapeEvent()); assert.strictEqual(spy.callCount, 0, 'ESC did not close the modal'); modal.dispose(); }); diff --git a/test/unit/player-user-actions.test.js b/test/unit/player-user-actions.test.js new file mode 100644 index 000000000..97e0ddea3 --- /dev/null +++ b/test/unit/player-user-actions.test.js @@ -0,0 +1,493 @@ +/* eslint-env qunit */ +import document from 'global/document'; +import keycode from 'keycode'; +import sinon from 'sinon'; +import TestHelpers from './test-helpers'; +import FullscreenApi from '../../src/js/fullscreen-api.js'; + +QUnit.module('Player: User Actions: Double Click', { + + beforeEach() { + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer({controls: true}); + }, + + afterEach() { + this.player.dispose(); + this.clock.restore(); + } +}); + +QUnit.test('by default, double-click opens fullscreen', function(assert) { + let fullscreen = false; + + this.player.isFullscreen = () => fullscreen; + this.player.requestFullscreen = sinon.spy(); + this.player.exitFullscreen = sinon.spy(); + + this.player.handleTechDoubleClick_({target: this.player.tech_.el_}); + + assert.strictEqual(this.player.requestFullscreen.callCount, 1, 'has gone fullscreen once'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + + fullscreen = true; + this.player.handleTechDoubleClick_({target: this.player.tech_.el_}); + + assert.strictEqual(this.player.requestFullscreen.callCount, 1, 'has gone fullscreen once'); + assert.strictEqual(this.player.exitFullscreen.callCount, 1, 'has exited fullscreen'); +}); + +QUnit.test('when controls are disabled, double-click does nothing', function(assert) { + let fullscreen = false; + + this.player.controls(false); + + this.player.isFullscreen = () => fullscreen; + this.player.requestFullscreen = sinon.spy(); + this.player.exitFullscreen = sinon.spy(); + + this.player.handleTechDoubleClick_({target: this.player.tech_.el_}); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + + fullscreen = true; + this.player.handleTechDoubleClick_({target: this.player.tech_.el_}); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); +}); + +QUnit.test('when userActions.doubleClick is false, double-click does nothing', function(assert) { + let fullscreen = false; + + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + doubleClick: false + } + }); + + this.player.isFullscreen = () => fullscreen; + this.player.requestFullscreen = sinon.spy(); + this.player.exitFullscreen = sinon.spy(); + + this.player.handleTechDoubleClick_({target: this.player.tech_.el_}); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + + fullscreen = true; + this.player.handleTechDoubleClick_({target: this.player.tech_.el_}); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); +}); + +QUnit.test('when userActions.doubleClick is a function, that function is called instead of going fullscreen', function(assert) { + let fullscreen = false; + + const doubleClickSpy = sinon.spy(); + + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + doubleClick: doubleClickSpy + } + }); + + this.player.isFullscreen = () => fullscreen; + this.player.requestFullscreen = sinon.spy(); + this.player.exitFullscreen = sinon.spy(); + + let event = {target: this.player.tech_.el_}; + + this.player.handleTechDoubleClick_(event); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + assert.strictEqual(doubleClickSpy.callCount, 1, 'has called the doubleClick handler'); + assert.strictEqual(doubleClickSpy.getCall(0).args[0], event, 'has passed the event to the handler'); + + fullscreen = true; + event = {target: this.player.tech_.el_}; + this.player.handleTechDoubleClick_(event); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + assert.strictEqual(doubleClickSpy.callCount, 2, 'has called the doubleClick handler'); + assert.strictEqual(doubleClickSpy.getCall(1).args[0], event, 'has passed the event to the handler'); +}); + +QUnit.module('Player: User Actions: Hotkeys', { + + beforeEach() { + this.clock = sinon.useFakeTimers(); + this.player = TestHelpers.makePlayer(); + }, + + afterEach() { + this.player.dispose(); + this.clock.restore(); + } +}); + +const mockKeyDownEvent = (key) => { + return { + preventDefault() {}, + stopPropagation() {}, + type: 'keydown', + which: keycode.codes[key] + }; +}; + +const defaultKeyTests = { + fullscreen(player, assert, positive) { + let fullscreen; + + if (document[FullscreenApi.fullscreenEnabled] === false) { + assert.ok(true, 'skipped fullscreen test because not supported'); + assert.ok(true, 'skipped fullscreen test because not supported'); + assert.ok(true, 'skipped fullscreen test because not supported'); + assert.ok(true, 'skipped fullscreen test because not supported'); + return; + } + + player.isFullscreen = () => fullscreen; + player.requestFullscreen = sinon.spy(); + player.exitFullscreen = sinon.spy(); + + fullscreen = false; + player.handleKeyDown(mockKeyDownEvent('f')); + + if (positive) { + assert.strictEqual(player.requestFullscreen.callCount, 1, 'has gone fullscreen'); + assert.strictEqual(player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + } else { + assert.strictEqual(player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + } + + fullscreen = true; + player.handleKeyDown(mockKeyDownEvent('f')); + + if (positive) { + assert.strictEqual(player.requestFullscreen.callCount, 1, 'has gone fullscreen'); + assert.strictEqual(player.exitFullscreen.callCount, 1, 'has exited fullscreen'); + } else { + assert.strictEqual(player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + } + }, + mute(player, assert, positive) { + let muted = false; + + player.muted = sinon.spy((val) => { + if (val !== undefined) { + muted = val; + } + return muted; + }); + + player.handleKeyDown(mockKeyDownEvent('m')); + + if (positive) { + assert.strictEqual(player.muted.callCount, 2, 'muted was called twice (get and set)'); + assert.strictEqual(player.muted.lastCall.args[0], true, 'most recent call was to mute'); + } else { + assert.strictEqual(player.muted.callCount, 0, 'muted was not called'); + } + + player.handleKeyDown(mockKeyDownEvent('m')); + + if (positive) { + assert.strictEqual(player.muted.callCount, 4, 'muted was called twice (get and set)'); + assert.strictEqual(player.muted.lastCall.args[0], false, 'most recent call was to unmute'); + } else { + assert.strictEqual(player.muted.callCount, 0, 'muted was not called'); + } + }, + playPause(player, assert, positive) { + let paused; + + player.paused = () => paused; + player.pause = sinon.spy(); + player.play = sinon.spy(); + + paused = true; + player.handleKeyDown(mockKeyDownEvent('k')); + + if (positive) { + assert.strictEqual(player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(player.play.callCount, 1, 'has played'); + } else { + assert.strictEqual(player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(player.play.callCount, 0, 'has not played'); + } + + paused = false; + player.handleKeyDown(mockKeyDownEvent('k')); + + if (positive) { + assert.strictEqual(player.pause.callCount, 1, 'has paused'); + assert.strictEqual(player.play.callCount, 1, 'has played'); + } else { + assert.strictEqual(player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(player.play.callCount, 0, 'has not played'); + } + + paused = true; + player.handleKeyDown(mockKeyDownEvent('space')); + + if (positive) { + assert.strictEqual(player.pause.callCount, 1, 'has paused'); + assert.strictEqual(player.play.callCount, 2, 'has played twice'); + } else { + assert.strictEqual(player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(player.play.callCount, 0, 'has not played'); + } + + paused = false; + player.handleKeyDown(mockKeyDownEvent('space')); + + if (positive) { + assert.strictEqual(player.pause.callCount, 2, 'has paused twice'); + assert.strictEqual(player.play.callCount, 2, 'has played twice'); + } else { + assert.strictEqual(player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(player.play.callCount, 0, 'has not played'); + } + } +}; + +QUnit.test('by default, hotkeys are disabled', function(assert) { + assert.expect(14); + defaultKeyTests.fullscreen(this.player, assert, false); + defaultKeyTests.mute(this.player, assert, false); + defaultKeyTests.playPause(this.player, assert, false); +}); + +QUnit.test('when userActions.hotkeys is true, hotkeys are enabled', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: true + } + }); + + assert.expect(16); + defaultKeyTests.fullscreen(this.player, assert, true); + defaultKeyTests.mute(this.player, assert, true); + defaultKeyTests.playPause(this.player, assert, true); +}); + +QUnit.test('when userActions.hotkeys is an object, hotkeys are enabled', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: {} + } + }); + + assert.expect(16); + defaultKeyTests.fullscreen(this.player, assert, true); + defaultKeyTests.mute(this.player, assert, true); + defaultKeyTests.playPause(this.player, assert, true); +}); + +QUnit.test('when userActions.hotkeys.fullscreenKey can be a function', function(assert) { + if (document[FullscreenApi.fullscreenEnabled] === false) { + assert.expect(0); + return; + } + + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: { + fullscreenKey: sinon.spy((e) => keycode.isEventKey(e, 'x')) + } + } + }); + + let fullscreen; + + this.player.isFullscreen = () => fullscreen; + this.player.requestFullscreen = sinon.spy(); + this.player.exitFullscreen = sinon.spy(); + + fullscreen = false; + this.player.handleKeyDown(mockKeyDownEvent('f')); + + assert.strictEqual(this.player.requestFullscreen.callCount, 0, 'has not gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + + this.player.handleKeyDown(mockKeyDownEvent('x')); + + assert.strictEqual(this.player.requestFullscreen.callCount, 1, 'has gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 0, 'has not exited fullscreen'); + + fullscreen = true; + this.player.handleKeyDown(mockKeyDownEvent('x')); + + assert.strictEqual(this.player.requestFullscreen.callCount, 1, 'has gone fullscreen'); + assert.strictEqual(this.player.exitFullscreen.callCount, 1, 'has exited fullscreen'); +}); + +QUnit.test('when userActions.hotkeys.muteKey can be a function', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: { + muteKey: sinon.spy((e) => keycode.isEventKey(e, 'x')) + } + } + }); + + let muted = false; + + this.player.muted = sinon.spy((val) => { + if (val !== undefined) { + muted = val; + } + return muted; + }); + + this.player.handleKeyDown(mockKeyDownEvent('m')); + + assert.strictEqual(this.player.muted.callCount, 0, 'muted was not called'); + + this.player.handleKeyDown(mockKeyDownEvent('x')); + + assert.strictEqual(this.player.muted.callCount, 2, 'muted was called twice (get and set)'); + assert.strictEqual(this.player.muted.lastCall.args[0], true, 'most recent call was to mute'); + + this.player.handleKeyDown(mockKeyDownEvent('x')); + + assert.strictEqual(this.player.muted.callCount, 4, 'muted was called twice (get and set)'); + assert.strictEqual(this.player.muted.lastCall.args[0], false, 'most recent call was to unmute'); +}); + +QUnit.test('when userActions.hotkeys.playPauseKey can be a function', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: { + playPauseKey: sinon.spy((e) => keycode.isEventKey(e, 'x')) + } + } + }); + + let paused; + + this.player.paused = () => paused; + this.player.pause = sinon.spy(); + this.player.play = sinon.spy(); + + paused = true; + this.player.handleKeyDown(mockKeyDownEvent('k')); + this.player.handleKeyDown(mockKeyDownEvent('space')); + + assert.strictEqual(this.player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(this.player.play.callCount, 0, 'has not played'); + + this.player.handleKeyDown(mockKeyDownEvent('x')); + + assert.strictEqual(this.player.pause.callCount, 0, 'has not paused'); + assert.strictEqual(this.player.play.callCount, 1, 'has played'); + + paused = false; + this.player.handleKeyDown(mockKeyDownEvent('x')); + + assert.strictEqual(this.player.pause.callCount, 1, 'has paused'); + assert.strictEqual(this.player.play.callCount, 1, 'has played'); +}); + +QUnit.test('hotkeys are ignored when focus is in a textarea', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: true + } + }); + + const textarea = document.createElement('textarea'); + + this.player.el_.appendChild(textarea); + textarea.focus(); + + assert.expect(14); + defaultKeyTests.fullscreen(this.player, assert, false); + defaultKeyTests.mute(this.player, assert, false); + defaultKeyTests.playPause(this.player, assert, false); +}); + +QUnit.test('hotkeys are ignored when focus is in a text input', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: true + } + }); + + const input = document.createElement('input'); + + input.type = 'text'; + this.player.el_.appendChild(input); + input.focus(); + + assert.expect(14); + defaultKeyTests.fullscreen(this.player, assert, false); + defaultKeyTests.mute(this.player, assert, false); + defaultKeyTests.playPause(this.player, assert, false); +}); + +QUnit.test('hotkeys are NOT ignored when focus is on a button element', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: true + } + }); + + const button = document.createElement('button'); + + this.player.el_.appendChild(button); + button.focus(); + + assert.expect(16); + defaultKeyTests.fullscreen(this.player, assert, true); + defaultKeyTests.mute(this.player, assert, true); + defaultKeyTests.playPause(this.player, assert, true); +}); + +QUnit.test('hotkeys are NOT ignored when focus is on a button input', function(assert) { + this.player.dispose(); + this.player = TestHelpers.makePlayer({ + controls: true, + userActions: { + hotkeys: true + } + }); + + const input = document.createElement('input'); + + input.type = 'button'; + this.player.el_.appendChild(input); + input.focus(); + + assert.expect(16); + defaultKeyTests.fullscreen(this.player, assert, true); + defaultKeyTests.mute(this.player, assert, true); + defaultKeyTests.playPause(this.player, assert, true); +});