bm-core-ui 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,27 @@ import {YES, NO, BMCopyProperties, BMNumberByConstrainingNumberToBounds} from '.
4
4
  import {BMPoint, BMPointMake} from '../Core/BMPoint'
5
5
  import {BMHook, __BMVelocityAnimate} from '../Core/BMAnimationContext'
6
6
  import 'velocity-animate'
7
- import { BMRectMakeWithNodeFrame } from '../Core/BMRect';
7
+ import { BMRect, BMRectMake, BMRectMakeWithNodeFrame, BMRectMakeWithOrigin } from '../Core/BMRect';
8
8
  import { BMView } from './BMView_v2.5';
9
9
  import { BMKeyboardShortcut } from '../BMWindow/BMKeyboardShortcut';
10
+ import { BMSizeMake } from '../Core/BMSize';
11
+
12
+ /**
13
+ * A flag that, if enabled, causes web animations to be used for menu animations.
14
+ */
15
+ const BMMENU_USE_WEB_ANIMATIONS = NO;
10
16
 
11
17
  /**
12
18
  * The standard spacing between the menu and the node it is created from.
13
19
  */
14
20
  const _BMMenuSpacingToNode = 8;
15
21
 
22
+ /**
23
+ * The amount of time, in milliseconds, to block interaction with menu items to
24
+ * allow the user to navigate to a submenu.
25
+ */
26
+ const _BMMenuSubmenuTimeout = 300;
27
+
16
28
  /**
17
29
  * The name of the CSS class that is added to nodes that have a popup menu.
18
30
  */
@@ -70,11 +82,58 @@ BMMenuItem.prototype = {
70
82
  return this._action;
71
83
  },
72
84
 
85
+ /**
86
+ * An optional submenu that opens from this menu item.
87
+ */
88
+ _submenu: undefined, // <BMMenu, nullable>
89
+ get submenu() {
90
+ return this._submenu;
91
+ },
92
+
93
+ /**
94
+ * Whether this menu item is a separator.
95
+ */
96
+ _isSeparator: NO, // <Boolean>
97
+ get isSeparator() {
98
+ return this._isSeparator;
99
+ },
100
+
73
101
  /**
74
102
  * Additional, arbitrary information associated with this menu item.
75
103
  */
76
104
  userInfo: undefined, // <AnyObject, nullable>
77
105
 
106
+ /**
107
+ * Additional CSS classes to apply to this item's node.
108
+ */
109
+ _CSSClass: '', // <String, nullable>
110
+ get CSSClass() {
111
+ return this._CSSClass;
112
+ },
113
+ set CSSClass(cls) {
114
+ // If this menu item is rendered, apply the class to its node
115
+ let node = this._node;
116
+ if (node) {
117
+ // First remove the previous classes, if they exist
118
+ if (this._CSSClass) {
119
+ this._CSSClass.split(' ').forEach(cls => node.classList.remove(cls));
120
+ }
121
+
122
+ // Then add the new classes, if they exist
123
+ if (cls) {
124
+ cls.split(' ').forEach(cls => node.classList.add(cls));
125
+ }
126
+ }
127
+
128
+ this._CSSClass = cls || '';
129
+ },
130
+
131
+ /**
132
+ * The node that represents this menu item, available while its menu
133
+ * is open.
134
+ */
135
+ _node: undefined, // <DOMNode, nullable>
136
+
78
137
  /**
79
138
  * Constructs and returns a menu item with the given name.
80
139
  * Optionally, the menu item may have an icon associated with it as well as an
@@ -86,16 +145,22 @@ BMMenuItem.prototype = {
86
145
  *
87
146
  * @param action <void ^ (BMMenuItem), nullable> If specified, this is a function that will be invoked when this menu item is selected.
88
147
  * This function will receive the selected menu item as a parameter.
148
+ * @param submenu <BMMenu, nullable> Optional. If specified, this represents a menu that opens from this menu item.
89
149
  * @param userInfo <AnyObject, nullable> Optional. If specified, this represents additional arbitrary data attached to this menu item.
90
150
  * }
91
151
  * @return <BMMenuItem> This menu item.
92
152
  */
