1. 1 : /**
  2. 2 : * @file menu.js
  3. 3 : */
  4. 4 : import Component from '../component.js';
  5. 5 : import document from 'global/document';
  6. 6 : import * as Dom from '../utils/dom.js';
  7. 7 : import * as Events from '../utils/events.js';
  8. 8 : import keycode from 'keycode';
  9. 9 :
  10. 10 : /**
  11. 11 : * The Menu component is used to build popup menus, including subtitle and
  12. 12 : * captions selection menus.
  13. 13 : *
  14. 14 : * @extends Component
  15. 15 : */
  16. 16 : class Menu extends Component {
  17. 17 :
  18. 18 : /**
  19. 19 : * Create an instance of this class.
  20. 20 : *
  21. 21 : * @param { import('../player').default } player
  22. 22 : * the player that this component should attach to
  23. 23 : *
  24. 24 : * @param {Object} [options]
  25. 25 : * Object of option names and values
  26. 26 : *
  27. 27 : */
  28. 28 : constructor(player, options) {
  29. 29 : super(player, options);
  30. 30 :
  31. 31 : if (options) {
  32. 32 : this.menuButton_ = options.menuButton;
  33. 33 : }
  34. 34 :
  35. 35 : this.focusedChild_ = -1;
  36. 36 :
  37. 37 : this.on('keydown', (e) => this.handleKeyDown(e));
  38. 38 :
  39. 39 : // All the menu item instances share the same blur handler provided by the menu container.
  40. 40 : this.boundHandleBlur_ = (e) => this.handleBlur(e);
  41. 41 : this.boundHandleTapClick_ = (e) => this.handleTapClick(e);
  42. 42 : }
  43. 43 :
  44. 44 : /**
  45. 45 : * Add event listeners to the {@link MenuItem}.
  46. 46 : *
  47. 47 : * @param {Object} component
  48. 48 : * The instance of the `MenuItem` to add listeners to.
  49. 49 : *
  50. 50 : */
  51. 51 : addEventListenerForItem(component) {
  52. 52 : if (!(component instanceof Component)) {
  53. 53 : return;
  54. 54 : }
  55. 55 :
  56. 56 : this.on(component, 'blur', this.boundHandleBlur_);
  57. 57 : this.on(component, ['tap', 'click'], this.boundHandleTapClick_);
  58. 58 : }
  59. 59 :
  60. 60 : /**
  61. 61 : * Remove event listeners from the {@link MenuItem}.
  62. 62 : *
  63. 63 : * @param {Object} component
  64. 64 : * The instance of the `MenuItem` to remove listeners.
  65. 65 : *
  66. 66 : */
  67. 67 : removeEventListenerForItem(component) {
  68. 68 : if (!(component instanceof Component)) {
  69. 69 : return;
  70. 70 : }
  71. 71 :
  72. 72 : this.off(component, 'blur', this.boundHandleBlur_);
  73. 73 : this.off(component, ['tap', 'click'], this.boundHandleTapClick_);
  74. 74 : }
  75. 75 :
  76. 76 : /**
  77. 77 : * This method will be called indirectly when the component has been added
  78. 78 : * before the component adds to the new menu instance by `addItem`.
  79. 79 : * In this case, the original menu instance will remove the component
  80. 80 : * by calling `removeChild`.
  81. 81 : *
  82. 82 : * @param {Object} component
  83. 83 : * The instance of the `MenuItem`
  84. 84 : */
  85. 85 : removeChild(component) {
  86. 86 : if (typeof component === 'string') {
  87. 87 : component = this.getChild(component);
  88. 88 : }
  89. 89 :
  90. 90 : this.removeEventListenerForItem(component);
  91. 91 : super.removeChild(component);
  92. 92 : }
  93. 93 :
  94. 94 : /**
  95. 95 : * Add a {@link MenuItem} to the menu.
  96. 96 : *
  97. 97 : * @param {Object|string} component
  98. 98 : * The name or instance of the `MenuItem` to add.
  99. 99 : *
  100. 100 : */
  101. 101 : addItem(component) {
  102. 102 : const childComponent = this.addChild(component);
  103. 103 :
  104. 104 : if (childComponent) {
  105. 105 : this.addEventListenerForItem(childComponent);
  106. 106 : }
  107. 107 : }
  108. 108 :
  109. 109 : /**
  110. 110 : * Create the `Menu`s DOM element.
  111. 111 : *
  112. 112 : * @return {Element}
  113. 113 : * the element that was created
  114. 114 : */
  115. 115 : createEl() {
  116. 116 : const contentElType = this.options_.contentElType || 'ul';
  117. 117 :
  118. 118 : this.contentEl_ = Dom.createEl(contentElType, {
  119. 119 : className: 'vjs-menu-content'
  120. 120 : });
  121. 121 :
  122. 122 : this.contentEl_.setAttribute('role', 'menu');
  123. 123 :
  124. 124 : const el = super.createEl('div', {
  125. 125 : append: this.contentEl_,
  126. 126 : className: 'vjs-menu'
  127. 127 : });
  128. 128 :
  129. 129 : el.appendChild(this.contentEl_);
  130. 130 :
  131. 131 : // Prevent clicks from bubbling up. Needed for Menu Buttons,
  132. 132 : // where a click on the parent is significant
  133. 133 : Events.on(el, 'click', function(event) {
  134. 134 : event.preventDefault();
  135. 135 : event.stopImmediatePropagation();
  136. 136 : });
  137. 137 :
  138. 138 : return el;
  139. 139 : }
  140. 140 :
  141. 141 : dispose() {
  142. 142 : this.contentEl_ = null;
  143. 143 : this.boundHandleBlur_ = null;
  144. 144 : this.boundHandleTapClick_ = null;
  145. 145 :
  146. 146 : super.dispose();
  147. 147 : }
  148. 148 :
  149. 149 : /**
  150. 150 : * Called when a `MenuItem` loses focus.
  151. 151 : *
  152. 152 : * @param {Event} event
  153. 153 : * The `blur` event that caused this function to be called.
  154. 154 : *
  155. 155 : * @listens blur
  156. 156 : */
  157. 157 : handleBlur(event) {
  158. 158 : const relatedTarget = event.relatedTarget || document.activeElement;
  159. 159 :
  160. 160 : // Close menu popup when a user clicks outside the menu
  161. 161 : if (!this.children().some((element) => {
  162. 162 : return element.el() === relatedTarget;
  163. 163 : })) {
  164. 164 : const btn = this.menuButton_;
  165. 165 :
  166. 166 : if (btn && btn.buttonPressed_ && relatedTarget !== btn.el().firstChild) {
  167. 167 : btn.unpressButton();
  168. 168 : }
  169. 169 : }
  170. 170 : }
  171. 171 :
  172. 172 : /**
  173. 173 : * Called when a `MenuItem` gets clicked or tapped.
  174. 174 : *
  175. 175 : * @param {Event} event
  176. 176 : * The `click` or `tap` event that caused this function to be called.
  177. 177 : *
  178. 178 : * @listens click,tap
  179. 179 : */
  180. 180 : handleTapClick(event) {
  181. 181 : // Unpress the associated MenuButton, and move focus back to it
  182. 182 : if (this.menuButton_) {
  183. 183 : this.menuButton_.unpressButton();
  184. 184 :
  185. 185 : const childComponents = this.children();
  186. 186 :
  187. 187 : if (!Array.isArray(childComponents)) {
  188. 188 : return;
  189. 189 : }
  190. 190 :
  191. 191 : const foundComponent = childComponents.filter(component => component.el() === event.target)[0];
  192. 192 :
  193. 193 : if (!foundComponent) {
  194. 194 : return;
  195. 195 : }
  196. 196 :
  197. 197 : // don't focus menu button if item is a caption settings item
  198. 198 : // because focus will move elsewhere
  199. 199 : if (foundComponent.name() !== 'CaptionSettingsMenuItem') {
  200. 200 : this.menuButton_.focus();
  201. 201 : }
  202. 202 : }
  203. 203 : }
  204. 204 :
  205. 205 : /**
  206. 206 : * Handle a `keydown` event on this menu. This listener is added in the constructor.
  207. 207 : *
  208. 208 : * @param {Event} event
  209. 209 : * A `keydown` event that happened on the menu.
  210. 210 : *
  211. 211 : * @listens keydown
  212. 212 : */
  213. 213 : handleKeyDown(event) {
  214. 214 :
  215. 215 : // Left and Down Arrows
  216. 216 : if (keycode.isEventKey(event, 'Left') || keycode.isEventKey(event, 'Down')) {
  217. 217 : event.preventDefault();
  218. 218 : event.stopPropagation();
  219. 219 : this.stepForward();
  220. 220 :
  221. 221 : // Up and Right Arrows
  222. 222 : } else if (keycode.isEventKey(event, 'Right') || keycode.isEventKey(event, 'Up')) {
  223. 223 : event.preventDefault();
  224. 224 : event.stopPropagation();
  225. 225 : this.stepBack();
  226. 226 : }
  227. 227 : }
  228. 228 :
  229. 229 : /**
  230. 230 : * Move to next (lower) menu item for keyboard users.
  231. 231 : */
  232. 232 : stepForward() {
  233. 233 : let stepChild = 0;
  234. 234 :
  235. 235 : if (this.focusedChild_ !== undefined) {
  236. 236 : stepChild = this.focusedChild_ + 1;
  237. 237 : }
  238. 238 : this.focus(stepChild);
  239. 239 : }
  240. 240 :
  241. 241 : /**
  242. 242 : * Move to previous (higher) menu item for keyboard users.
  243. 243 : */
  244. 244 : stepBack() {
  245. 245 : let stepChild = 0;
  246. 246 :
  247. 247 : if (this.focusedChild_ !== undefined) {
  248. 248 : stepChild = this.focusedChild_ - 1;
  249. 249 : }
  250. 250 : this.focus(stepChild);
  251. 251 : }
  252. 252 :
  253. 253 : /**
  254. 254 : * Set focus on a {@link MenuItem} in the `Menu`.
  255. 255 : *
  256. 256 : * @param {Object|string} [item=0]
  257. 257 : * Index of child item set focus on.
  258. 258 : */
  259. 259 : focus(item = 0) {
  260. 260 : const children = this.children().slice();
  261. 261 : const haveTitle = children.length && children[0].hasClass('vjs-menu-title');
  262. 262 :
  263. 263 : if (haveTitle) {
  264. 264 : children.shift();
  265. 265 : }
  266. 266 :
  267. 267 : if (children.length > 0) {
  268. 268 : if (item < 0) {
  269. 269 : item = 0;
  270. 270 : } else if (item >= children.length) {
  271. 271 : item = children.length - 1;
  272. 272 : }
  273. 273 :
  274. 274 : this.focusedChild_ = item;
  275. 275 :
  276. 276 : children[item].el_.focus();
  277. 277 : }
  278. 278 : }
  279. 279 : }
  280. 280 :
  281. 281 : Component.registerComponent('Menu', Menu);
  282. 282 : export default Menu;