bm-core-ui 2.12.0-beta.13 → 2.12.0-beta.15

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.
@@ -6,7 +6,7 @@ import {BMHook, __BMVelocityAnimate} from '../Core/BMAnimationContext'
6
6
  import 'velocity-animate'
7
7
  import { BMRect, BMRectMake, BMRectMakeWithNodeFrame, BMRectMakeWithOrigin } from '../Core/BMRect';
8
8
  import { BMView } from './BMView_v2.5';
9
- import { BMKeyboardShortcut } from '../BMWindow/BMKeyboardShortcut';
9
+ import { BMKeyboardShortcut, BMKeyboardShortcutModifier } from '../BMWindow/BMKeyboardShortcut';
10
10
  import { BMSizeMake } from '../Core/BMSize';
11
11
 
12
12
  /**
@@ -111,21 +111,33 @@ BMMenuItem.prototype = {
111
111
  return this._CSSClass;
112
112
  },
113
113
  set CSSClass(cls) {
114
+ if (!this._replacementItem) {
115
+ this._updateCSSClass(this._CSSClass, {toClass: cls});
116
+ }
117
+ this._CSSClass = cls || '';
118
+ },
119
+
120
+ /**
121
+ * Updates the CSS class on this item's node.
122
+ * @param fromClass <String> The currently applied CSS classes.
123
+ * {
124
+ * @param toClass <String> The new classes to apply.
125
+ * }
126
+ */
127
+ _updateCSSClass(fromClass, {toClass}) {
114
128
  // If this menu item is rendered, apply the class to its node
115
129
  let node = this._node;
116
130
  if (node) {
117
131
  // First remove the previous classes, if they exist
118
- if (this._CSSClass) {
119
- this._CSSClass.split(' ').forEach(cls => node.classList.remove(cls));
132
+ if (fromClass) {
133
+ fromClass.split(' ').forEach(cls => node.classList.remove(cls));
120
134
  }
121
135
 
122
136
  // Then add the new classes, if they exist
123
- if (cls) {
124
- cls.split(' ').forEach(cls => node.classList.add(cls));
137
+ if (toClass) {
138
+ toClass.split(' ').forEach(cls => node.classList.add(cls));
125
139
  }
126
140
  }
127
-
128
- this._CSSClass = cls || '';
129
141
  },
130
142
 
131
143
  /**
@@ -149,6 +161,16 @@ BMMenuItem.prototype = {
149
161
  },
150
162
  set hintHTML(hint) {
151
163
  this._hintHTML = hint;
164
+ if (!this._replacementItem) {
165
+ this._setHintHTML(hint);
166
+ }
167
+ },
168
+
169
+ /**
170
+ * Updates this item's HTML hint node with the specified hint.
171
+ * @param hint <String> The hint to use.
172
+ */
173
+ _setHintHTML(hint) {
152
174
  if (this._node) {
153
175
  const hintContainer = this._node.querySelector('.BMMenuItemHint');
154
176
  if (hintContainer) {
@@ -163,6 +185,18 @@ BMMenuItem.prototype = {
163
185
  */
164
186
  _node: undefined, // <DOMNode, nullable>
165
187
 
188
+ /**
189
+ * The node that represents this menu item's icon, available while its menu
190
+ * is open.
191
+ */
192
+ _iconNode: undefined, // <DOMNode, nullable>
193
+
194
+ /**
195
+ * The node that represents this menu item's title, available while its menu
196
+ * is open.
197
+ */
198
+ _nameNode: undefined, // <DOMNode, nullable>
199
+
166
200
  /**
167
201
  * Defaults to `YES`. Controls whether this menu item is enabled. If this menu
168
202
  * item is not enabled, it can not be highlighted or selected.
@@ -183,6 +217,16 @@ BMMenuItem.prototype = {
183
217
 
184
218
  this._enabled = enabled;
185
219
 
220
+ if (!this._replacementItem) {
221
+ this._setEnabled(enabled);
222
+ }
223
+ },
224
+
225
+ /**
226
+ * Updates the enabled state of this menu item's node.
227
+ * @param enabled <Boolean> Whether the item is enabled.
228
+ */
229
+ _setEnabled(enabled) {
186
230
  if (this._node) {
187
231
  /** @type {HTMLElement} */ const node = this._node;
188
232
  if (enabled) {
@@ -195,6 +239,89 @@ BMMenuItem.prototype = {
195
239
  },
196
240
 
197
241
  /**
242
+ * The currently active replacement menu item, if any.
243
+ */
244
+ _replacementItem: undefined, // <BMMenuItem, nullable>
245
+
246
+ /**
247
+ * Causes this menu item to instead display the specified menu item instead.
248
+ * @param item <BMMenuItem, nullable> The replacement menu item, or `undefined` to restore
249
+ * this menu item.
250
+ * @returns <Boolean> `YES` if a replacement occurred, `NO` otherwise.
251
+ */
252
+ _setReplacementItem(item) {
253
+ if (this._replacementItem == item) {
254
+ return NO;
255
+ }
256
+
257
+ const previousReplacementItem = this._replacementItem || this;
258
+ this._replacementItem = item;
259
+ if (!this._node) {
260
+ return YES;
261
+ }
262
+
263
+ if (!item) {
264
+ item = this;
265
+ }
266
+
267
+ // Update the display of this menu item to the specified replacement item
268
+ if (this._iconNode) {
269
+ this._iconNode.src = item.icon;
270
+ }
271
+
272
+ this._nameNode.innerText = item.name;
273
+ this._setEnabled(item.enabled);
274
+ this._setHintHTML(item.hintHTML);
275
+ this._updateCSSClass(previousReplacementItem.CSSClass, item.CSSClass);
276
+
277
+ return YES;
278
+ },
279
+
280
+ /**
281
+ * Contains the replacement menu items and their modifiers.
282
+ */
283
+ _overrideMenuItems: undefined, // <Object<string, {item: BMMenuItem, priority: number}>>
284
+
285
+ /**
286
+ * Registers a menu item that will replace this menu item in an open menu when the
287
+ * specified keyboard modifiers are held.
288
+ * @param item <BMMenuItem> The replacement menu item to add.
289
+ * {
290
+ * @param forModifiers <[BMKeyboardShortcutModifier]> An array of modifiers that must be held for the
291
+ * replacement item to be selected.
292
+ * }
293
+ */
294
+ addOverrideMenuItem(item, {forModifiers: modifiers}) {
295
+ if (!modifiers.length) {
296
+ return;
297
+ }
298
+
299
+ let bitmap = 0;
300
+ for (const modifier of modifiers) {
301
+ bitmap = bitmap | modifier.value;
302
+ }
303
+
304
+ this._overrideMenuItems[bitmap] = {item, priority: modifiers.length};
305
+ },
306
+
307
+ /**
308
+ * Unregisters the replacement menu item when the specified keyboard modifiers are held.
309
+ * Has no effect if a replacement menu item was not set for the specified modifiers.
310
+ * @param modifiers <[BMKeyboardShortcutModifier]> The keyboard modifiers.
311
+ */
312
+ removeOverrideMenuItemForModifiers(modifiers) {
313
+ let bitmap = 0;
314
+ for (const modifier of modifiers) {
315
+ bitmap = bitmap | modifier.value;
316
+ }
317
+
318
+ if (this._overrideMenuItems[bitmap]) {
319
+ delete this._overrideMenuItems[bitmap];
320
+ }
321
+ },
322
+
323
+ /**
324
+ * Designated initializer.
198
325
  * Constructs and returns a menu item with the given name.
199
326
  * Optionally, the menu item may have an icon associated with it as well as an
200
327
  * action that is invoked when the item is selected.
@@ -202,7 +329,7 @@ BMMenuItem.prototype = {
202
329
  * {
203
330
  * @param icon <String, nullable> If specified, this represents the URL to an icon associated with this menu item.
204
331
  * For most menus, icons will only be displayed if all of its menu items have icons defined.
205
- *
332
+ *
206
333
  * @param action <void ^ (BMMenuItem), nullable> If specified, this is a function that will be invoked when this menu item is selected.
207
334
  * This function will receive the selected menu item as a parameter.
208
335
  * @param enabled <Boolean, nullable> Defaults to `YES`. Controls whether this item is enabled and can be selected.
@@ -212,6 +339,8 @@ BMMenuItem.prototype = {
212
339
  * @return <BMMenuItem> This menu item.
213
340
  */
214
341
  initWithName(name, {icon, action, submenu, enabled = true, userInfo} = {}) {
342
+ this._overrideMenuItems = {};
343
+
215
344
  this._name = name;
216
345
  this._icon = icon;
217
346
  this._action = action;
@@ -238,7 +367,7 @@ BMMenuItem.prototype = {
238
367
  * {
239
368
  * @param icon <String, nullable> If specified, this represents the URL to an icon associated with this menu item.
240
369
  * For most menus, icons will only be displayed if all of its menu items have icons defined.
241
- *
370
+ *
242
371
  * @param action <void ^ (BMMenuItem), nullable> If specified, this is a function that will be invoked when this menu item is selected.
243
372
  * This function will receive the selected menu item as a parameter.
244
373
  * @param enabled <Boolean, nullable> Defaults to `YES`. Controls whether this item is enabled and can be selected.
@@ -469,9 +598,9 @@ BMMenu.prototype = {
469
598
  /**
470
599
  * Initializes this menu with the specified items.
471
600
  * The menu will be hidden by default; the method showAtPoint(_) should be used to make the menu visible.
472
- *
601
+ *
473
602
  * @param items <[BMMenuItem]> An array of menu items that will be displayed by this menu.
474
- *
603
+ *
475
604
  * @return <BMMenu> A menu.
476
605
  */
477
606
  initWithItems(items) {
@@ -480,6 +609,76 @@ BMMenu.prototype = {
480
609
  return this;
481
610
  },
482
611
 
612
+ /**
613
+ * The current modifier bitmap.
614
+ */
615
+ __modifierBitmap: 0, // <Number>
616
+
617
+ get _modifierBitmap() {
618
+ return this._modifierBitmap;
619
+ },
620
+
621
+ set _modifierBitmap(bitmap) {
622
+ this.__modifierBitmap = bitmap;
623
+
624
+ let changeOccurred = NO;
625
+
626
+ for (const item of this._items) {
627
+ if (!item._node) {
628
+ continue;
629
+ }
630
+
631
+ if (!bitmap) {
632
+ changeOccurred = item._setReplacementItem(undefined) || changeOccurred;
633
+ }
634
+
635
+ let replacementPriority = 0;
636
+ let replacementItem;
637
+ for (const replacementBitmap in item._overrideMenuItems) {
638
+ const modifierBitmap = parseInt(replacementBitmap);
639
+ if ((modifierBitmap & bitmap) != modifierBitmap) {
640
+ continue;
641
+ }
642
+
643
+ if (item._overrideMenuItems[replacementBitmap].priority > replacementPriority) {
644
+ replacementPriority = item._overrideMenuItems[replacementBitmap].priority;
645
+ replacementItem = item._overrideMenuItems[replacementBitmap].item;
646
+ }
647
+ }
648
+
649
+ changeOccurred = item._setReplacementItem(replacementItem) || changeOccurred;
650
+ }
651
+
652
+ if (changeOccurred && this._node && this._rect) {
653
+ this._setAnchorRect(this._rect);
654
+ }
655
+ },
656
+
657
+
658
+
659
+ /**
660
+ * Invoked to handle the specified keyboard event.
661
+ * @param event <KeyboardEvent> The event that triggered this action.
662
+ */
663
+ handleEvent(event) {
664
+ this._updateModifierBitmapWithEvent(event);
665
+ },
666
+
667
+ /**
668
+ * Updates the modifier bitmap based on the specified event.
669
+ * @param event <Event> The event describing the currently pressed modifier keys.
670
+ */
671
+ _updateModifierBitmapWithEvent(event) {
672
+ let bitmap = 0;
673
+ for (const modifier in BMKeyboardShortcutModifier) {
674
+ if (event[BMKeyboardShortcutModifier[modifier].key]) {
675
+ bitmap = bitmap | BMKeyboardShortcutModifier[modifier].value;
676
+ }
677
+ }
678
+
679
+ this._modifierBitmap = bitmap;
680
+ },
681
+
483
682
  /**
484
683
  * Invoked by CoreUI to build the menu node.
485
684
  */
@@ -555,6 +754,15 @@ BMMenu.prototype = {
555
754
  shortcut._menuItem = item;
556
755
  BMView.registerKeyboardShortcut(shortcut, {forNode: menuNode});
557
756
  }
757
+
758
+ for (const key in item._overrideMenuItems) {
759
+ const replacementItem = item._overrideMenuItems[key].item;
760
+ if (replacementItem.keySequence) {
761
+ const shortcut = BMKeyboardShortcut.keyboardShortcutWithKeySequence(replacementItem.keySequence, {target: this, action: '_selectMenuItemWithEvent'});
762
+ shortcut._menuItem = item;
763
+ BMView.registerKeyboardShortcut(shortcut, {forNode: menuNode});
764
+ }
765
+ }
558
766
  }
559
767
 
560
768
  // The overlay which intercepts clicks outside of the menu
@@ -563,6 +771,10 @@ BMMenu.prototype = {
563
771
  menuContainer.className = 'BMMenuContainer';
564
772
  }
565
773
 
774
+ // Register the modifier listener that sets replacement items on each item
775
+ menuContainer.addEventListener('keydown', this, {passive: true});
776
+ menuContainer.addEventListener('keyup', this, {passive: true});
777
+
566
778
  if (!('backdropFilter' in document.body.style) && !('webkitBackdropFilter' in document.body.style)) {
567
779
  menuNode.style.backgroundColor = 'white';
568
780
  }
@@ -634,6 +846,9 @@ BMMenu.prototype = {
634
846
  return;
635
847
  }
636
848
 
849
+ // Clear out any previously set replacement item
850
+ item._setReplacementItem(undefined);
851
+
637
852
  // Create the item node
638
853
  let itemNode = document.createElement('div');
639
854
  itemNode.className = 'BMMenuItem';
@@ -649,12 +864,18 @@ BMMenu.prototype = {
649
864
  height: iconSize + 'px'
650
865
  });
651
866
  itemNode.appendChild(icon);
867
+
868
+ item._iconNode = icon;
869
+ }
870
+ else {
871
+ item._iconNode = undefined;
652
872
  }
653
873
 
654
874
  // Create the label
655
875
  const itemText = document.createElement('span');
656
876
  itemText.innerText = item.name;
657
877
  itemNode.appendChild(itemText);
878
+ item._nameNode = itemText;
658
879
 
659
880
  // Create the hint container
660
881
  const hint = document.createElement('div');
@@ -674,7 +895,7 @@ BMMenu.prototype = {
674
895
  if (!item._enabled) {
675
896
  itemNode.classList.add('BMMenuItemDisabled');
676
897
  }
677
-
898
+
678
899
  // TWhen clicking an item, trigger its action, if it has one
679
900
  itemNode.addEventListener('click', event => {
680
901
  // Disabled items can't be clicked
@@ -694,7 +915,7 @@ BMMenu.prototype = {
694
915
  // Ask the delegate if this menu item should be selected
695
916
  let shouldSelect = YES;
696
917
  if (this.delegate && this.delegate.menuShouldSelectItem) {
697
- shouldSelect = this.delegate.menuShouldSelectItem(this, item);
918
+ shouldSelect = this.delegate.menuShouldSelectItem(this, item._replacementItem ?? item);
698
919
  }
699
920
 
700
921
  if (!shouldSelect) {
@@ -703,6 +924,9 @@ BMMenu.prototype = {
703
924
  return;
704
925
  }
705
926
 
927
+ // Allow selection to go through when events are suppressed
928
+ this._delaysEvents = NO;
929
+
706
930
  // For desktop menus, delay closing slightly to allow the selection
707
931
  // animation to play out
708
932
  this._delaysClosing = YES;
@@ -756,9 +980,14 @@ BMMenu.prototype = {
756
980
  * @param item <BMMenuItem> The menu item whose action should be performed.
757
981
  */
758
982
  _performActionForMenuItem(item) {
759
- item.action?.(item);
983
+ if (item._replacementItem?.action) {
984
+ item._replacementItem.action(item._replacementItem);
985
+ }
986
+ else {
987
+ item.action?.(item._replacementItem ?? item);
988
+ }
760
989
 
761
- this.delegate?.menuDidSelectItem?.(this, item);
990
+ this.delegate?.menuDidSelectItem?.(this, item._replacementItem ?? item);
762
991
  },
763
992
 
764
993
  /**
@@ -774,12 +1003,14 @@ BMMenu.prototype = {
774
1003
  * @param animated <Boolean, nullable> Defaults to `NO`. If set to `YES`, this change will be animated.
775
1004
  * If this method is invoked from within an animation context, the value of this parameter is ignored
776
1005
  * and the values of the current animation context are used.
777
- *
1006
+ *
778
1007
  * @param kind <BMMenuKind, nullable> Defaults to `Menu`. The kind of menu.
779
1008
  * }
780
1009
  * @return <Promise<void>> A promise that resolves when the operation completes.
781
1010
  */
782
1011
  async openFromNode(node, {animated = NO, kind = BMMenuKind.Menu} = {}) {
1012
+ this._rect = undefined;
1013
+
783
1014
  // Signal the delegate that this menu is about to open
784
1015
  if (this.delegate && this.delegate.menuWillOpen) {
785
1016
  this.delegate.menuWillOpen(this);
@@ -897,7 +1128,7 @@ BMMenu.prototype = {
897
1128
  BMCopyProperties(menuNode.style, {left: point.x + 'px', top: point.y + 'px'});
898
1129
 
899
1130
  this._frame = BMRectMakeWithOrigin(point, {size: BMSizeMake(menuWidth, menuHeight)});
900
-
1131
+
901
1132
  // Prepare the initial state of the animation
902
1133
  if (!this._supermenu) {
903
1134
  BMHook(menuContainer, {opacity: 0});
@@ -906,8 +1137,8 @@ BMMenu.prototype = {
906
1137
  BMHook(menuNode, {opacity: 0});
907
1138
  }
908
1139
  BMHook(menuNode, {
909
- scaleX: kind == BMMenuKind.PullDownMenu ? pullDownScale : scale,
910
- scaleY: kind == BMMenuKind.PullDownMenu ? pullDownScale : scale,
1140
+ scaleX: kind == BMMenuKind.PullDownMenu ? pullDownScale : scale,
1141
+ scaleY: kind == BMMenuKind.PullDownMenu ? pullDownScale : scale,
911
1142
  translateY: -displacement + 'px',
912
1143
  translateX: -horizontalDisplacement + 'px',
913
1144
  });
@@ -923,7 +1154,7 @@ BMMenu.prototype = {
923
1154
 
924
1155
  // Make the menu expand
925
1156
  itemsContainerNode.style.overflow = 'hidden';
926
-
1157
+
927
1158
  __BMVelocityAnimate(menuNode, {opacity: 1}, {
928
1159
  duration: duration / 2,
929
1160
  // Use a different easing for the opacity so that it doesn't overshoot the 1 limit
@@ -942,7 +1173,7 @@ BMMenu.prototype = {
942
1173
  // Animate each child node in
943
1174
  let delay = 50;
944
1175
  let delayIncrement = Math.min(16, 150 / itemsContainerNode.childNodes.length);
945
-
1176
+
946
1177
  for (let i = 0; i < itemsContainerNode.childNodes.length; i++) {
947
1178
  const child = itemsContainerNode.childNodes[i];
948
1179
  BMHook(child, {translateY: '16px', translateZ: 0, opacity: 0});
@@ -965,14 +1196,19 @@ BMMenu.prototype = {
965
1196
 
966
1197
  // Animate the source node shadow
967
1198
  await __BMVelocityAnimate(
968
- this._sourceNodeShadow,
969
- {translateY: displacement + 'px', translateX: horizontalDisplacement + 'px', scaleX: sourceNodeScale, scaleY: sourceNodeScale},
1199
+ this._sourceNodeShadow,
1200
+ {translateY: displacement + 'px', translateX: horizontalDisplacement + 'px', scaleX: sourceNodeScale, scaleY: sourceNodeScale},
970
1201
  {duration, easing},
971
1202
  BMMENU_USE_WEB_ANIMATIONS
972
1203
  );
973
1204
 
974
1205
  },
975
1206
 
1207
+ /**
1208
+ * The rect around which this menu is positioned.
1209
+ */
1210
+ _rect: undefined, // <BMRect, nullable>
1211
+
976
1212
  /**
977
1213
  * Animatable. Shows this menu at the specified point. The coordinates of this point are relative to the viewport.
978
1214
  * @param point <BMPoint> The point at which to show this menu.
@@ -980,7 +1216,7 @@ BMMenu.prototype = {
980
1216
  * @param animated <Boolean, nullable> Defaults to `NO`. If set to `YES`, this change will be animated.
981
1217
  * If this method is invoked from within an animation context, the value of this parameter is ignored
982
1218
  * and the values of the current animation context are used.
983
- *
1219
+ *
984
1220
  * @param kind <BMMenuKind, nullable> Defaults to `Menu`. The kind of menu.
985
1221
  * }
986
1222
  */
@@ -988,48 +1224,21 @@ BMMenu.prototype = {
988
1224
  // Create a 0-by-0 rect at the given point and open the menu around it
989
1225
  const rect = BMRectMakeWithOrigin(point, {size: BMSizeMake()});
990
1226
 
1227
+ this._rect = rect;
991
1228
  return this._openAroundRect(rect, arguments[1]);
992
1229
  },
993
1230
 
994
1231
  /**
995
- * Shows this menu at an appropriate position around the given rect. The coordinates of this rect
996
- * are relative to the viewport.
997
- * @param rect <BMRect> The rect around which to show this menu.
998
- * {
999
- * @param animated <Boolean, nullable> Defaults to `NO`. If set to `YES`, this change will be animated.
1000
- * If this method is invoked from within an animation context, the value of this parameter is ignored
1001
- * and the values of the current animation context are used.
1002
- *
1003
- * @param kind <BMMenuKind, nullable> Defaults to `Menu`. The kind of menu.
1232
+ * Updates this menu's position around the specified rect.
1233
+ * @param rect <BMRect> The rect around which to position this menu.
1004
1234
  */
1005
- _openAroundRect(rect, {animated = NO, kind = BMMenuKind.Menu} = {}) {
1006
- // If the menu is already open, do nothing
1007
- if (this._node) return;
1008
-
1009
- // Signal the delegate that this menu is about to open
1010
- if (this.delegate && this.delegate.menuWillOpen) {
1011
- this.delegate.menuWillOpen(this);
1012
- }
1013
-
1014
- // Build the DOM structure
1015
- this._renderMenu();
1235
+ _setAnchorRect(rect) {
1236
+ this._rect = rect;
1016
1237
 
1017
1238
  const menuNode = this._node;
1018
1239
  const itemsContainerNode = this._itemsContainerNode;
1019
- const menuContainer = this._containerNode;
1020
-
1021
- this._kind = kind;
1022
-
1023
- // Prepare the initial state of the animation
1024
- BMHook(menuNode, {
1025
- scaleX: kind == BMMenuKind.PullDownMenu ? 1 : .75,
1026
- scaleY: kind == BMMenuKind.PullDownMenu ? .5 : .75,
1027
- opacity: 0
1028
- });
1029
1240
 
1030
- if (!this._supermenu) {
1031
- document.body.appendChild(menuContainer);
1032
- }
1241
+ const kind = this._kind;
1033
1242
 
1034
1243
  // Get the menu's metrics to determine where it fits best
1035
1244
  const height = menuNode.offsetHeight;
@@ -1090,6 +1299,58 @@ BMMenu.prototype = {
1090
1299
  menuNode.style.left = point.x + 'px';
1091
1300
  menuNode.style.top = point.y + 'px';
1092
1301
 
1302
+ // If any submenus are open, update their positions as well
1303
+ if (this._submenu) {
1304
+ const itemNode = this._submenu._sourceItem._node;
1305
+ if (itemNode) {
1306
+ const frame = BMRectMakeWithNodeFrame(itemNode);
1307
+ this._submenu._setAnchorRect(frame);
1308
+ }
1309
+ }
1310
+ },
1311
+
1312
+ /**
1313
+ * Shows this menu at an appropriate position around the given rect. The coordinates of this rect
1314
+ * are relative to the viewport.
1315
+ * @param rect <BMRect> The rect around which to show this menu.
1316
+ * {
1317
+ * @param animated <Boolean, nullable> Defaults to `NO`. If set to `YES`, this change will be animated.
1318
+ * If this method is invoked from within an animation context, the value of this parameter is ignored
1319
+ * and the values of the current animation context are used.
1320
+ *
1321
+ * @param kind <BMMenuKind, nullable> Defaults to `Menu`. The kind of menu.
1322
+ */
1323
+ _openAroundRect(rect, {animated = NO, kind = BMMenuKind.Menu} = {}) {
1324
+ // If the menu is already open, do nothing
1325
+ if (this._node) return;
1326
+
1327
+ // Signal the delegate that this menu is about to open
1328
+ if (this.delegate && this.delegate.menuWillOpen) {
1329
+ this.delegate.menuWillOpen(this);
1330
+ }
1331
+
1332
+ // Build the DOM structure
1333
+ this._renderMenu();
1334
+
1335
+ const menuNode = this._node;
1336
+ const itemsContainerNode = this._itemsContainerNode;
1337
+ const menuContainer = this._containerNode;
1338
+
1339
+ this._kind = kind;
1340
+
1341
+ // Prepare the initial state of the animation
1342
+ BMHook(menuNode, {
1343
+ scaleX: kind == BMMenuKind.PullDownMenu ? 1 : .75,
1344
+ scaleY: kind == BMMenuKind.PullDownMenu ? .5 : .75,
1345
+ opacity: 0
1346
+ });
1347
+
1348
+ if (!this._supermenu) {
1349
+ document.body.appendChild(menuContainer);
1350
+ }
1351
+
1352
+ this._setAnchorRect(rect);
1353
+
1093
1354
  // Animate the menu in
1094
1355
  itemsContainerNode.style.overflow = 'hidden';
1095
1356
  (window.Velocity || $.Velocity).animate(menuNode, {scaleX: 1, scaleY: 1, opacity: 1, translateZ: 0}, {
@@ -1157,6 +1418,7 @@ BMMenu.prototype = {
1157
1418
 
1158
1419
  // Render the submenu and set its delegate to this menu's delegate
1159
1420
  submenu._supermenu = this;
1421
+ submenu._sourceItem = item;
1160
1422
  this._submenu = submenu;
1161
1423
 
1162
1424
  // Open an appropriate menu kind based on this menu's kind
@@ -1196,9 +1458,6 @@ BMMenu.prototype = {
1196
1458
  let topPoint;
1197
1459
  let bottomPoint;
1198
1460
 
1199
- // Determines whether the slope needs to be between the min and max slopes or outside their range
1200
- let flipSlopes = false;
1201
-
1202
1461
  const eventTarget = this._containerNode;
1203
1462
  // Immediately block interactions with menu elements
1204
1463
  this._delaysEvents = YES;
@@ -1216,7 +1475,7 @@ BMMenu.prototype = {
1216
1475
  this._delaysEvents = NO;
1217
1476
 
1218
1477
  // Remove the mousemove event handler
1219
- eventTarget.removeEventListener(mouseMoveEvent, handler, {capture: YES});
1478
+ window.removeEventListener(mouseMoveEvent, handler, {capture: YES});
1220
1479
 
1221
1480
  // Allow pointer interaction with the menu nodes
1222
1481
  eventTarget.classList.remove('BMMenuContainerSubmenuActive');
@@ -1242,21 +1501,13 @@ BMMenu.prototype = {
1242
1501
  // On the first movement initialize the previous position
1243
1502
  // and top and bottom points relative to the pointer's position
1244
1503
  lastPosition = currentPosition;
1245
- topPoint = submenuFrame.origin.x > lastPosition.x ?
1246
- submenuFrame.origin :
1504
+ topPoint = submenuFrame.origin.x > lastPosition.x ?
1505
+ submenuFrame.origin :
1247
1506
  BMPointMake(submenuFrame.right, submenuFrame.origin.y);
1248
- bottomPoint = submenuFrame.origin.x > lastPosition.x ?
1249
- BMPointMake(submenuFrame.origin.x, submenuFrame.bottom) :
1507
+ bottomPoint = submenuFrame.origin.x > lastPosition.x ?
1508
+ BMPointMake(submenuFrame.origin.x, submenuFrame.bottom) :
1250
1509
  BMPointMake(submenuFrame.right, submenuFrame.bottom);
1251
1510
 
1252
- if (submenuFrame.origin.x < lastPosition.x) {
1253
- // When the menu appears to the left, the min and max slopes need to be
1254
- // flipped, because the "interior" of the slopes is counted clockwise
1255
- // from the bottom slope to the top one, but on the left side
1256
- // the interior needs to be counted counter-clockwise
1257
- flipSlopes = true;
1258
- }
1259
-
1260
1511
  return;
1261
1512
  }
1262
1513
 
@@ -1301,21 +1552,40 @@ BMMenu.prototype = {
1301
1552
  const topSlope = currentPosition.slopeAngleToPoint(topPoint);
1302
1553
  const bottomSlope = currentPosition.slopeAngleToPoint(bottomPoint);
1303
1554
 
1555
+ // Normalizes the specified angle so that it's within the [0, 2Pi] range
1556
+ function normalizeAngle(angle) {
1557
+ // Coerce angle into [-2Pi, 2Pi]
1558
+ angle %= 2 * Math.PI;
1559
+
1560
+ // Coerce the result into a positive angle
1561
+ return angle < 0 ? angle + 2 * Math.PI : angle;
1562
+ }
1563
+
1304
1564
  // Get the slope angle for the current movement, which indicates the direction
1305
1565
  // in which the pointer moves
1306
- const currentSlope = lastPosition.slopeAngleToPoint(currentPosition);
1566
+ const currentSlope = normalizeAngle(lastPosition.slopeAngleToPoint(currentPosition));
1307
1567
 
1308
- const minSlope = Math.min(topSlope, bottomSlope);
1309
- const maxSlope = Math.max(topSlope, bottomSlope);
1568
+ const minSlope = normalizeAngle(bottomSlope - topSlope);
1569
+ const maxSlope = normalizeAngle(topSlope - bottomSlope);
1310
1570
 
1571
+ // Determine which is the starting and ending slopes and normalize them
1572
+ let startSlope, endSlope;
1573
+ if (minSlope <= maxSlope) {
1574
+ startSlope = normalizeAngle(topSlope);
1575
+ endSlope = normalizeAngle(bottomSlope);
1576
+ }
1577
+ else {
1578
+ startSlope = normalizeAngle(bottomSlope);
1579
+ endSlope = normalizeAngle(topSlope);
1580
+ }
1581
+
1311
1582
  // If the mouse moves away from the submenu, resume events
1312
1583
  // The pointer is considered to be moving towards the menu if the slope angle
1313
1584
  // of the line formed between the current and previous mouse positions
1314
1585
  // is between these two slope angles
1315
- let isOutsideSlopeRange = flipSlopes ?
1316
- currentSlope > minSlope && currentSlope < maxSlope :
1317
- currentSlope < minSlope || currentSlope > maxSlope;
1318
-
1586
+ // This is done by rotating the end and current slope by the start slope and checking which is larger
1587
+ const isOutsideSlopeRange = normalizeAngle(currentSlope - startSlope) >= normalizeAngle(endSlope - startSlope);
1588
+
1319
1589
  if (isOutsideSlopeRange) {
1320
1590
  resumeEvents();
1321
1591
  return;
@@ -1325,7 +1595,7 @@ BMMenu.prototype = {
1325
1595
  lastPosition = currentPosition;
1326
1596
  };
1327
1597
 
1328
- eventTarget.addEventListener(mouseMoveEvent, handler, {capture: YES});
1598
+ window.addEventListener(mouseMoveEvent, handler, {capture: YES});
1329
1599
  },
1330
1600
 
1331
1601
  /**
@@ -1342,6 +1612,9 @@ BMMenu.prototype = {
1342
1612
  // If the menu isn't visible this action has no effect.
1343
1613
  if (!this._node) return;
1344
1614
 
1615
+ this._containerNode.removeEventListener('keydown', this, {passive: true});
1616
+ this._containerNode.removeEventListener('keyup', this, {passive: true});
1617
+
1345
1618
  // If any submenu is displayed, dismiss it as well
1346
1619
  if (this._submenu) {
1347
1620
  this._submenu._isSupermenuClosing = YES;
@@ -1382,7 +1655,7 @@ BMMenu.prototype = {
1382
1655
  }
1383
1656
 
1384
1657
  // Prevent interaction with the menu while its close animation is running
1385
- this._node.style.pointerEvents = 'none';
1658
+ this._node.style.pointerEvents = 'none';
1386
1659
  // this._node.inert = true;
1387
1660
 
1388
1661
  if (!supermenu) {
@@ -1410,7 +1683,7 @@ BMMenu.prototype = {
1410
1683
 
1411
1684
  const selectedMenuItem = this._selectedMenuItem;
1412
1685
  this._selectedMenuItem = undefined;
1413
-
1686
+
1414
1687
  if (selectedMenuItem) {
1415
1688
  if (this.delaysActions && selectedItemCallbackDelay) {
1416
1689
  setTimeout(() => this._performActionForMenuItem(selectedMenuItem), selectedItemCallbackDelay);
@@ -1457,9 +1730,9 @@ BMMenu.prototype = {
1457
1730
  itemsContainerNode.style.overflow = 'hidden';
1458
1731
 
1459
1732
  __BMVelocityAnimate(this._node, {
1460
- scaleX: this._kind == BMMenuKind.PullDownMenu ? 1 : scale,
1461
- scaleY: scale,
1462
- opacity: (sourceNodeShadow && !supermenu) ? 1 : 0,
1733
+ scaleX: this._kind == BMMenuKind.PullDownMenu ? 1 : scale,
1734
+ scaleY: scale,
1735
+ opacity: (sourceNodeShadow && !supermenu) ? 1 : 0,
1463
1736
  translateY: sourceNodeShadow ? -this._sourceNodeDisplacement + 'px' : '0px',
1464
1737
  translateX: sourceNodeShadow ? -this._sourceNodeHorizontalDisplacement + 'px' : '0px',
1465
1738
  translateZ: 0
@@ -1659,7 +1932,7 @@ BMMenu.prototype = {
1659
1932
  if (this._node) {
1660
1933
  this.closeAnimated(YES);
1661
1934
  }
1662
- }
1935
+ },
1663
1936
 
1664
1937
 
1665
1938
  };
@@ -1667,13 +1940,13 @@ BMMenu.prototype = {
1667
1940
  /**
1668
1941
  * Constructs and returns a menu with the specified items.
1669
1942
  * The menu will be hidden by default; the method `openAtPoint(_)` should be used to make the menu visible.
1670
- *
1943
+ *
1671
1944
  * @param items <[BMMenuItem]> An array of menu items that will be displayed by this menu.
1672
- *
1945
+ *
1673
1946
  * @return <BMMenu> A menu.
1674
1947
  */
1675
1948
  BMMenu.menuWithItems = function (items) {
1676
1949
  return (new BMMenu).initWithItems(items);
1677
1950
  };
1678
1951
 
1679
- // @endtype
1952
+ // @endtype