/** * @file menu-button.js */ import Button from '../button.js'; import Component from '../component.js'; import Menu from './menu.js'; import * as Dom from '../utils/dom.js'; import * as Events from '../utils/events.js'; import {toTitleCase} from '../utils/str.js'; import { IS_IOS } from '../utils/browser.js'; import document from 'global/document'; import keycode from 'keycode'; /** * A `MenuButton` class for any popup {@link Menu}. * * @extends Component */ class MenuButton extends Component { /** * Creates an instance of this class. * * @param { import('../player').default } player * The `Player` that this class should be attached to. * * @param {Object} [options={}] * The key/value store of player options. */ constructor(player, options = {}) { super(player, options); this.menuButton_ = new Button(player, options); this.menuButton_.controlText(this.controlText_); this.menuButton_.el_.setAttribute('aria-haspopup', 'true'); // Add buildCSSClass values to the button, not the wrapper const buttonClass = Button.prototype.buildCSSClass(); this.menuButton_.el_.className = this.buildCSSClass() + ' ' + buttonClass; this.menuButton_.removeClass('vjs-control'); this.addChild(this.menuButton_); this.update(); this.enabled_ = true; const handleClick = (e) => this.handleClick(e); this.handleMenuKeyUp_ = (e) => this.handleMenuKeyUp(e); this.on(this.menuButton_, 'tap', handleClick); this.on(this.menuButton_, 'click', handleClick); this.on(this.menuButton_, 'keydown', (e) => this.handleKeyDown(e)); this.on(this.menuButton_, 'mouseenter', () => { this.addClass('vjs-hover'); this.menu.show(); Events.on(document, 'keyup', this.handleMenuKeyUp_); }); this.on('mouseleave', (e) => this.handleMouseLeave(e)); this.on('keydown', (e) => this.handleSubmenuKeyDown(e)); } /** * Update the menu based on the current state of its items. */ update() { const menu = this.createMenu(); if (this.menu) { this.menu.dispose(); this.removeChild(this.menu); } this.menu = menu; this.addChild(menu); /** * Track the state of the menu button * * @type {Boolean} * @private */ this.buttonPressed_ = false; this.menuButton_.el_.setAttribute('aria-expanded', 'false'); if (this.items && this.items.length <= this.hideThreshold_) { this.hide(); this.menu.contentEl_.removeAttribute('role'); } else { this.show(); this.menu.contentEl_.setAttribute('role', 'menu'); } } /** * Create the menu and add all items to it. * * @return {Menu} * The constructed menu */ createMenu() { const menu = new Menu(this.player_, { menuButton: this }); /** * Hide the menu if the number of items is less than or equal to this threshold. This defaults * to 0 and whenever we add items which can be hidden to the menu we'll increment it. We list * it here because every time we run `createMenu` we need to reset the value. * * @protected * @type {Number} */ this.hideThreshold_ = 0; // Add a title list item to the top if (this.options_.title) { const titleEl = Dom.createEl('li', { className: 'vjs-menu-title', textContent: toTitleCase(this.options_.title), tabIndex: -1 }); const titleComponent = new Component(this.player_, {el: titleEl}); menu.addItem(titleComponent); } this.items = this.createItems(); if (this.items) { // Add menu items to the menu for (let i = 0; i < this.items.length; i++) { menu.addItem(this.items[i]); } } return menu; } /** * Create the list of menu items. Specific to each subclass. * * @abstract */ createItems() {} /** * Create the `MenuButtons`s DOM element. * * @return {Element} * The element that gets created. */ createEl() { return super.createEl('div', { className: this.buildWrapperCSSClass() }, { }); } /** * Overwrites the `setIcon` method from `Component`. * In this case, we want the icon to be appended to the menuButton. * * @param {string} name * The icon name to be added. */ setIcon(name) { super.setIcon(name, this.menuButton_.el_); } /** * Allow sub components to stack CSS class names for the wrapper element * * @return {string} * The constructed wrapper DOM `className` */ buildWrapperCSSClass() { let menuButtonClass = 'vjs-menu-button'; // If the inline option is passed, we want to use different styles altogether. if (this.options_.inline === true) { menuButtonClass += '-inline'; } else { menuButtonClass += '-popup'; } // TODO: Fix the CSS so that this isn't necessary const buttonClass = Button.prototype.buildCSSClass(); return `vjs-menu-button ${menuButtonClass} ${buttonClass} ${super.buildCSSClass()}`; } /** * Builds the default DOM `className`. * * @return {string} * The DOM `className` for this object. */ buildCSSClass() { let menuButtonClass = 'vjs-menu-button'; // If the inline option is passed, we want to use different styles altogether. if (this.options_.inline === true) { menuButtonClass += '-inline'; } else { menuButtonClass += '-popup'; } return `vjs-menu-button ${menuButtonClass} ${super.buildCSSClass()}`; } /** * Get or set the localized control text that will be used for accessibility. * * > NOTE: This will come from the internal `menuButton_` element. * * @param {string} [text] * Control text for element. * * @param {Element} [el=this.menuButton_.el()] * Element to set the title on. * * @return {string} * - The control text when getting */ controlText(text, el = this.menuButton_.el()) { return this.menuButton_.controlText(text, el); } /** * Dispose of the `menu-button` and all child components. */ dispose() { this.handleMouseLeave(); super.dispose(); } /** * Handle a click on a `MenuButton`. * See {@link ClickableComponent#handleClick} for instances where this is called. * * @param {Event} event * The `keydown`, `tap`, or `click` event that caused this function to be * called. * * @listens tap * @listens click */ handleClick(event) { if (this.buttonPressed_) { this.unpressButton(); } else { this.pressButton(); } } /** * Handle `mouseleave` for `MenuButton`. * * @param {Event} event * The `mouseleave` event that caused this function to be called. * * @listens mouseleave */ handleMouseLeave(event) { this.removeClass('vjs-hover'); Events.off(document, 'keyup', this.handleMenuKeyUp_); } /** * Set the focus to the actual button, not to this element */ focus() { this.menuButton_.focus(); } /** * Remove the focus from the actual button, not this element */ blur() { this.menuButton_.blur(); } /** * Handle tab, escape, down arrow, and up arrow keys for `MenuButton`. See * {@link ClickableComponent#handleKeyDown} for instances where this is called. * * @param {Event} event * The `keydown` event that caused this function to be called. * * @listens keydown */ 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(); // Set focus back to the menu button's button this.menuButton_.focus(); } // Up Arrow or Down Arrow also 'press' the button to open the menu } else if (keycode.isEventKey(event, 'Up') || keycode.isEventKey(event, 'Down')) { if (!this.buttonPressed_) { event.preventDefault(); this.pressButton(); } } } /** * Handle a `keyup` event on a `MenuButton`. The listener for this is added in * the constructor. * * @param {Event} event * Key press event * * @listens keyup */ handleMenuKeyUp(event) { // Escape hides popup menu if (keycode.isEventKey(event, 'Esc') || keycode.isEventKey(event, 'Tab')) { this.removeClass('vjs-hover'); } } /** * This method name now delegates to `handleSubmenuKeyDown`. This means * anyone calling `handleSubmenuKeyPress` will not see their method calls * stop working. * * @param {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. * * @param {Event} event * Key press event * * @listens keydown */ handleSubmenuKeyDown(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(); // Set focus back to the menu button's button this.menuButton_.focus(); } } else { // NOTE: This is a special case where we don't pass unhandled // keydown events up to the Component handler, because it is // just intending the keydown handling of the `MenuItem` // in the `Menu` which already passes unused keys up. } } /** * Put the current `MenuButton` into a pressed state. */ pressButton() { if (this.enabled_) { this.buttonPressed_ = true; this.menu.show(); this.menu.lockShowing(); this.menuButton_.el_.setAttribute('aria-expanded', 'true'); // set the focus into the submenu, except on iOS where it is resulting in // undesired scrolling behavior when the player is in an iframe if (IS_IOS && Dom.isInFrame()) { // Return early so that the menu isn't focused return; } this.menu.focus(); } } /** * Take the current `MenuButton` out of a pressed state. */ unpressButton() { if (this.enabled_) { this.buttonPressed_ = false; this.menu.unlockShowing(); this.menu.hide(); this.menuButton_.el_.setAttribute('aria-expanded', 'false'); } } /** * Disable the `MenuButton`. Don't allow it to be clicked. */ disable() { this.unpressButton(); this.enabled_ = false; this.addClass('vjs-disabled'); this.menuButton_.disable(); } /** * Enable the `MenuButton`. Allow it to be clicked. */ enable() { this.enabled_ = true; this.removeClass('vjs-disabled'); this.menuButton_.enable(); } } Component.registerComponent('MenuButton', MenuButton); export default MenuButton;