Source: components/sl-menu.js

  1. import Ember from 'ember';
  2. import StreamEnabled from 'ember-stream/mixins/stream-enabled';
  3. import layout from '../templates/components/sl-menu';
  4. import { warn } from '../utils/all';
  5. /**
  6. * @module
  7. * @augments ember/Component
  8. * @augments ember-stream/mixins/stream-enabled
  9. */
  10. export default Ember.Component.extend( StreamEnabled, {
  11. // -------------------------------------------------------------------------
  12. // Dependencies
  13. // -------------------------------------------------------------------------
  14. // Attributes
  15. /** @type {String[]} */
  16. classNameBindings: [
  17. 'showingAll:show-all'
  18. ],
  19. /** @type {String[]} */
  20. classNames: [
  21. 'sl-menu'
  22. ],
  23. /** @type {Object} */
  24. layout,
  25. /** @type {String} */
  26. tagName: 'div',
  27. // -------------------------------------------------------------------------
  28. // Actions
  29. /** @type {Object} */
  30. actions: {
  31. /**
  32. * Handle an action from a sub-menu item
  33. *
  34. * @function actions:handleAction
  35. * @param {String} actionName - The name of an action to pass up to the
  36. * parent controller
  37. * @param {*} data - Any data to also pass up to the parent controller
  38. * @returns {undefined}
  39. */
  40. handleAction( actionName, data ) {
  41. this.sendAction( 'action', actionName, data );
  42. },
  43. /**
  44. * Trigger hiding all of the menu's sub-menus
  45. *
  46. * @function actions:hideAll
  47. * @returns {undefined}
  48. */
  49. hideAll() {
  50. this.hideAll();
  51. },
  52. /**
  53. * Trigger showing all the menu's sub-menus
  54. *
  55. * @function actions:showAll
  56. * @returns {undefined}
  57. */
  58. showAll() {
  59. this.showAll();
  60. }
  61. },
  62. // -------------------------------------------------------------------------
  63. // Events
  64. /**
  65. * mouseLeave event handler
  66. *
  67. * @function
  68. * @returns {undefined}
  69. */
  70. mouseLeave() {
  71. this.send( 'hideAll' );
  72. },
  73. /**
  74. * mouseMove event handler
  75. *
  76. * @function
  77. * returns {undefined}
  78. */
  79. mouseMove() {
  80. this.clearSelections();
  81. },
  82. // -------------------------------------------------------------------------
  83. // Properties
  84. /**
  85. * Whether to show a menu item to display all sub-menus
  86. *
  87. * @type {Boolean}
  88. */
  89. allowShowAll: false,
  90. /**
  91. * The array of menu items
  92. *
  93. * @type {?Object[]}
  94. */
  95. items: null,
  96. /**
  97. * An array of objects containing data about the selected states
  98. *
  99. * @private
  100. * @type {?ember/Array}
  101. */
  102. selections: null,
  103. /**
  104. * Whether to show all the menu's sub-items
  105. *
  106. * @private
  107. * @type {Boolean}
  108. */
  109. showingAll: false,
  110. // -------------------------------------------------------------------------
  111. // Observers
  112. /**
  113. * Initialize any computed properties that need setup
  114. *
  115. * @function
  116. * @returns {undefined}
  117. */
  118. initialize: Ember.on(
  119. 'init',
  120. function() {
  121. this.set( 'selections', new Ember.A() );
  122. }
  123. ),
  124. /**
  125. * Setup the stream actions bindings
  126. *
  127. * @function
  128. * @returns {undefined}
  129. */
  130. setupStreamActions: Ember.on(
  131. 'init',
  132. function() {
  133. const stream = this.get( 'stream' );
  134. if ( !stream ) {
  135. return;
  136. }
  137. stream.on( 'doAction', () => {
  138. this.doAction();
  139. });
  140. stream.on( 'hideAll', () => {
  141. this.hideAll();
  142. });
  143. stream.on( 'select', ( index ) => {
  144. this.select( index );
  145. });
  146. stream.on( 'selectDown', () => {
  147. this.selectDown();
  148. });
  149. stream.on( 'selectLeft', () => {
  150. this.selectLeft();
  151. });
  152. stream.on( 'selectNext', () => {
  153. this.selectNext();
  154. });
  155. stream.on( 'selectParent', () => {
  156. this.selectParent();
  157. });
  158. stream.on( 'selectPrevious', () => {
  159. this.selectPrevious();
  160. });
  161. stream.on( 'selectRight', () => {
  162. this.selectRight();
  163. });
  164. stream.on( 'selectSubMenu', () => {
  165. this.selectSubMenu();
  166. });
  167. stream.on( 'selectUp', () => {
  168. this.selectUp();
  169. });
  170. stream.on( 'showAll', () => {
  171. this.showAll();
  172. });
  173. }
  174. ),
  175. /**
  176. * Retrieve the currently selected item
  177. *
  178. * @function
  179. * @returns {?Object}
  180. */
  181. selectedItem: Ember.computed(
  182. 'selections.@each.item',
  183. function() {
  184. const lastItem = this.get( 'selections.lastObject.item' );
  185. return lastItem ? lastItem : null;
  186. }
  187. ),
  188. // -------------------------------------------------------------------------
  189. // Methods
  190. /**
  191. * Clear the `selections` data
  192. *
  193. * @function
  194. * @returns {undefined}
  195. */
  196. clearSelections() {
  197. const selections = this.get( 'selections' );
  198. selections.forEach( ( selection ) => {
  199. Ember.set( selection.item, 'selected', false );
  200. });
  201. this.set( 'selections', new Ember.A() );
  202. },
  203. /**
  204. * Perform the currently selected item's `action`
  205. *
  206. * @function
  207. * @returns {undefined}
  208. */
  209. doAction() {
  210. const selectedItem = this.get( 'selectedItem' );
  211. if ( selectedItem ) {
  212. const action = Ember.get( selectedItem, 'action' );
  213. if ( action ) {
  214. this.sendAction( 'action', action, Ember.get( selectedItem, 'data' ) );
  215. }
  216. }
  217. },
  218. /**
  219. * Hide all the menu's sub-menus
  220. *
  221. * @function
  222. * @returns {undefined}
  223. */
  224. hideAll() {
  225. this.set( 'showingAll', false );
  226. this.clearSelections();
  227. },
  228. /**
  229. * Select an item by its index in the current selection context
  230. *
  231. * @function
  232. * @param {Number} index - The index of the item to select
  233. * @throws {ember/Error}
  234. * @returns {undefined}
  235. */
  236. select( index ) {
  237. if ( this.get( 'showingAll' ) ) {
  238. this.hideAll();
  239. }
  240. const selections = this.get( 'selections' );
  241. const selectionsLength = selections.length;
  242. let item;
  243. if ( selectionsLength > 0 ) {
  244. const selection = selections.objectAt( selectionsLength - 1 );
  245. if ( !selection ) {
  246. throw new Ember.Error( 'Current selection is undefined' );
  247. }
  248. const contextItems = selectionsLength > 1 ?
  249. Ember.get( selection, 'items' ) :
  250. this.get( 'items' );
  251. const currentItem = Ember.get( selection, 'item' );
  252. if ( !currentItem ) {
  253. throw new Ember.Error( 'Current item is undefined' );
  254. }
  255. item = contextItems.objectAt( index );
  256. if ( !item ) {
  257. return;
  258. }
  259. Ember.set( currentItem, 'selected', false );
  260. Ember.set( item, 'selected', true );
  261. Ember.setProperties( selection, { index, item });
  262. } else {
  263. const items = this.get( 'items' );
  264. if ( !items ) {
  265. throw new Ember.Error( 'Component `items` is undefined' );
  266. }
  267. if ( items.length > 0 && index < items.length ) {
  268. item = items[ index ];
  269. Ember.set( item, 'selected', true );
  270. selections.pushObject({ index, item, items });
  271. }
  272. }
  273. },
  274. /**
  275. * Select a menu item in the "down" direction
  276. *
  277. * At the top-level of the menu, "down" corresponds to opening and selecting
  278. * the first child in its sub-menu.
  279. * Inside a sub-menu, "down" corresponds to selecting the next sibling
  280. * menu item.
  281. *
  282. * @function
  283. * @returns {undefined}
  284. */
  285. selectDown() {
  286. const selectionsLength = this.get( 'selections' ).length;
  287. if ( 1 === selectionsLength ) {
  288. this.selectSubMenu();
  289. } else if ( selectionsLength > 1 ) {
  290. this.selectNext();
  291. } else {
  292. this.select( 0 );
  293. }
  294. },
  295. /**
  296. * Select a menu item in the "left" direction
  297. *
  298. * At the top-level of the menu, "left" corresponds to selecting the
  299. * previous sibling menu item.
  300. * Inside a sub-menu, "left" corresponds to parsing back to the parent item
  301. *
  302. * @function
  303. * @returns {undefined}
  304. */
  305. selectLeft() {
  306. const selectionsLength = this.get( 'selections' ).length;
  307. if ( 1 === selectionsLength || this.get( 'showingAll' ) ) {
  308. this.selectPrevious();
  309. } else if ( selectionsLength > 1 ) {
  310. this.selectParent();
  311. }
  312. },
  313. /**
  314. * Select the next sibling in the current context
  315. *
  316. * @function
  317. * @throws {ember/Error}
  318. * @returns {undefined}
  319. */
  320. selectNext() {
  321. const selections = this.get( 'selections' );
  322. // Select the first item from `items` if nothing is currently selected
  323. if ( selections.length < 1 ) {
  324. if ( this.get( 'showingAll' ) ) {
  325. this.hideAll();
  326. }
  327. this.select( 0 );
  328. return;
  329. }
  330. const selection = selections.objectAt( selections.length - 1 );
  331. const currentItems = Ember.get( selection, 'items' );
  332. if ( !currentItems ) {
  333. throw new Ember.Error( 'Current selection items are undefined' );
  334. }
  335. const currentIndex = Ember.get( selection, 'index' );
  336. if ( 'number' !== Ember.typeOf( currentIndex ) ) {
  337. throw new Ember.Error( 'Current index is not valid' );
  338. }
  339. // Select the "show all" option if we're on the last context item at the
  340. // top level, and the `allowShowAll` is enabled
  341. if (
  342. 1 === selections.length &&
  343. currentItems.length - 1 === currentIndex &&
  344. this.get( 'allowShowAll' )
  345. ) {
  346. this.clearSelections();
  347. this.showAll();
  348. return;
  349. }
  350. const currentItem = Ember.get( selection, 'item' );
  351. if ( !currentItem ) {
  352. throw new Ember.Error( 'Current item is undefined' );
  353. }
  354. let newIndex = currentIndex + 1;
  355. if ( newIndex >= currentItems.length ) {
  356. newIndex -= currentItems.length;
  357. }
  358. const item = currentItems[ newIndex ];
  359. if ( !item ) {
  360. throw new Ember.Error( `Item with index ${newIndex} is undefined` );
  361. }
  362. Ember.set( currentItem, 'selected', false );
  363. Ember.set( item, 'selected', true );
  364. Ember.setProperties( selection, {
  365. index: newIndex,
  366. item
  367. });
  368. },
  369. /**
  370. * Select the parent menu from the current context
  371. *
  372. * @function
  373. * @throws {ember/Error}
  374. * @returns {undefined}
  375. */
  376. selectParent() {
  377. const selections = this.get( 'selections' );
  378. if ( selections.length <= 1 ) {
  379. warn( '`selectParent` triggered with no parent context' );
  380. }
  381. const currentItem = Ember.get( selections.popObject(), 'item' );
  382. if ( !currentItem ) {
  383. throw new Ember.Error( 'Invalid last menu item' );
  384. }
  385. Ember.set( currentItem, 'selected', false );
  386. },
  387. /**
  388. * Select the previous sibling in the current context
  389. *
  390. * @function
  391. * @throws {ember/Error}
  392. * @returns {undefined}
  393. */
  394. selectPrevious() {
  395. const selections = this.get( 'selections' );
  396. // Check if we're at the top-level context
  397. if ( selections.length < 1 ) {
  398. // Trigger "show all" if allowed to
  399. if ( this.get( 'allowShowAll' ) && !this.get( 'showingAll' ) ) {
  400. this.showAll();
  401. } else {
  402. // Otherwise, select the last item in the context
  403. this.hideAll();
  404. this.select( this.get( 'items' ).length - 1 );
  405. }
  406. return;
  407. }
  408. const selection = selections.objectAt( selections.length - 1 );
  409. const currentItems = Ember.get( selection, 'items' );
  410. if ( !currentItems ) {
  411. throw new Ember.Error( 'Current items are undefined' );
  412. }
  413. // Select the "show all" option when at the beginning of the top-level
  414. // and `allowShowAll` is enabled
  415. if (
  416. 1 === selections.length &&
  417. 0 === selection.index &&
  418. this.get( 'allowShowAll' )
  419. ) {
  420. this.clearSelections();
  421. this.showAll();
  422. return;
  423. }
  424. if ( currentItems.length < 2 ) {
  425. warn( '`selectPrevious` triggered with no siblings in context' );
  426. return;
  427. }
  428. const currentIndex = Ember.get( selection, 'index' );
  429. if ( 'number' !== Ember.typeOf( currentIndex ) ) {
  430. throw new Ember.Error( 'Current index is not valid' );
  431. }
  432. const currentItem = Ember.get( selection, 'item' );
  433. if ( !currentItem ) {
  434. throw new Ember.Error( 'Current item is undefined' );
  435. }
  436. let newIndex = currentIndex - 1;
  437. if ( newIndex < 0 ) {
  438. newIndex += currentItems.length;
  439. }
  440. const item = currentItems[ newIndex ];
  441. if ( !item ) {
  442. throw new Ember.Error( `Item with index ${newIndex} is undefined` );
  443. }
  444. Ember.set( currentItem, 'selected', false );
  445. Ember.set( item, 'selected', true );
  446. Ember.setProperties( selection, {
  447. index: newIndex,
  448. item
  449. });
  450. },
  451. /**
  452. * Select a menu item in the "right" direction
  453. *
  454. * When at the top-level of the menu, "right" corresponds to the next
  455. * sibling item.
  456. * When inside a sub-menu, "right" corresponds to entering its sub-menu, if
  457. * it has one.
  458. *
  459. * @function
  460. * @throws {ember/Error}
  461. * @returns {undefined}
  462. */
  463. selectRight() {
  464. const selections = this.get( 'selections' );
  465. if ( 1 === selections.length || this.get( 'showingAll' ) ) {
  466. this.selectNext();
  467. } else if ( selections.length > 1 ) {
  468. this.selectSubMenu();
  469. }
  470. },
  471. /**
  472. * Select the sub-menu in the current context
  473. *
  474. * @function
  475. * @throws {ember/Error}
  476. * @returns {undefined}
  477. */
  478. selectSubMenu() {
  479. const selections = this.get( 'selections' );
  480. if ( selections.length < 1 ) {
  481. return;
  482. }
  483. const selection = selections.get( selections.length - 1 );
  484. if ( !selection ) {
  485. throw new Ember.Error( 'Last item of `selection` is invalid' );
  486. }
  487. const currentItem = Ember.get( selection, 'item' );
  488. if ( !currentItem ) {
  489. throw new Ember.Error( 'Last selection menu item is invalid' );
  490. }
  491. const items = Ember.get( currentItem, 'items' );
  492. if ( !items ) {
  493. return;
  494. }
  495. const index = 0;
  496. const item = items[ index ];
  497. if ( !item ) {
  498. throw new Ember.Error( 'First item in selected sub-menu is undefined' );
  499. }
  500. Ember.set( item, 'selected', true );
  501. selections.pushObject({
  502. index,
  503. item,
  504. items
  505. });
  506. },
  507. /**
  508. * Select a menu item in the "up" direction
  509. *
  510. * When at the top level, "up" corresponds to no action.
  511. * When in the first sub-menu and on the first item, "up" corresponds to
  512. * selecting the top level.
  513. * When in any other sub-menu, "up" corresponds to selecting the previous
  514. * sibling menu item.
  515. *
  516. * @function
  517. * @throws {ember/Error}
  518. * @returns {undefined}
  519. */
  520. selectUp() {
  521. const selections = this.get( 'selections' );
  522. const selectionsLength = selections.length;
  523. // Do nothing if there is no parent context
  524. if ( selectionsLength < 2 ) {
  525. return;
  526. }
  527. // Check if the selection is in a first-level sub-menu
  528. if ( 2 === selectionsLength ) {
  529. const selection = selections.get( 1 );
  530. if ( 0 === Ember.get( selection, 'index' ) ) {
  531. this.selectParent();
  532. return;
  533. }
  534. }
  535. // In any other sub-menu level, cycle through siblings
  536. this.selectPrevious();
  537. },
  538. /**
  539. * Trigger the showAll menu-item
  540. *
  541. * @function
  542. * @returns {undefined}
  543. */
  544. showAll() {
  545. this.set( 'showingAll', true );
  546. }
  547. });