93
- initWithName(name, {icon, action, userInfo} = {}) {
153
+ initWithName(name, {icon, action, submenu, userInfo} = {}) {
94
154
  this._name = name;
95
155
  this._icon = icon;
96
156
  this._action = action;
157
+ this._submenu = submenu;
97
158
  this.userInfo = userInfo;
98
159
 
160
+ if (name.startsWith('---')) {
161
+ this._isSeparator = YES;
162
+ }
163
+
99
164
  return this;
100
165
  }
101
166
 
@@ -112,6 +177,8 @@ BMMenuItem.prototype = {
112
177
  *
113
178
  * @param action <void ^ (BMMenuItem), nullable> If specified, this is a function that will be invoked when this menu item is selected.
114
179
  * This function will receive the selected menu item as a parameter.
180
+ * @param submenu <BMMenu, nullable> Optional. If specified, this represents a menu that opens from this menu item.
181
+ * @param userInfo <AnyObject, nullable> Optional. If specified, this represents additional arbitrary data attached to this menu item.
115
182
  * }
116
183
  * @return <BMMenuItem> A menu item.
117
184
  */
@@ -161,32 +228,46 @@ BMMenu.prototype = {
161
228
  * Defaults to 24. The size to use for this menu's icons.
162
229
  */
163
230
  _iconSize: 24, // <Number>
164
-
165
231
  get iconSize() {
166
232
  return this._iconSize;
167
233
  },
168
234
  set iconSize(size) {
169
235
  this._iconSize = size;
170
236
 
171
- if (this._node) for (const icon of this._node.querySelectorAll()) {
172
- BMCopyProperties(icon.style, {
173
- width: size + 'px',
174
- height: size + 'px'
175
- });
237
+ if (this._node) {
238
+ // When the icon size is changed while the menu is active
239
+ // apply it to all menu items
240
+ for (const icon of this._node.querySelectorAll('.BMMenuIcon')) {
241
+ BMCopyProperties(icon.style, {
242
+ width: size + 'px',
243
+ height: size + 'px'
244
+ });
245
+ }
176
246
  }
177
247
  },
178
248
 
179
249
  /**
180
250
  * A custom list of CSS classes to add to the menu DOM node.
181
- * This only has effect when set before the menu is opened.
182
251
  */
183
252
  _CSSClass: '', // <String>
184
-
185
253
  get CSSClass() {
186
254
  return this._CSSClass;
187
255
  },
188
-
189
256
  set CSSClass(CSSClass) {
257
+ // If this menu item is rendered, apply the class to its node
258
+ let node = this._node;
259
+ if (node) {
260
+ // First remove the previous classes, if they exist
261
+ if (this._CSSClass) {
262
+ this._CSSClass.split(' ').forEach(cls => node.classList.remove(cls));
263
+ }
264
+
265
+ // Then add the new classes, if they exist
266
+ if (CSSClass) {
267
+ CSSClass.split(' ').forEach(cls => node.classList.add(cls));
268
+ }
269
+ }
270
+
190
271
  this._CSSClass = CSSClass || '';
191
272
  },
192
273
 
@@ -218,22 +299,43 @@ BMMenu.prototype = {
218
299
  /**
219
300
  * The source node displacement, used while the menu is visible.
220
301
  */
221
- _sourceNodeDisplacement: undefined, // <Number, nullable>
302
+ _sourceNodeDisplacement: 0, // <Number, nullable>
222
303
 
223
304
  /**
224
- * The index of the currently highlighted menu item.
305
+ * The source node horizontal displacement, used while the menu is visible.
225
306
  */
226
- _highlightedIndex: -1, // <Number>
307
+ _sourceNodeHorizontalDisplacement: 0, // <Number, nullable>
227
308
 
228
309
  /**
229
310
  * The DOM node that was focused when this alert was opened.
230
311
  */
231
- _previouslyActiveNode: undefined, // DOMNode
312
+ _previouslyActiveNode: undefined, // <DOMNode>
313
+
314
+ /**
315
+ * The submenu currently displayed, if any.
316
+ */
317
+ _submenu: undefined, // <BMMenu, nullable>
318
+
319
+ /**
320
+ * The menu that opened this menu, if any.
321
+ */
322
+ _supermenu: undefined, // <BMMenu, nullable>
232
323
 
324
+ /**
325
+ * The menu's frame, relative to the viewport, available while this menu is visible.
326
+ */
327
+ _frame: undefined, // <BMRect, nullable>
328
+ get frame() {
329
+ return this._frame && this._frame.copy();
330
+ },
331
+
332
+ /**
333
+ * The index of the currently highlighted menu item.
334
+ */
335
+ _highlightedIndex: -1, // <Number>
233
336
  get highlightedIndex() {
234
337
  return this._highlightedIndex;
235
338
  },
236
-
237
339
  set highlightedIndex(index) {
238
340
  if (index != this._highlightedIndex) {
239
341
  this._highlightedIndex = index;
@@ -251,9 +353,37 @@ BMMenu.prototype = {
251
353
  nextHighlightedItem.classList.add('BMMenuItemHighlighted');
252
354
  }
253
355
  }
356
+
357
+ // If the highlight index changes while a submenu is open
358
+ // dismiss the submenu
359
+ if (index != -1 && this._submenu) {
360
+ this._submenu.closeAnimated(YES);
361
+ }
254
362
  }
255
363
  },
256
364
 
365
+ /**
366
+ * Set to `YES` while delaying events due to a submenu having
367
+ * been recently activated.
368
+ */
369
+ _delaysEvents: NO, // <Boolean>
370
+
371
+ /**
372
+ * Controls whether the closing animation should be delayed to allow time
373
+ * for the selection animation to play on the selected menu item.
374
+ */
375
+ __delaysClosing: NO, // <Boolean>
376
+ get _delaysClosing() {
377
+ return this.__delaysClosing;
378
+ },
379
+ set _delaysClosing(delays) {
380
+ // When set to YES, this should propagate up to the supermenu
381
+ if (this._supermenu) {
382
+ this._supermenu._delaysClosing = delays;
383
+ }
384
+ this.__delaysClosing = delays;
385
+ },
386
+
257
387
  /**
258
388
  * Initializes this menu with the specified items.
259
389
  * The menu will be hidden by default; the method showAtPoint(_) should be used to make the menu visible.
@@ -272,11 +402,28 @@ BMMenu.prototype = {
272
402
  * Invoked by CoreUI to build the menu node.
273
403
  */
274
404
  _renderMenu() {
405
+ if (this._supermenu) {
406
+ // If this menu opens as a submenu, share the parent menu's container node
407
+ return this._renderMenuWithContainer(this._supermenu._containerNode);
408
+ }
409
+ else {
410
+ return this._renderMenuWithContainer();
411
+ }
412
+ },
413
+
414
+ /**
415
+ * Invoked by CoreUI to build the menu node, in the specified container.
416
+ * @param container <DOMNode, nullable> The container node in which to render the menu.
417
+ * If not specified, a new container will be created
418
+ * for this menu.
419
+ */
420
+ _renderMenuWithContainer(container) {
275
421
  // The actual menu node
276
422
  const menuNode = document.createElement('div');
277
423
  menuNode.className = this._CSSClass ? `BMMenu ${this._CSSClass}` : 'BMMenu';
278
424
  this._node = menuNode;
279
425
 
426
+ // When opening the menu, reset the highlight index
280
427
  this._highlightedIndex = -1;
281
428
 
282
429
  menuNode.tabIndex = -1;
@@ -290,6 +437,14 @@ BMMenu.prototype = {
290
437
  downArrow.preventsDefault = YES;
291
438
  BMView.registerKeyboardShortcut(downArrow, {forNode: menuNode});
292
439
 
440
+ const rightArrow = BMKeyboardShortcut.keyboardShortcutWithKeyCode('ArrowRight', {modifiers: [], target: this, action: 'arrowRightPressedWithEvent'});
441
+ rightArrow.preventsDefault = YES;
442
+ BMView.registerKeyboardShortcut(rightArrow, {forNode: menuNode});
443
+
444
+ const leftArrow = BMKeyboardShortcut.keyboardShortcutWithKeyCode('ArrowLeft', {modifiers: [], target: this, action: 'arrowLeftPressedWithEvent'});
445
+ leftArrow.preventsDefault = YES;
446
+ BMView.registerKeyboardShortcut(leftArrow, {forNode: menuNode});
447
+
293
448
  const returnShortcut = BMKeyboardShortcut.keyboardShortcutWithKeyCode('Enter', {modifiers: [], target: this, action: 'returnPressedWithEvent'});
294
449
  returnShortcut.preventsDefault = YES;
295
450
  BMView.registerKeyboardShortcut(returnShortcut, {forNode: menuNode});
@@ -307,8 +462,11 @@ BMMenu.prototype = {
307
462
  BMView.registerKeyboardShortcut(escape, {forNode: menuNode});
308
463
 
309
464
  // The overlay which intercepts clicks outside of the menu
310
- const menuContainer = document.createElement('div');
311
- menuContainer.className = 'BMMenuContainer';
465
+ const menuContainer = container || document.createElement('div');
466
+ if (!container) {
467
+ menuContainer.className = 'BMMenuContainer';
468
+ }
469
+
312
470
  if (!('backdropFilter' in document.body.style) && !('webkitBackdropFilter' in document.body.style)) {
313
471
  menuNode.style.backgroundColor = 'white';
314
472
  }
@@ -317,17 +475,46 @@ BMMenu.prototype = {
317
475
 
318
476
  this._renderMenuItems();
319
477
 
320
- menuNode.addEventListener('click', event => event.preventDefault());
478
+ // When clicking an empty area of the menu don't do anything
479
+ menuNode.addEventListener('click', event => {
480
+ if (event.target == menuNode) {
481
+ // But close any open submenus
482
+ if (this._submenu) {
483
+ this._submenu.closeAnimated(YES);
484
+ }
485
+ event.stopPropagation();
486
+ }
487
+
488
+ event.preventDefault();
489
+ });
321
490
 
491
+ // When the mouse exits the menu area, reset the highlight position to the first element
492
+ // unless the menu is not focused
322
493
  menuNode.addEventListener('mouseleave', event => {
323
- this.highlightedIndex = -1;
494
+ // If events should be delayed, don't process this mouseleave event
495
+ if (this._delaysEvents) return;
496
+
497
+ if (document.activeElement == menuNode) {
498
+ this.highlightedIndex = -1;
499
+ }
324
500
  });
325
501
 
502
+ // Close the menu when clicking outside of it
326
503
  menuContainer.addEventListener('click', event => {
504
+ // If the click occurs inside the container while events are being suppressed
505
+ // do not close the menu
506
+ if (this._delaysEvents) {
507
+ if (this._frame && this._frame.intersectsPoint(BMPointMake(event.clientX, event.clientY))) {
508
+ return;
509
+ }
510
+ }
511
+
327
512
  this.closeAnimated(YES);
328
513
  event.preventDefault();
329
514
  });
330
515
 
516
+ // Also close the menu when right-clicking outside of it
517
+ // and prevent the browser's regular context menu from appearing
331
518
  menuContainer.addEventListener('contextmenu', event => {
332
519
  this.closeAnimated(YES);
333
520
  event.preventDefault();
@@ -342,6 +529,8 @@ BMMenu.prototype = {
342
529
  const iconSize = this._iconSize;
343
530
 
344
531
  this._items.forEach((item, index) => {
532
+ // Items starting with at least three minus signs are interpreted as separators
533
+ // TODO: In the future, there should be a better documented way to do this
345
534
  if (item.name.startsWith('---')) {
346
535
  const itemNode = document.createElement('div');
347
536
  itemNode.className = 'BMMenuDivider';
@@ -349,10 +538,13 @@ BMMenu.prototype = {
349
538
  return;
350
539
  }
351
540
 
541
+ // Create the item node
352
542
  let itemNode = document.createElement('div');
353
543
  itemNode.className = 'BMMenuItem';
544
+ item._node = itemNode;
354
545
 
355
546
  if (item.icon) {
547
+ // Create the icon, if the menu item has one
356
548
  const icon = document.createElement('img');
357
549
  icon.className = 'BMMenuIcon';
358
550
  icon.src = item.icon;
@@ -363,25 +555,82 @@ BMMenu.prototype = {
363
555
  itemNode.appendChild(icon);
364
556
  }
365
557
 
558
+ // Create the label
366
559
  const itemText = document.createElement('span');
367
560
  itemText.innerText = item.name;
368
561
  itemNode.appendChild(itemText);
562
+
563
+ // Create the disclosure container
564
+ const submenuIcon = document.createElement('div');
565
+ submenuIcon.classList.add('BMMenuItemDisclosure');
566
+ // If the item has a submenu, draw an icon indicating it
567
+ if (item.submenu) {
568
+ submenuIcon.classList.add('BMMenuItemDisclosureSubmenu');
569
+ }
570
+ itemNode.appendChild(submenuIcon);
369
571
 
572
+ // TWhen clicking an item, trigger its action, if it has one
370
573
  itemNode.addEventListener('click', event => {
371
- itemNode.classList.add('BMMenuItemActive');
574
+ if (item.submenu && this._sourceNodeShadow) {
575
+ // For touch menus, if the item has a submenu, open it
576
+ this._openSubmenuForMenuItem(item, {animated: YES, delayEvents: NO, acquireFocus: YES});
372
577
 
373
- if (item.action) item.action(item);
578
+ // Don't activate the item or close the menu
579
+ event.stopPropagation();
580
+ return;
581
+ }
582
+
583
+ // Ask the delegate if this menu item should be selected
584
+ let shouldSelect = YES;
585
+ if (this.delegate && this.delegate.menuShouldSelectItem) {
586
+ shouldSelect = this.delegate.menuShouldSelectItem(this, item);
587
+ }
588
+
589
+ if (!shouldSelect) {
590
+ // If the item shouldn't be selected, also prevent closing the menu
591
+ event.stopPropagation();
592
+ return;
593
+ }
594
+
595
+ // For desktop menus, delay closing slightly to allow the selection
596
+ // animation to play out
597
+ this._delaysClosing = YES;
598
+
599
+ if (item.action) {
600
+ // Invoke the menu's action if configured
601
+ item.action(item);
602
+ }
603
+
604
+ itemNode.classList.add('BMMenuItemActive');
374
605
 
375
606
  if (this.delegate && this.delegate.menuDidSelectItem) {
376
607
  this.delegate.menuDidSelectItem(this, item);
377
608
  }
378
609
  });
379
610
 
380
- itemNode.addEventListener('mouseenter', event => {
611
+ // When moving the mouse over an item, set the highlight index to it,
612
+ // allowing further keyboard navigation to continute from this item
613
+ itemNode.addEventListener('mouseover', event => {
614
+ if (this._highlightedIndex == index) return;
615
+
616
+ // If events should be delayed, don't process this mouseover event
617
+ if (this._delaysEvents) return;
618
+
381
619
  this.highlightedIndex = index;
382
- })
383
620
 
384
- itemNode.addEventListener
621
+ if (item.submenu) {
622
+ // If the item has a submenu, open it after a short delay as long as the highlighted
623
+ // element doesn't change
624
+ setTimeout(() => {
625
+ // For touch menus, require a click to bring up the submenu
626
+ if (this._sourceNodeShadow) return;
627
+
628
+ if (this._highlightedIndex == index) {
629
+ this._openSubmenuForMenuItem(item, {animated: YES, delayEvents: YES});
630
+ }
631
+ }, 100);
632
+ }
633
+ });
385
634
 
386
635
  this._node.appendChild(itemNode);
387
636
  });
@@ -405,47 +654,108 @@ BMMenu.prototype = {
405
654
  * @return <Promise<void>> A promise that resolves when the operation completes.
406
655
  */
407
656
  async openFromNode(node, {animated = NO, kind = BMMenuKind.Menu} = {}) {
657
+ // Signal the delegate that this menu is about to open
658
+ if (this.delegate && this.delegate.menuWillOpen) {
659
+ this.delegate.menuWillOpen(this);
660
+ }
661
+
408
662
  this._sourceNode = node;
409
663
 
664
+ // Get the source node's position to position its shadow appropriately
410
665
  const sourceRect = BMRectMakeWithNodeFrame(node);
666
+
667
+ // The source node may be partially hidden because of the scroll position, so use
668
+ // its actual height for the rect
411
669
  sourceRect.size.height = node.offsetHeight;
670
+
412
671
  const viewportHeight = window.innerHeight;
413
672
 
673
+ // Create a copy of the source node, to be displayed next to the menu
414
674
  this._sourceNodeShadow = node.cloneNode(YES);
415
675
 
416
676
  this._renderMenu();
417
677
 
418
678
  const menuNode = this._node;
419
679
  const menuContainer = this._containerNode;
420
- document.body.appendChild(menuContainer);
680
+ if (!this._supermenu) {
681
+ document.body.appendChild(menuContainer);
682
+ }
421
683
  document.body.appendChild(this._sourceNodeShadow);
422
684
 
685
+ // Apply a slight scale to the source node
686
+ const sourceRectScaled = sourceRect.copy();
687
+ sourceRectScaled.scaleWithFactor(1.1);
688
+
423
689
  // When open from a node, the menu container will darken the content behind it
424
690
  menuContainer.classList.add('BMMenuContainerFullScreen');
425
691
  this._sourceNodeShadow.classList.add('BMMenuSourceNodeShadow');
426
692
  menuNode.classList.add('BMMenuTouch');
427
693
 
694
+ if (this._supermenu) {
695
+ // If this opens as a submenu, it must be at least as wide as the supermenu
696
+ menuNode.style.minWidth = `${this._supermenu.frame.size.width + 8}px`;
697
+
698
+ // Additionally, the supermenu should stop recieving pointer input until this
699
+ // submenu is closed
700
+ this._supermenu._node.classList.add('BMMenuTouchSubmenu');
701
+ }
702
+
703
+ // Hide the source node while the menu is visible
428
704
  this._sourceNode.classList.add(BMMenuSourceNodeCSSClass);
429
705
 
430
706
  // When applicable, this interaction will perform a short tap
431
707
  if ('vibrate' in window.navigator) window.navigator.vibrate(10);
432
708
 
433
- const remainingHeight = viewportHeight - sourceRect.bottom;
434
709
  const menuHeight = menuNode.offsetHeight;
710
+ const menuWidth = menuNode.offsetWidth;
711
+
712
+ const viewportWidth = window.innerWidth;
713
+
714
+ // The scale to apply to the source node shadow
715
+ let sourceNodeScale = 1.1;
716
+
717
+ // When the source node is wider or taller than the viewport, scale it down to fit
718
+ if (sourceRectScaled.width > viewportWidth - _BMMenuSpacingToNode * 2) {
719
+ const widthScale = (viewportWidth - _BMMenuSpacingToNode * 2) / sourceRectScaled.width;
720
+ sourceRectScaled.scaleWithFactor(widthScale);
721
+
722
+ sourceNodeScale *= widthScale;
723
+ }
724
+
725
+ if (sourceRectScaled.height > viewportHeight - menuHeight - _BMMenuSpacingToNode * 2) {
726
+ const heightScale = (viewportHeight - menuHeight - _BMMenuSpacingToNode * 2) / sourceRectScaled.height;
727
+ sourceRectScaled.scaleWithFactor(heightScale);
728
+
729
+ sourceNodeScale *= heightScale;
730
+ }
731
+
732
+ const remainingHeight = viewportHeight - sourceRectScaled.bottom;
435
733
 
436
734
  let displacement = 0;
735
+ let horizontalDisplacement = 0;
437
736
 
438
737
  const scale = .33;
439
738
  const pullDownScale = .5;
440
739
  const duration = 400;
441
740
  const easing = [.17,1.46,.84,.93];
442
741
 
443
- // Displace the element, if needed
742
+ // Displace the element if it is above the visible area
743
+ if (sourceRectScaled.origin.y < 0) {
744
+ displacement = -sourceRectScaled.origin.y + 16;
745
+ }
746
+
747
+ // Displace the element, if needed because there's no space on the bottom
444
748
  if (menuHeight + _BMMenuSpacingToNode * 2 > remainingHeight) {
445
749
  displacement = remainingHeight - (menuHeight + _BMMenuSpacingToNode * 2);
446
750
  }
447
751
 
752
+ // Displace the element, if needed because there's no space on the right
753
+ if (menuWidth + sourceRectScaled.origin.x + _BMMenuSpacingToNode > viewportWidth) {
754
+ horizontalDisplacement = viewportWidth - (menuWidth + sourceRectScaled.origin.x + _BMMenuSpacingToNode);
755
+ }
756
+
448
757
  this._sourceNodeDisplacement = displacement;
758
+ this._sourceNodeHorizontalDisplacement = horizontalDisplacement;
449
759
 
450
760
  BMCopyProperties(this._sourceNodeShadow.style, {left: sourceRect.origin.x + 'px', top: sourceRect.origin.y + 'px', width: sourceRect.size.width + 'px', height: sourceRect.size.height + 'px', transform: 'none'});
451
761
 
@@ -454,25 +764,40 @@ BMMenu.prototype = {
454
764
  menuNode.style.transformOrigin = '50% 0%';
455
765
  }
456
766
 
457
- const point = BMPointMake(sourceRect.origin.x, sourceRect.bottom + _BMMenuSpacingToNode + displacement);
767
+ const point = BMPointMake(Math.max(sourceRectScaled.origin.x + horizontalDisplacement, 8), sourceRectScaled.bottom + _BMMenuSpacingToNode + displacement);
458
768
  BMCopyProperties(menuNode.style, {left: point.x + 'px', top: point.y + 'px'});
769
+
770
+ this._frame = BMRectMakeWithOrigin(point, {size: BMSizeMake(menuWidth, menuHeight)});
459
771
 
460
- BMHook(menuContainer, {opacity: 0});
772
+ // Prepare the initial state of the animation
773
+ if (!this._supermenu) {
774
+ BMHook(menuContainer, {opacity: 0});
775
+ }
776
+ else {
777
+ BMHook(menuNode, {opacity: 0});
778
+ }
461
779
  BMHook(menuNode, {
462
780
  scaleX: kind == BMMenuKind.PullDownMenu ? pullDownScale : scale,
463
781
  scaleY: kind == BMMenuKind.PullDownMenu ? pullDownScale : scale,
464
- translateY: -displacement + 'px'
782
+ translateY: -displacement + 'px',
783
+ translateX: -horizontalDisplacement + 'px',
465
784
  });
466
785
 
467
- // Make the container visible
468
- __BMVelocityAnimate(menuContainer, {opacity: 1}, {duration: duration, easing: easing});
786
+ if (!this._supermenu) {
787
+ // Make the container visible
788
+ __BMVelocityAnimate(menuContainer, {opacity: 1}, {duration, easing}, BMMENU_USE_WEB_ANIMATIONS);
789
+ }
790
+ else {
791
+ this._supermenu._node.classList.add('BMMenuInactive');
792
+ __BMVelocityAnimate(this._supermenu._node, {scaleX: .95, scaleY: .95}, {duration, easing: 'easeOutQuad'}, BMMENU_USE_WEB_ANIMATIONS);
793
+ }
469
794
 
470
795
  // Make the menu expand
471
- __BMVelocityAnimate(menuNode, {scaleX: 1, scaleY: 1, opacity: 1, translateZ: 0, translateY: 0}, {
472
- duration: duration,
473
- easing: easing,
796
+ __BMVelocityAnimate(menuNode, {scaleX: 1, scaleY: 1, opacity: 1, translateZ: 0, translateY: 0, translateX: 0}, {
797
+ duration,
798
+ easing,
474
799
  complete: _ => ((menuNode.style.pointerEvents = 'all'), menuContainer.style.pointerEvents = 'all')
475
- });
800
+ }, BMMENU_USE_WEB_ANIMATIONS);
476
801
 
477
802
  // Animate each child node in
478
803
  let delay = 50;
@@ -482,15 +807,21 @@ BMMenu.prototype = {
482
807
  duration: 100,
483
808
  easing: 'easeOutQuad',
484
809
  delay: delay
485
- });
810
+ }, BMMENU_USE_WEB_ANIMATIONS);
486
811
  delay += 16;
487
812
  }
488
813
 
814
+ // Retain the preivously focused node, to restore its focus when the menu closes
489
815
  this._previouslyActiveNode = document.activeElement;
490
816
  menuNode.focus();
491
817
 
492
818
  // Animate the source node shadow
493
- await __BMVelocityAnimate(this._sourceNodeShadow, {translateY: displacement + 'px'}, {duration: duration, easing: easing});
819
+ await __BMVelocityAnimate(
820
+ this._sourceNodeShadow,
821
+ {translateY: displacement + 'px', translateX: horizontalDisplacement + 'px', scaleX: sourceNodeScale, scaleY: sourceNodeScale},
822
+ {duration, easing},
823
+ BMMENU_USE_WEB_ANIMATIONS
824
+ );
494
825
 
495
826
  },
496
827
 
@@ -506,54 +837,98 @@ BMMenu.prototype = {
506
837
  * }
507
838
  */
508
839
  openAtPoint(point, {animated = NO, kind = BMMenuKind.Menu} = {}) {
840
+ // Create a 0-by-0 rect at the given point and open the menu around it
841
+ const rect = BMRectMakeWithOrigin(point, {size: BMSizeMake()});
842
+
843
+ return this._openAroundRect(rect, arguments[1]);
844
+ },
845
+
846
+ /**
847
+ * Shows this menu at an appropriate position around the given rect. The coordinates of this rect
848
+ * are relative to the viewport.
849
+ * @param rect <BMRect> The rect around which to show this menu.
850
+ * {
851
+ * @param animated <Boolean, nullable> Defaults to `NO`. If set to `YES`, this change will be animated.
852
+ * If this method is invoked from within an animation context, the value of this parameter is ignored
853
+ * and the values of the current animation context are used.
854
+ *
855
+ * @param kind <BMMenuKind, nullable> Defaults to `Menu`. The kind of menu.
856
+ */
857
+ _openAroundRect(rect, {animated = NO, kind = BMMenuKind.Menu} = {}) {
509
858
  // If the menu is already open, do nothing
510
859
  if (this._node) return;
511
860
 
861
+ // Signal the delegate that this menu is about to open
512
862
  if (this.delegate && this.delegate.menuWillOpen) {
513
863
  this.delegate.menuWillOpen(this);
514
864
  }
515
865
 
866
+ // Build the DOM structure
516
867
  this._renderMenu();
517
868
 
518
869
  const menuNode = this._node;
519
870
  const menuContainer = this._containerNode;
520
871
 
872
+ // Prepare the initial state of the animation
521
873
  BMHook(menuNode, {
522
874
  scaleX: kind == BMMenuKind.PullDownMenu ? .5 : .75,
523
875
  scaleY: kind == BMMenuKind.PullDownMenu ? .5 : .75,
524
876
  opacity: 0
525
877
  });
526
878
 
527
- document.body.appendChild(menuContainer);
879
+ if (!this._supermenu) {
880
+ document.body.appendChild(menuContainer);
881
+ }
528
882
 
883
+ // Get the menu's metrics to determine where it fits best
529
884
  const height = menuNode.offsetHeight;
530
885
  const width = menuNode.offsetWidth;
886
+ const metrics = window.getComputedStyle(menuNode);
887
+
888
+ // The paddings are used to adjust the menu's position so that the first
889
+ // or last menu item appears directly on the pointer's position
890
+ const paddingTop = parseInt(metrics.paddingTop, 10) || 0;
891
+ const paddingBottom = parseInt(metrics.paddingBottom, 10) || 0;
531
892
 
893
+ // The transform origins will be adjusted based on which direction the menu opens in
532
894
  let transformOriginX = '0%';
533
895
  let transformOriginY = ' 0%';
534
896
 
897
+ // Attempt to open the menu towards the bottom right, starting at the
898
+ // top-right corner of the rect, subtracting the menu's padding so
899
+ // that the first menu item appears directly on the pointer's position
900
+ const point = BMPointMake(rect.right, rect.origin.y - paddingTop);
901
+
902
+ // Determine where to place the menu based on where it fits best, preferring towards
903
+ // the bottom-right of the origin point
535
904
  if (kind == BMMenuKind.PullDownMenu) {
536
905
  transformOriginX = '50%';
537
906
  }
538
907
  else if (width + point.x > document.documentElement.clientWidth) {
908
+ // If the menu doesn't fit towards the right, open it towards the left
539
909
  transformOriginX = '100%';
540
- point.x -= width;
910
+ point.x = rect.origin.x - width;
541
911
  }
542
912
  if (height + point.y > document.documentElement.clientHeight) {
913
+ // If the menu doesn't fit towards the bottom, open it towards the top
543
914
  transformOriginY = ' 100%';
544
- point.y -= height;
915
+ point.y = rect.bottom + paddingBottom - height;
545
916
  }
546
917
  menuNode.style.transformOrigin = transformOriginX + transformOriginY;
547
918
 
919
+ // Set menu's the position
920
+ this._frame = BMRectMakeWithOrigin(point, {size: BMSizeMake(width, height)});
548
921
  menuNode.style.left = point.x + 'px';
549
922
  menuNode.style.top = point.y + 'px';
550
923
 
924
+ // Animate the menu in
551
925
  (window.Velocity || $.Velocity).animate(menuNode, {scaleX: 1, scaleY: 1, opacity: 1, translateZ: 0}, {
552
926
  duration: 200,
553
927
  easing: 'easeOutQuad',
554
928
  complete: _ => ((menuNode.style.pointerEvents = 'all'), menuContainer.style.pointerEvents = 'all')
555
929
  });
556
930
 
931
+ // Animate each menu item in
557
932
  let delay = 0;
558
933
  for (let child of menuNode.childNodes) {
559
934
  BMHook(child, {translateY: '16px', translateZ: 0, opacity: 0});
@@ -565,62 +940,339 @@ BMMenu.prototype = {
565
940
  delay += 16;
566
941
  }
567
942
 
943
+ // Retain the preivously focused node, to restore its focus when the menu closes
568
944
  this._previouslyActiveNode = document.activeElement;
569
- menuNode.focus();
570
945
 
571
- return menuNode;
946
+ // Focus this menu, unless it opens as a submenu, in which case the parent menu
947
+ // is responsible for granting focus
948
+ if (!this._supermenu) {
949
+ menuNode.focus();
950
+ }
572
951
  },
573
952
 
574
953
  /**
575
- * Animatable. Dismisses this menu, if it is visible.
576
- * @param animated <Boolean, nullable> Defaults to `NO`. If set to `YES`, this change will be animated.
577
- * If this method is invoked from within an animation context, the value of this parameter is ignored
578
- * and the values of the current animation context are used.
954
+ * Opens the submenu for the given menu item.
955
+ * @param item <BMMenuItem> The item whose submenu should be opened.
956
+ * {
957
+ * @param animated <Boolean, nullable> Defaults to `YES`. If set to `YES` this change will be animated.
958
+ * @param delaysEvents <Boolean, nullable> Defaults to `NO`. If set to `YES`, mouse events on the current menu will be
959
+ * temporarily suspended, allowing the pointer to move over other menu items
960
+ * without closing the submenu.
961
+ * @param acquireFocus <Boolean, nullable> Defaults to `NO`. If set to `YES`, the submenu will acquire keyboard focus upon
962
+ * opening. Otherwise, this menu will retain keyboard focus.
963
+ * }
964
+ */
965
+ _openSubmenuForMenuItem(item, {animated = YES, delayEvents, acquireFocus} = {animated: YES}) {
966
+ const submenu = item._submenu;
967
+
968
+ // If the item doesn't have a submenu, there's no action to take
969
+ if (!submenu) return;
970
+
971
+ const itemFrame = BMRectMakeWithNodeFrame(item._node);
972
+
973
+ if (delayEvents) {
974
+ this._delayEventsForSubmenu(submenu, {item});
975
+ }
976
+
977
+ // Render the submenu and set its delegate to this menu's delegate
978
+ submenu._supermenu = this;
979
+ this._submenu = submenu;
980
+
981
+ // Open an appropriate menu kind based on this menu's kind
982
+ if (this._sourceNodeShadow) {
983
+ submenu.openFromNode(item._node, {animated, kind: BMMenuKind.PullDownMenu});
984
+ }
985
+ else {
986
+ submenu._openAroundRect(itemFrame, {animated});
987
+ }
988
+
989
+ if (acquireFocus) {
990
+ // If focus should be acquired, grant it immediately when opening
991
+ submenu._node.focus();
992
+ }
993
+ },
994
+
995
+ /**
996
+ * Temporarily prevents menu items from being highlighted, while the mouse
997
+ * pointer moves towards the specified submenu.
998
+ * @param submenu <BMMenu> The submenu for which to delay events.
999
+ * {
1000
+ * @param item <BMMenuItem> The item from which the submenu opened.
1001
+ * }
1002
+ */
1003
+ _delayEventsForSubmenu(submenu, {item}) {
1004
+ /** @type {BMRect} */
1005
+ let submenuFrame = submenu.frame;
1006
+
1007
+ const itemFrame = BMRectMakeWithNodeFrame(item._node);
1008
+
1009
+ // The position of the previous event, used to determine the direction in which the mouse
1010
+ // pointer moves
1011
+ let lastPosition;// = BMPointMake(event.clientX, event.clientY);
1012
+
1013
+ // Determine the top and bottom corners on the edge closest to the pointer's current position
1014
+ // These will be used to verify if the mouse pointer does move towards the submenu or away from it
1015
+ let topPoint;
1016
+ let bottomPoint;
1017
+
1018
+ // Determines whether the slope needs to be between the min and max slopes or outside their range
1019
+ let flipSlopes = false;
1020
+
1021
+ const eventTarget = this._containerNode;
1022
+ // Immediately block interactions with menu elements
1023
+ this._delaysEvents = YES;
1024
+ eventTarget.classList.add('BMMenuContainerSubmenuActive');
1025
+ const mouseMoveEvent = 'mousemove';
1026
+
1027
+ let timeout;
1028
+
1029
+ // Set to `YES` after pointer leaves the menu item's frame
1030
+ let active = NO;
1031
+
1032
+ // Causes regular event processing to resume.
1033
+ const resumeEvents = () => {
1034
+ // Stops ignoring events
1035
+ this._delaysEvents = NO;
1036
+
1037
+ // Remove the mousemove event handler
1038
+ eventTarget.removeEventListener(mouseMoveEvent, handler, {capture: YES});
1039
+
1040
+ // Allow pointer interaction with the menu nodes
1041
+ eventTarget.classList.remove('BMMenuContainerSubmenuActive');
1042
+
1043
+ if (timeout) {
1044
+ // Clear the timeout if it still exists
1045
+ window.clearTimeout(timeout);
1046
+ timeout = undefined;
1047
+ }
1048
+ }
1049
+
1050
+ const handler = (/** @type {MouseEvent} */ event) => {
1051
+ const currentPosition = BMPointMake(event.clientX, event.clientY);
1052
+
1053
+ if (!submenuFrame) {
1054
+ // The handler is set up before the submenu actually renders
1055
+ // so get its frame when it becomes available
1056
+ submenuFrame = submenu.frame;
1057
+ return;
1058
+ }
1059
+
1060
+ if (!lastPosition) {
1061
+ // On the first movement initialize the previous position
1062
+ // and top and bottom points relative to the pointer's position
1063
+ lastPosition = currentPosition;
1064
+ topPoint = submenuFrame.origin.x > lastPosition.x ?
1065
+ submenuFrame.origin :
1066
+ BMPointMake(submenuFrame.right, submenuFrame.origin.y);
1067
+ bottomPoint = submenuFrame.origin.x > lastPosition.x ?
1068
+ BMPointMake(submenuFrame.origin.x, submenuFrame.bottom) :
1069
+ BMPointMake(submenuFrame.right, submenuFrame.bottom);
1070
+
1071
+ if (submenuFrame.origin.x < lastPosition.x) {
1072
+ // When the menu appears to the left, the min and max slopes need to be
1073
+ // flipped, because the "interior" of the slopes is counted clockwise
1074
+ // from the bottom slope to the top one, but on the left side
1075
+ // the interior needs to be counted counter-clockwise
1076
+ flipSlopes = true;
1077
+ }
1078
+
1079
+ return;
1080
+ }
1081
+
1082
+ // If the menu stops delaying events, remove the handler and restore regular event processing
1083
+ if (!this._delaysEvents) {
1084
+ resumeEvents();
1085
+ return;
1086
+ }
1087
+
1088
+ if (!active) {
1089
+ if (!itemFrame.intersectsPoint(currentPosition)) {
1090
+ // Upon leaving the menu item's frame, activate further processing
1091
+ active = YES;
1092
+
1093
+ // After a delay, if the submenu isn't focused, resume events
1094
+ timeout = window.setTimeout(resumeEvents, _BMMenuSubmenuTimeout);
1095
+ }
1096
+ else {
1097
+ return;
1098
+ }
1099
+ }
1100
+
1101
+ // If the mouse pointer enters the submenu's frame, cause it to acquire focus
1102
+ // and resume events
1103
+ if (submenuFrame.intersectsPoint(currentPosition)) {
1104
+ submenu._node.focus();
1105
+ resumeEvents();
1106
+ return;
1107
+ }
1108
+
1109
+ // Stop these events from propagating anywhere else
1110
+ event.preventDefault();
1111
+ event.stopPropagation();
1112
+
1113
+ // If the pointer doesn't actually move, ignore this event
1114
+ if (currentPosition.isEqualToPoint(lastPosition)) return;
1115
+
1116
+ // Get the slope angles to the top and bottom corners on the edge
1117
+ // closest to the current pointer's position
1118
+ const topSlope = currentPosition.slopeAngleToPoint(topPoint);
1119
+ const bottomSlope = currentPosition.slopeAngleToPoint(bottomPoint);
1120
+
1121
+ // Get the slope angle for the current movement, which indicates the direction
1122
+ // in which the pointer moves
1123
+ const currentSlope = lastPosition.slopeAngleToPoint(currentPosition);
1124
+
1125
+ const minSlope = Math.min(topSlope, bottomSlope);
1126
+ const maxSlope = Math.max(topSlope, bottomSlope);
1127
+
1128
+ // If the mouse moves away from the submenu, resume events
1129
+ // The pointer is considered to be moving towards the menu if the slope angle
1130
+ // of the line formed between the current and previous mouse positions
1131
+ // is between these two slope angles
1132
+ let isOutsideSlopeRange = flipSlopes ?
1133
+ currentSlope > minSlope && currentSlope < maxSlope :
1134
+ currentSlope < minSlope || currentSlope > maxSlope;
1135
+
1136
+ if (isOutsideSlopeRange) {
1137
+ resumeEvents();
1138
+ return;
1139
+ }
1140
+
1141
+ // Otherwise continue monitoring the pointer's position
1142
+ lastPosition = currentPosition;
1143
+ };
1144
+
1145
+ eventTarget.addEventListener(mouseMoveEvent, handler, {capture: YES});
1146
+ },
1147
+
1148
+ /**
1149
+ * Dismisses this menu, if it is visible.
1150
+ * @param animated <Boolean, nullable> Defaults to `YES`. If set to `YES`, this change will be animated.
1151
+ * If this method is invoked from within an animation context, the value of this parameter is ignored
1152
+ * and the values of the current animation context are used.
1153
+ * {
1154
+ * @param withSupermenu <Boolean, nullable> Defaults to `NO`. If set to `YES` and this menu is displayed as a submenu, its supermenus
1155
+ * will also close.
1156
+ * }
579
1157
  */
580
- closeAnimated(animated = YES) {
1158
+ closeAnimated(animated = YES, {withSupermenu = NO} = {}) {
1159
+ // If the menu isn't visible this action has no effect.
581
1160
  if (!this._node) return;
582
1161
 
1162
+ // If any submenu is displayed, dismiss it as well
1163
+ if (this._submenu) {
1164
+ this._submenu._isSupermenuClosing = YES;
1165
+ this._submenu.closeAnimated(animated);
1166
+ }
1167
+
1168
+ // Signal the delegate that this menu is about to close
583
1169
  if (this.delegate && this.delegate.menuWillClose) {
584
1170
  this.delegate.menuWillClose(this);
585
1171
  }
586
1172
 
1173
+ if (withSupermenu && this._supermenu) {
1174
+ this._supermenu.closeAnimated(animated, {withSupermenu});
1175
+ }
1176
+
1177
+ // If the menu was delaying events, restore event processing for
1178
+ // when it reopens
1179
+ this._delaysEvents = NO;
1180
+
1181
+ // Restore focus to whichever node had it prior to this menu opening
587
1182
  if (this._previouslyActiveNode) {
588
1183
  this._previouslyActiveNode.focus();
589
1184
  }
590
1185
 
1186
+ const supermenu = this._supermenu;
1187
+ // If this is a submenu, clear the parent menu's submenu
1188
+ if (supermenu) {
1189
+ supermenu._submenu = undefined;
1190
+ this._supermenu = undefined;
1191
+
1192
+ // Allow the supermenu items to receive input again
1193
+ if (supermenu._node && !this._isSupermenuClosing) {
1194
+ __BMVelocityAnimate(supermenu._node, {scaleX: 1, scaleY: 1}, {duration: 200, easing: 'easeInOutQuad'}, BMMENU_USE_WEB_ANIMATIONS);
1195
+
1196
+ supermenu._node.classList.remove('BMMenuTouchSubmenu');
1197
+ supermenu._node.classList.remove('BMMenuInactive');
1198
+ }
1199
+ }
1200
+
1201
+ // Prevent interaction with the menu while its close animation is running
591
1202
  this._node.style.pointerEvents = 'none';
592
- this._containerNode.style.pointerEvents = 'none';
593
- let delay = this._node.childNodes.length * 16 + 100 - 200;
594
- delay = (delay < 0 ? 0 : delay) + 200;
1203
+ // this._node.inert = true;
1204
+
1205
+ if (!supermenu) {
1206
+ this._containerNode.style.pointerEvents = 'none';
1207
+ // this._containerNode.inert = true;
1208
+ }
1209
+
1210
+ this._frame = undefined;
1211
+
1212
+ // Delay the actual menu node closing animation to allow time
1213
+ // for the menu items to run their animations
1214
+ let delay = this._node.childNodes.length * 16;
1215
+ delay = (delay < 0 ? 0 : delay);
1216
+
1217
+ // If an item was clicked, further delay closing to allow the selection animation to play
1218
+ const delaysClosing = this._delaysClosing;
1219
+ this._delaysClosing = NO;
1220
+ if (delaysClosing && !this._sourceNodeShadow) {
1221
+ delay += 200;
1222
+ }
595
1223
 
596
1224
  const sourceNodeShadow = this._sourceNodeShadow;
597
1225
  const containerNode = this._containerNode;
1226
+ const node = this._node;
598
1227
  const sourceNode = this._sourceNode;
599
1228
 
600
1229
  this._sourceNode = undefined;
601
1230
  this._sourceNodeShadow = undefined;
602
1231
 
1232
+ // Clear the references to the items' nodes
1233
+ this._items.forEach(item => item._node = undefined);
1234
+
1235
+ // Adjust the animation parameters depending on whether there is a node shadow displaying
1236
+ // alongside the menu
603
1237
  const scale = sourceNodeShadow ? .33 : .75;
604
1238
  const duration = sourceNodeShadow ? 200 : 400;
605
1239
  const easing = sourceNodeShadow ? 'easeInOutQuad' : 'easeInQuad';
606
1240
 
607
1241
  if (sourceNodeShadow) {
608
- __BMVelocityAnimate(sourceNodeShadow, {translateY: '0px'}, {duration: duration, easing: easing, delay});
609
- __BMVelocityAnimate(containerNode, {opacity: 0}, {duration: duration, easing: easing, delay});
1242
+ // If a node shadow is shown, animate it back towards its original node
1243
+ const sourceNodeProperties = {translateY: 0, translateX: 0, scaleX: 1, scaleY: 1};
1244
+ if (this._isSupermenuClosing) {
1245
+ sourceNodeProperties.opacity = 0;
1246
+ }
1247
+
1248
+ __BMVelocityAnimate(sourceNodeShadow, sourceNodeProperties, {duration: duration, easing: easing, delay}, BMMENU_USE_WEB_ANIMATIONS);
1249
+ if (!supermenu) {
1250
+ // Do not modify the container node if this is a submenu as it is owned by the parent menu
1251
+ __BMVelocityAnimate(containerNode, {opacity: 0}, {duration: duration, easing: easing, delay}, BMMENU_USE_WEB_ANIMATIONS);
1252
+ }
610
1253
  }
611
1254
 
1255
+ this._isSupermenuClosing = NO;
1256
+
612
1257
  __BMVelocityAnimate(this._node, {
613
1258
  scaleX: this._kind == BMMenuKind.PullDownMenu ? 1 : scale,
614
1259
  scaleY: scale,
615
- opacity: sourceNodeShadow ? 1 : 0,
1260
+ opacity: (sourceNodeShadow && !supermenu) ? 1 : 0,
616
1261
  translateY: sourceNodeShadow ? -this._sourceNodeDisplacement + 'px' : '0px',
1262
+ translateX: sourceNodeShadow ? -this._sourceNodeHorizontalDisplacement + 'px' : '0px',
617
1263
  translateZ: 0
618
1264
  }, {
619
1265
  duration: 200,
620
1266
  easing: easing,
621
1267
  delay: delay,
622
1268
  complete: _ => {
623
- containerNode.remove();
1269
+ if (!supermenu) {
1270
+ // Do not modify the container node if this is a submenu as it is owned by the parent menu
1271
+ containerNode.remove();
1272
+ }
1273
+ else {
1274
+ node.remove();
1275
+ }
624
1276
  if (sourceNodeShadow) {
625
1277
  sourceNodeShadow.remove();
626
1278
  sourceNode.classList.remove(BMMenuSourceNodeCSSClass);
@@ -629,16 +1281,22 @@ BMMenu.prototype = {
629
1281
  this.delegate.menuDidClose(this);
630
1282
  }
631
1283
  }
632
- });
633
-
634
- delay = 200;
1284
+ }, BMMENU_USE_WEB_ANIMATIONS);
1285
+
1286
+ // Run the item animation in reverse order
1287
+ delay = 0;
1288
+ if (delaysClosing && !sourceNodeShadow) {
1289
+ // Delay this animation to allow the selection animation to run
1290
+ // if an item was selected
1291
+ delay = 200;
1292
+ }
635
1293
  for (let i = this._node.childNodes.length - 1; i >= 0; i--) {
636
1294
  let child = this._node.childNodes[i];
637
1295
  __BMVelocityAnimate(child, {translateY: '16px', translateZ: 0, opacity: 0}, {
638
1296
  duration: 100,
639
1297
  easing: 'easeInQuad',
640
1298
  delay: delay
641
- });
1299
+ }, BMMENU_USE_WEB_ANIMATIONS);
642
1300
  delay += 16;
643
1301
  }
644
1302
 
@@ -652,7 +1310,19 @@ BMMenu.prototype = {
652
1310
  */
653
1311
  arrowUpPressedWithEvent(event) {
654
1312
  if (this._highlightedIndex != 0) {
655
- this.highlightedIndex = BMNumberByConstrainingNumberToBounds(this._highlightedIndex - 1, 0, this._items.length - 1);
1313
+ // Find the previous index that may be highlighted
1314
+ let previousIndex = this._highlightedIndex;
1315
+ while (YES) {
1316
+ previousIndex = BMNumberByConstrainingNumberToBounds(previousIndex - 1, 0, this._items.length - 1);
1317
+
1318
+ // If item at the previous index isn't a separator, highlight it
1319
+ if (!this._items[previousIndex]._isSeparator) break;
1320
+
1321
+ // If the start of the items array has been reached and a viable highlightable item hasn't
1322
+ // been found, don't take any action
1323
+ if (previousIndex == 0) return;
1324
+ }
1325
+ this.highlightedIndex = previousIndex;
656
1326
  }
657
1327
  },
658
1328
 
@@ -662,26 +1332,81 @@ BMMenu.prototype = {
662
1332
  */
663
1333
  arrowDownPressedWithEvent(event) {
664
1334
  if (this._highlightedIndex != this._items.length - 1) {
665
- this.highlightedIndex = BMNumberByConstrainingNumberToBounds(this._highlightedIndex + 1, 0, this._items.length - 1);
1335
+ // Find the next index that may be highlighted
1336
+ let nextIndex = this._highlightedIndex;
1337
+ while (YES) {
1338
+ nextIndex = BMNumberByConstrainingNumberToBounds(nextIndex + 1, 0, this._items.length - 1);
1339
+
1340
+ // If item at the next index isn't a separator, highlight it
1341
+ if (!this._items[nextIndex]._isSeparator) break;
1342
+
1343
+ // If the end of the items array has been reached and a viable highlightable item hasn't
1344
+ // been found, don't take any action
1345
+ if (nextIndex == this._items.length - 1) return;
1346
+ }
1347
+ this.highlightedIndex = nextIndex;
1348
+ }
1349
+ },
1350
+
1351
+ /**
1352
+ * Invoked when the right arrow is pressed.
1353
+ * @param event <KeyboardEvent> The event that triggered this action.
1354
+ */
1355
+ arrowRightPressedWithEvent(event) {
1356
+ const item = this._items[this._highlightedIndex];
1357
+ if (!item) return;
1358
+
1359
+ // If the currently focused item has a submenu, open it and cause it
1360
+ // to acquire keyboard focus
1361
+ if (item._submenu) {
1362
+ this._openSubmenuForMenuItem(item, {delayEvents: NO, acquireFocus: YES});
1363
+ }
1364
+ },
1365
+
1366
+ /**
1367
+ * Invoked when the left arrow is pressed.
1368
+ * @param event <KeyboardEvent> The event that triggered this action.
1369
+ */
1370
+ arrowLeftPressedWithEvent(event) {
1371
+ // If this menu is displayed as a submenu, close it and restore focus
1372
+ // back to the supermenu
1373
+ if (this._supermenu) {
1374
+ this.closeAnimated(YES);
666
1375
  }
667
1376
  },
668
1377
 
1378
+ /**
1379
+ * Invoked when the return or spacebar key is pressed.
1380
+ * @param event <KeyboardEvent> The event that triggered this action.
1381
+ */
669
1382
  returnPressedWithEvent(event) {
670
- if (this._highlightedIndex >= 0 && this._highlightedIndex < this._items.length - 1) {
1383
+ if (this._highlightedIndex >= 0 && this._highlightedIndex < this._items.length) {
671
1384
  const item = this._items[this._highlightedIndex];
672
1385
 
1386
+ // Ask the delegate if this menu item should be selected
1387
+ let shouldSelect = YES;
1388
+ if (this.delegate && this.delegate.menuShouldSelectItem) {
1389
+ shouldSelect = this.delegate.menuShouldSelectItem(this, item);
1390
+ }
1391
+
1392
+ if (!shouldSelect) return;
1393
+
1394
+ // For desktop menus, delay closing slightly to allow the selection
1395
+ // animation to play out
1396
+ this._delaysClosing = YES;
1397
+
673
1398
  if (item.action) item.action(item);
674
1399
 
675
1400
  if (this.delegate && this.delegate.menuDidSelectItem) {
676
1401
  this.delegate.menuDidSelectItem(this, item);
677
1402
  }
678
1403
 
679
- const highlightedNode = this._node.querySelector('.BMMenuItemHighlighted');
1404
+ const highlightedNode = item._node;
680
1405
  if (highlightedNode) {
681
1406
  highlightedNode.classList.add('BMMenuItemActive');
682
1407
  }
683
1408
 
684
- this.closeAnimated(YES);
1409
+ this.closeAnimated(YES, {withSupermenu: YES});
685
1410
  }
686
1411
  },
687
1412