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.
- package/build/@types/index.d.ts +327 -31
- package/build/BMCollectionView/BMCollectionView.js +33 -16
- package/build/BMCollectionView/BMCollectionViewFlowLayout.js +159 -39
- package/build/BMCollectionView/BMCollectionViewLayout.js +4 -1
- package/build/BMCoreUI.css +161 -13
- package/build/BMView/BMLayoutConstraint_v2.5.js +1 -1
- package/build/BMView/BMMenu.js +790 -65
- package/build/BMView/BMScrollView.js +1 -1
- package/build/BMView/BMTextFieldDelegate.js +6 -6
- package/build/BMViewLayoutEditor/BMLayoutEditorSettingCells.js +2 -2
- package/build/BMViewLayoutEditor/BMLayoutEditorViewSettings.js +5 -2
- package/build/BMWindow/BMConfirmationPopup.js +21 -1
- package/build/BMWindow/BMPopover/BMPopover.js +310 -52
- package/build/BMWindow/BMWindow.js +61 -18
- package/lib/@types/BMCoreUI.min.d.ts +327 -31
- package/lib/BMCoreUI.min.js +1 -1
- package/package.json +1 -1
- package/CHANGELOG.md +0 -1739
- package/build/BMViewLayoutEditor/BMLayoutEditorDelegate.js +0 -59
- package/build/BMViewLayoutEditor/BMLayoutEditorSettingsDelegate.js +0 -46
- package/build/BMViewLayoutEditor/BMLayoutVariableProvider.js +0 -202
package/build/BMView/BMMenu.js
CHANGED
|
@@ -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)
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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:
|
|
302
|
+
_sourceNodeDisplacement: 0, // <Number, nullable>
|
|
222
303
|
|
|
223
304
|
/**
|
|
224
|
-
* The
|
|
305
|
+
* The source node horizontal displacement, used while the menu is visible.
|
|
225
306
|
*/
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
|
473
|
-
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
576
|
-
* @param
|
|
577
|
-
*
|
|
578
|
-
*
|
|
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.
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|