1. 1 : /**
  2. 2 : * @file menu-button.js
  3. 3 : */
  4. 4 : import Button from '../button.js';
  5. 5 : import Component from '../component.js';
  6. 6 : import Menu from './menu.js';
  7. 7 : import * as Dom from '../utils/dom.js';
  8. 8 : import * as Events from '../utils/events.js';
  9. 9 : import {toTitleCase} from '../utils/str.js';
  10. 10 : import { IS_IOS } from '../utils/browser.js';
  11. 11 : import document from 'global/document';
  12. 12 : import keycode from 'keycode';
  13. 13 :
  14. 14 : /**
  15. 15 : * A `MenuButton` class for any popup {@link Menu}.
  16. 16 : *
  17. 17 : * @extends Component
  18. 18 : */
  19. 19 : class MenuButton extends Component {
  20. 20 :
  21. 21 : /**
  22. 22 : * Creates an instance of this class.
  23. 23 : *
  24. 24 : * @param { import('../player').default } player
  25. 25 : * The `Player` that this class should be attached to.
  26. 26 : *
  27. 27 : * @param {Object} [options={}]
  28. 28 : * The key/value store of player options.
  29. 29 : */
  30. 30 : constructor(player, options = {}) {
  31. 31 : super(player, options);
  32. 32 :
  33. 33 : this.menuButton_ = new Button(player, options);
  34. 34 :
  35. 35 : this.menuButton_.controlText(this.controlText_);
  36. 36 : this.menuButton_.el_.setAttribute('aria-haspopup', 'true');
  37. 37 :
  38. 38 : // Add buildCSSClass values to the button, not the wrapper
  39. 39 : const buttonClass = Button.prototype.buildCSSClass();
  40. 40 :
  41. 41 : this.menuButton_.el_.className = this.buildCSSClass() + ' ' + buttonClass;
  42. 42 : this.menuButton_.removeClass('vjs-control');
  43. 43 :
  44. 44 : this.addChild(this.menuButton_);
  45. 45 :
  46. 46 : this.update();
  47. 47 :
  48. 48 : this.enabled_ = true;
  49. 49 :
  50. 50 : const handleClick = (e) => this.handleClick(e);
  51. 51 :
  52. 52 : this.handleMenuKeyUp_ = (e) => this.handleMenuKeyUp(e);
  53. 53 :
  54. 54 : this.on(this.menuButton_, 'tap', handleClick);
  55. 55 : this.on(this.menuButton_, 'click', handleClick);
  56. 56 : this.on(this.menuButton_, 'keydown', (e) => this.handleKeyDown(e));
  57. 57 : this.on(this.menuButton_, 'mouseenter', () => {
  58. 58 : this.addClass('vjs-hover');
  59. 59 : this.menu.show();
  60. 60 : Events.on(document, 'keyup', this.handleMenuKeyUp_);
  61. 61 : });
  62. 62 : this.on('mouseleave', (e) => this.handleMouseLeave(e));
  63. 63 : this.on('keydown', (e) => this.handleSubmenuKeyDown(e));
  64. 64 : }
  65. 65 :
  66. 66 : /**
  67. 67 : * Update the menu based on the current state of its items.
  68. 68 : */
  69. 69 : update() {
  70. 70 : const menu = this.createMenu();
  71. 71 :
  72. 72 : if (this.menu) {
  73. 73 : this.menu.dispose();
  74. 74 : this.removeChild(this.menu);
  75. 75 : }
  76. 76 :
  77. 77 : this.menu = menu;
  78. 78 : this.addChild(menu);
  79. 79 :
  80. 80 : /**
  81. 81 : * Track the state of the menu button
  82. 82 : *
  83. 83 : * @type {Boolean}
  84. 84 : * @private
  85. 85 : */
  86. 86 : this.buttonPressed_ = false;
  87. 87 : this.menuButton_.el_.setAttribute('aria-expanded', 'false');
  88. 88 :
  89. 89 : if (this.items && this.items.length <= this.hideThreshold_) {
  90. 90 : this.hide();
  91. 91 : this.menu.contentEl_.removeAttribute('role');
  92. 92 :
  93. 93 : } else {
  94. 94 : this.show();
  95. 95 : this.menu.contentEl_.setAttribute('role', 'menu');
  96. 96 : }
  97. 97 : }
  98. 98 :
  99. 99 : /**
  100. 100 : * Create the menu and add all items to it.
  101. 101 : *
  102. 102 : * @return {Menu}
  103. 103 : * The constructed menu
  104. 104 : */
  105. 105 : createMenu() {
  106. 106 : const menu = new Menu(this.player_, { menuButton: this });
  107. 107 :
  108. 108 : /**
  109. 109 : * Hide the menu if the number of items is less than or equal to this threshold. This defaults
  110. 110 : * to 0 and whenever we add items which can be hidden to the menu we'll increment it. We list
  111. 111 : * it here because every time we run `createMenu` we need to reset the value.
  112. 112 : *
  113. 113 : * @protected
  114. 114 : * @type {Number}
  115. 115 : */
  116. 116 : this.hideThreshold_ = 0;
  117. 117 :
  118. 118 : // Add a title list item to the top
  119. 119 : if (this.options_.title) {
  120. 120 : const titleEl = Dom.createEl('li', {
  121. 121 : className: 'vjs-menu-title',
  122. 122 : textContent: toTitleCase(this.options_.title),
  123. 123 : tabIndex: -1
  124. 124 : });
  125. 125 :
  126. 126 : const titleComponent = new Component(this.player_, {el: titleEl});
  127. 127 :
  128. 128 : menu.addItem(titleComponent);
  129. 129 : }
  130. 130 :
  131. 131 : this.items = this.createItems();
  132. 132 :
  133. 133 : if (this.items) {
  134. 134 : // Add menu items to the menu
  135. 135 : for (let i = 0; i < this.items.length; i++) {
  136. 136 : menu.addItem(this.items[i]);
  137. 137 : }
  138. 138 : }
  139. 139 :
  140. 140 : return menu;
  141. 141 : }
  142. 142 :
  143. 143 : /**
  144. 144 : * Create the list of menu items. Specific to each subclass.
  145. 145 : *
  146. 146 : * @abstract
  147. 147 : */
  148. 148 : createItems() {}
  149. 149 :
  150. 150 : /**
  151. 151 : * Create the `MenuButtons`s DOM element.
  152. 152 : *
  153. 153 : * @return {Element}
  154. 154 : * The element that gets created.
  155. 155 : */
  156. 156 : createEl() {
  157. 157 : return super.createEl('div', {
  158. 158 : className: this.buildWrapperCSSClass()
  159. 159 : }, {
  160. 160 : });
  161. 161 : }
  162. 162 :
  163. 163 : /**
  164. 164 : * Allow sub components to stack CSS class names for the wrapper element
  165. 165 : *
  166. 166 : * @return {string}
  167. 167 : * The constructed wrapper DOM `className`
  168. 168 : */
  169. 169 : buildWrapperCSSClass() {
  170. 170 : let menuButtonClass = 'vjs-menu-button';
  171. 171 :
  172. 172 : // If the inline option is passed, we want to use different styles altogether.
  173. 173 : if (this.options_.inline === true) {
  174. 174 : menuButtonClass += '-inline';
  175. 175 : } else {
  176. 176 : menuButtonClass += '-popup';
  177. 177 : }
  178. 178 :
  179. 179 : // TODO: Fix the CSS so that this isn't necessary
  180. 180 : const buttonClass = Button.prototype.buildCSSClass();
  181. 181 :
  182. 182 : return `vjs-menu-button ${menuButtonClass} ${buttonClass} ${super.buildCSSClass()}`;
  183. 183 : }
  184. 184 :
  185. 185 : /**
  186. 186 : * Builds the default DOM `className`.
  187. 187 : *
  188. 188 : * @return {string}
  189. 189 : * The DOM `className` for this object.
  190. 190 : */
  191. 191 : buildCSSClass() {
  192. 192 : let menuButtonClass = 'vjs-menu-button';
  193. 193 :
  194. 194 : // If the inline option is passed, we want to use different styles altogether.
  195. 195 : if (this.options_.inline === true) {
  196. 196 : menuButtonClass += '-inline';
  197. 197 : } else {
  198. 198 : menuButtonClass += '-popup';
  199. 199 : }
  200. 200 :
  201. 201 : return `vjs-menu-button ${menuButtonClass} ${super.buildCSSClass()}`;
  202. 202 : }
  203. 203 :
  204. 204 : /**
  205. 205 : * Get or set the localized control text that will be used for accessibility.
  206. 206 : *
  207. 207 : * > NOTE: This will come from the internal `menuButton_` element.
  208. 208 : *
  209. 209 : * @param {string} [text]
  210. 210 : * Control text for element.
  211. 211 : *
  212. 212 : * @param {Element} [el=this.menuButton_.el()]
  213. 213 : * Element to set the title on.
  214. 214 : *
  215. 215 : * @return {string}
  216. 216 : * - The control text when getting
  217. 217 : */
  218. 218 : controlText(text, el = this.menuButton_.el()) {
  219. 219 : return this.menuButton_.controlText(text, el);
  220. 220 : }
  221. 221 :
  222. 222 : /**
  223. 223 : * Dispose of the `menu-button` and all child components.
  224. 224 : */
  225. 225 : dispose() {
  226. 226 : this.handleMouseLeave();
  227. 227 : super.dispose();
  228. 228 : }
  229. 229 :
  230. 230 : /**
  231. 231 : * Handle a click on a `MenuButton`.
  232. 232 : * See {@link ClickableComponent#handleClick} for instances where this is called.
  233. 233 : *
  234. 234 : * @param {Event} event
  235. 235 : * The `keydown`, `tap`, or `click` event that caused this function to be
  236. 236 : * called.
  237. 237 : *
  238. 238 : * @listens tap
  239. 239 : * @listens click
  240. 240 : */
  241. 241 : handleClick(event) {
  242. 242 : if (this.buttonPressed_) {
  243. 243 : this.unpressButton();
  244. 244 : } else {
  245. 245 : this.pressButton();
  246. 246 : }
  247. 247 : }
  248. 248 :
  249. 249 : /**
  250. 250 : * Handle `mouseleave` for `MenuButton`.
  251. 251 : *
  252. 252 : * @param {Event} event
  253. 253 : * The `mouseleave` event that caused this function to be called.
  254. 254 : *
  255. 255 : * @listens mouseleave
  256. 256 : */
  257. 257 : handleMouseLeave(event) {
  258. 258 : this.removeClass('vjs-hover');
  259. 259 : Events.off(document, 'keyup', this.handleMenuKeyUp_);
  260. 260 : }
  261. 261 :
  262. 262 : /**
  263. 263 : * Set the focus to the actual button, not to this element
  264. 264 : */
  265. 265 : focus() {
  266. 266 : this.menuButton_.focus();
  267. 267 : }
  268. 268 :
  269. 269 : /**
  270. 270 : * Remove the focus from the actual button, not this element
  271. 271 : */
  272. 272 : blur() {
  273. 273 : this.menuButton_.blur();
  274. 274 : }
  275. 275 :
  276. 276 : /**
  277. 277 : * Handle tab, escape, down arrow, and up arrow keys for `MenuButton`. See
  278. 278 : * {@link ClickableComponent#handleKeyDown} for instances where this is called.
  279. 279 : *
  280. 280 : * @param {Event} event
  281. 281 : * The `keydown` event that caused this function to be called.
  282. 282 : *
  283. 283 : * @listens keydown
  284. 284 : */
  285. 285 : handleKeyDown(event) {
  286. 286 :
  287. 287 : // Escape or Tab unpress the 'button'
  288. 288 : if (keycode.isEventKey(event, 'Esc') || keycode.isEventKey(event, 'Tab')) {
  289. 289 : if (this.buttonPressed_) {
  290. 290 : this.unpressButton();
  291. 291 : }
  292. 292 :
  293. 293 : // Don't preventDefault for Tab key - we still want to lose focus
  294. 294 : if (!keycode.isEventKey(event, 'Tab')) {
  295. 295 : event.preventDefault();
  296. 296 : // Set focus back to the menu button's button
  297. 297 : this.menuButton_.focus();
  298. 298 : }
  299. 299 : // Up Arrow or Down Arrow also 'press' the button to open the menu
  300. 300 : } else if (keycode.isEventKey(event, 'Up') || keycode.isEventKey(event, 'Down')) {
  301. 301 : if (!this.buttonPressed_) {
  302. 302 : event.preventDefault();
  303. 303 : this.pressButton();
  304. 304 : }
  305. 305 : }
  306. 306 : }
  307. 307 :
  308. 308 : /**
  309. 309 : * Handle a `keyup` event on a `MenuButton`. The listener for this is added in
  310. 310 : * the constructor.
  311. 311 : *
  312. 312 : * @param {Event} event
  313. 313 : * Key press event
  314. 314 : *
  315. 315 : * @listens keyup
  316. 316 : */
  317. 317 : handleMenuKeyUp(event) {
  318. 318 : // Escape hides popup menu
  319. 319 : if (keycode.isEventKey(event, 'Esc') || keycode.isEventKey(event, 'Tab')) {
  320. 320 : this.removeClass('vjs-hover');
  321. 321 : }
  322. 322 : }
  323. 323 :
  324. 324 : /**
  325. 325 : * This method name now delegates to `handleSubmenuKeyDown`. This means
  326. 326 : * anyone calling `handleSubmenuKeyPress` will not see their method calls
  327. 327 : * stop working.
  328. 328 : *
  329. 329 : * @param {Event} event
  330. 330 : * The event that caused this function to be called.
  331. 331 : */
  332. 332 : handleSubmenuKeyPress(event) {
  333. 333 : this.handleSubmenuKeyDown(event);
  334. 334 : }
  335. 335 :
  336. 336 : /**
  337. 337 : * Handle a `keydown` event on a sub-menu. The listener for this is added in
  338. 338 : * the constructor.
  339. 339 : *
  340. 340 : * @param {Event} event
  341. 341 : * Key press event
  342. 342 : *
  343. 343 : * @listens keydown
  344. 344 : */
  345. 345 : handleSubmenuKeyDown(event) {
  346. 346 : // Escape or Tab unpress the 'button'
  347. 347 : if (keycode.isEventKey(event, 'Esc') || keycode.isEventKey(event, 'Tab')) {
  348. 348 : if (this.buttonPressed_) {
  349. 349 : this.unpressButton();
  350. 350 : }
  351. 351 : // Don't preventDefault for Tab key - we still want to lose focus
  352. 352 : if (!keycode.isEventKey(event, 'Tab')) {
  353. 353 : event.preventDefault();
  354. 354 : // Set focus back to the menu button's button
  355. 355 : this.menuButton_.focus();
  356. 356 : }
  357. 357 : } else {
  358. 358 : // NOTE: This is a special case where we don't pass unhandled
  359. 359 : // keydown events up to the Component handler, because it is
  360. 360 : // just intending the keydown handling of the `MenuItem`
  361. 361 : // in the `Menu` which already passes unused keys up.
  362. 362 : }
  363. 363 : }
  364. 364 :
  365. 365 : /**
  366. 366 : * Put the current `MenuButton` into a pressed state.
  367. 367 : */
  368. 368 : pressButton() {
  369. 369 : if (this.enabled_) {
  370. 370 : this.buttonPressed_ = true;
  371. 371 : this.menu.show();
  372. 372 : this.menu.lockShowing();
  373. 373 : this.menuButton_.el_.setAttribute('aria-expanded', 'true');
  374. 374 :
  375. 375 : // set the focus into the submenu, except on iOS where it is resulting in
  376. 376 : // undesired scrolling behavior when the player is in an iframe
  377. 377 : if (IS_IOS && Dom.isInFrame()) {
  378. 378 : // Return early so that the menu isn't focused
  379. 379 : return;
  380. 380 : }
  381. 381 :
  382. 382 : this.menu.focus();
  383. 383 : }
  384. 384 : }
  385. 385 :
  386. 386 : /**
  387. 387 : * Take the current `MenuButton` out of a pressed state.
  388. 388 : */
  389. 389 : unpressButton() {
  390. 390 : if (this.enabled_) {
  391. 391 : this.buttonPressed_ = false;
  392. 392 : this.menu.unlockShowing();
  393. 393 : this.menu.hide();
  394. 394 : this.menuButton_.el_.setAttribute('aria-expanded', 'false');
  395. 395 : }
  396. 396 : }
  397. 397 :
  398. 398 : /**
  399. 399 : * Disable the `MenuButton`. Don't allow it to be clicked.
  400. 400 : */
  401. 401 : disable() {
  402. 402 : this.unpressButton();
  403. 403 :
  404. 404 : this.enabled_ = false;
  405. 405 : this.addClass('vjs-disabled');
  406. 406 :
  407. 407 : this.menuButton_.disable();
  408. 408 : }
  409. 409 :
  410. 410 : /**
  411. 411 : * Enable the `MenuButton`. Allow it to be clicked.
  412. 412 : */
  413. 413 : enable() {
  414. 414 : this.enabled_ = true;
  415. 415 : this.removeClass('vjs-disabled');
  416. 416 :
  417. 417 : this.menuButton_.enable();
  418. 418 : }
  419. 419 : }
  420. 420 :
  421. 421 : Component.registerComponent('MenuButton', MenuButton);
  422. 422 : export default MenuButton;