bm-core-ui 2.7.0 → 2.8.3

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.
@@ -1,10 +1,12 @@
1
1
  // @ts-check
2
2
 
3
- import {YES, NO, BMCopyProperties} from '../Core/BMCoreUI'
3
+ import {YES, NO, BMCopyProperties, BMNumberByConstrainingNumberToBounds} from '../Core/BMCoreUI'
4
4
  import {BMPoint, BMPointMake} from '../Core/BMPoint'
5
5
  import {BMHook, __BMVelocityAnimate} from '../Core/BMAnimationContext'
6
6
  import 'velocity-animate'
7
7
  import { BMRectMakeWithNodeFrame } from '../Core/BMRect';
8
+ import { BMView } from './BMView_v2.5';
9
+ import { BMKeyboardShortcut } from '../BMWindow/BMKeyboardShortcut';
8
10
 
9
11
  /**
10
12
  * The standard spacing between the menu and the node it is created from.
@@ -218,6 +220,40 @@ BMMenu.prototype = {
218
220
  */
219
221
  _sourceNodeDisplacement: undefined, // <Number, nullable>
220
222
 
223
+ /**
224
+ * The index of the currently highlighted menu item.
225
+ */
226
+ _highlightedIndex: -1, // <Number>
227
+
228
+ /**
229
+ * The DOM node that was focused when this alert was opened.
230
+ */
231
+ _previouslyActiveNode: undefined, // DOMNode
232
+
233
+ get highlightedIndex() {
234
+ return this._highlightedIndex;
235
+ },
236
+
237
+ set highlightedIndex(index) {
238
+ if (index != this._highlightedIndex) {
239
+ this._highlightedIndex = index;
240
+
241
+ if (this._node) {
242
+ // Remove the currently highlighted item's style
243
+ const highlightedItem = this._node.querySelector('.BMMenuItemHighlighted');
244
+ if (highlightedItem) {
245
+ highlightedItem.classList.remove('BMMenuItemHighlighted');
246
+ }
247
+
248
+ // And add it to the newly highlighted item
249
+ const nextHighlightedItem = this._node.children[index];
250
+ if (nextHighlightedItem) {
251
+ nextHighlightedItem.classList.add('BMMenuItemHighlighted');
252
+ }
253
+ }
254
+ }
255
+ },
256
+
221
257
  /**
222
258
  * Initializes this menu with the specified items.
223
259
  * The menu will be hidden by default; the method showAtPoint(_) should be used to make the menu visible.
@@ -241,6 +277,35 @@ BMMenu.prototype = {
241
277
  menuNode.className = this._CSSClass ? `BMMenu ${this._CSSClass}` : 'BMMenu';
242
278
  this._node = menuNode;
243
279
 
280
+ this._highlightedIndex = -1;
281
+
282
+ menuNode.tabIndex = -1;
283
+
284
+ // Register the keyboard shortcuts to be used while the menu is visible
285
+ const upArrow = BMKeyboardShortcut.keyboardShortcutWithKeyCode('ArrowUp', {modifiers: [], target: this, action: 'arrowUpPressedWithEvent'});
286
+ upArrow.preventsDefault = YES;
287
+ BMView.registerKeyboardShortcut(upArrow, {forNode: menuNode});
288
+
289
+ const downArrow = BMKeyboardShortcut.keyboardShortcutWithKeyCode('ArrowDown', {modifiers: [], target: this, action: 'arrowDownPressedWithEvent'});
290
+ downArrow.preventsDefault = YES;
291
+ BMView.registerKeyboardShortcut(downArrow, {forNode: menuNode});
292
+
293
+ const returnShortcut = BMKeyboardShortcut.keyboardShortcutWithKeyCode('Enter', {modifiers: [], target: this, action: 'returnPressedWithEvent'});
294
+ returnShortcut.preventsDefault = YES;
295
+ BMView.registerKeyboardShortcut(returnShortcut, {forNode: menuNode});
296
+
297
+ const spacebar = BMKeyboardShortcut.keyboardShortcutWithKeyCode('Space', {modifiers: [], target: this, action: 'returnPressedWithEvent'});
298
+ spacebar.preventsDefault = YES;
299
+ BMView.registerKeyboardShortcut(spacebar, {forNode: menuNode});
300
+
301
+ const tab = BMKeyboardShortcut.keyboardShortcutWithKeyCode('Tab', {modifiers: [], target: this, action: 'tabPressedWithEvent'});
302
+ tab.preventsDefault = YES;
303
+ BMView.registerKeyboardShortcut(tab, {forNode: menuNode});
304
+
305
+ const escape = BMKeyboardShortcut.keyboardShortcutWithKeyCode('Escape', {modifiers: [], target: this, action: 'escapePressedWithEvent'});
306
+ escape.preventsDefault = YES;
307
+ BMView.registerKeyboardShortcut(escape, {forNode: menuNode});
308
+
244
309
  // The overlay which intercepts clicks outside of the menu
245
310
  const menuContainer = document.createElement('div');
246
311
  menuContainer.className = 'BMMenuContainer';
@@ -254,6 +319,10 @@ BMMenu.prototype = {
254
319
 
255
320
  menuNode.addEventListener('click', event => event.preventDefault());
256
321
 
322
+ menuNode.addEventListener('mouseleave', event => {
323
+ this.highlightedIndex = -1;
324
+ });
325
+
257
326
  menuContainer.addEventListener('click', event => {
258
327
  this.closeAnimated(YES);
259
328
  event.preventDefault();
@@ -271,12 +340,13 @@ BMMenu.prototype = {
271
340
  _renderMenuItems() {
272
341
  // Create and append the items for this menu
273
342
  const iconSize = this._iconSize;
274
- for (const item of this._items) {
343
+
344
+ this._items.forEach((item, index) => {
275
345
  if (item.name.startsWith('---')) {
276
346
  const itemNode = document.createElement('div');
277
347
  itemNode.className = 'BMMenuDivider';
278
348
  this._node.appendChild(itemNode);
279
- continue;
349
+ return;
280
350
  }
281
351
 
282
352
  let itemNode = document.createElement('div');
@@ -298,6 +368,8 @@ BMMenu.prototype = {
298
368
  itemNode.appendChild(itemText);
299
369
 
300
370
  itemNode.addEventListener('click', event => {
371
+ itemNode.classList.add('BMMenuItemActive');
372
+
301
373
  if (item.action) item.action(item);
302
374
 
303
375
  if (this.delegate && this.delegate.menuDidSelectItem) {
@@ -305,8 +377,14 @@ BMMenu.prototype = {
305
377
  }
306
378
  });
307
379
 
380
+ itemNode.addEventListener('mouseenter', event => {
381
+ this.highlightedIndex = index;
382
+ })
383
+
384
+ itemNode.addEventListener
385
+
308
386
  this._node.appendChild(itemNode);
309
- }
387
+ });
310
388
  },
311
389
 
312
390
  /**
@@ -408,6 +486,9 @@ BMMenu.prototype = {
408
486
  delay += 16;
409
487
  }
410
488
 
489
+ this._previouslyActiveNode = document.activeElement;
490
+ menuNode.focus();
491
+
411
492
  // Animate the source node shadow
412
493
  await __BMVelocityAnimate(this._sourceNodeShadow, {translateY: displacement + 'px'}, {duration: duration, easing: easing});
413
494
 
@@ -484,6 +565,9 @@ BMMenu.prototype = {
484
565
  delay += 16;
485
566
  }
486
567
 
568
+ this._previouslyActiveNode = document.activeElement;
569
+ menuNode.focus();
570
+
487
571
  return menuNode;
488
572
  },
489
573
 
@@ -500,10 +584,14 @@ BMMenu.prototype = {
500
584
  this.delegate.menuWillClose(this);
501
585
  }
502
586
 
587
+ if (this._previouslyActiveNode) {
588
+ this._previouslyActiveNode.focus();
589
+ }
590
+
503
591
  this._node.style.pointerEvents = 'none';
504
592
  this._containerNode.style.pointerEvents = 'none';
505
593
  let delay = this._node.childNodes.length * 16 + 100 - 200;
506
- delay = (delay < 0 ? 0 : delay);
594
+ delay = (delay < 0 ? 0 : delay) + 200;
507
595
 
508
596
  const sourceNodeShadow = this._sourceNodeShadow;
509
597
  const containerNode = this._containerNode;
@@ -517,8 +605,8 @@ BMMenu.prototype = {
517
605
  const easing = sourceNodeShadow ? 'easeInOutQuad' : 'easeInQuad';
518
606
 
519
607
  if (sourceNodeShadow) {
520
- __BMVelocityAnimate(sourceNodeShadow, {translateY: '0px'}, {duration: duration, easing: easing});
521
- __BMVelocityAnimate(containerNode, {opacity: 0}, {duration: duration, easing: easing});
608
+ __BMVelocityAnimate(sourceNodeShadow, {translateY: '0px'}, {duration: duration, easing: easing, delay});
609
+ __BMVelocityAnimate(containerNode, {opacity: 0}, {duration: duration, easing: easing, delay});
522
610
  }
523
611
 
524
612
  __BMVelocityAnimate(this._node, {
@@ -543,7 +631,7 @@ BMMenu.prototype = {
543
631
  }
544
632
  });
545
633
 
546
- delay = 0;
634
+ delay = 200;
547
635
  for (let i = this._node.childNodes.length - 1; i >= 0; i--) {
548
636
  let child = this._node.childNodes[i];
549
637
  __BMVelocityAnimate(child, {translateY: '16px', translateZ: 0, opacity: 0}, {
@@ -556,7 +644,66 @@ BMMenu.prototype = {
556
644
 
557
645
  this._node = undefined;
558
646
  this._containerNode = undefined;
647
+ },
648
+
649
+ /**
650
+ * Invoked when the up arrow is pressed.
651
+ * @param event <KeyboardEvent> The event that triggered this action.
652
+ */
653
+ arrowUpPressedWithEvent(event) {
654
+ if (this._highlightedIndex != 0) {
655
+ this.highlightedIndex = BMNumberByConstrainingNumberToBounds(this._highlightedIndex - 1, 0, this._items.length - 1);
656
+ }
657
+ },
658
+
659
+ /**
660
+ * Invoked when the down arrow is pressed.
661
+ * @param event <KeyboardEvent> The event that triggered this action.
662
+ */
663
+ arrowDownPressedWithEvent(event) {
664
+ if (this._highlightedIndex != this._items.length - 1) {
665
+ this.highlightedIndex = BMNumberByConstrainingNumberToBounds(this._highlightedIndex + 1, 0, this._items.length - 1);
666
+ }
667
+ },
668
+
669
+ returnPressedWithEvent(event) {
670
+ if (this._highlightedIndex >= 0 && this._highlightedIndex < this._items.length) {
671
+ const item = this._items[this._highlightedIndex];
672
+
673
+ if (item.action) item.action(item);
674
+
675
+ if (this.delegate && this.delegate.menuDidSelectItem) {
676
+ this.delegate.menuDidSelectItem(this, item);
677
+ }
678
+
679
+ const highlightedNode = this._node.querySelector('.BMMenuItemHighlighted');
680
+ if (highlightedNode) {
681
+ highlightedNode.classList.add('BMMenuItemActive');
682
+ }
683
+
684
+ this.closeAnimated(YES);
685
+ }
686
+ },
687
+
688
+ /**
689
+ * Invoked when the tab key is pressed.
690
+ * @param event <KeyboardEvent> The event that triggered this action.
691
+ */
692
+ tabPressedWithEvent(event) {
693
+ // No action, this just prevents the keyboard focus from moving to another element
694
+ },
695
+
696
+ /**
697
+ * Invoked when the escape key is pressed.
698
+ * @param event <KeyboardEvent> The event that triggered this action.
699
+ */
700
+ escapePressedWithEvent(event) {
701
+ if (this._node) {
702
+ this.closeAnimated(YES);
703
+ }
559
704
  }
705
+
706
+
560
707
  };
561
708
 
562
709
  /**
@@ -1,9 +1,15 @@
1
- import { BMExtend, NO } from "../Core/BMCoreUI";
1
+ // @ts-check
2
+
3
+ import { BMExtend, NO, YES } from "../Core/BMCoreUI";
2
4
  import { BMView } from "./BMView_v2.5";
3
5
  import { BMMenuKind } from "./BMMenu";
6
+ import { BMRectMakeWithNodeFrame } from "../Core/BMRect";
7
+ import { BMPointMake } from "../Core/BMPoint";
8
+ import { BMHook } from "../Core/BMAnimationContext";
4
9
 
5
- // @ts-check
6
-
10
+ /**
11
+ * The default limit of visible suggestions.
12
+ */
7
13
  const BM_TEXT_FIELD_MAX_SUGGESTIONS = 10;
8
14
 
9
15
  // @type BMTextField extends BMView
@@ -63,6 +69,11 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
63
69
  }
64
70
  },
65
71
 
72
+ /**
73
+ * The maximum number of suggestions that will be visible when the autocomplete menu is open.
74
+ */
75
+ maxSuggestions: BM_TEXT_FIELD_MAX_SUGGESTIONS, // <Number>
76
+
66
77
  /**
67
78
  * An optional delegate that can be used to respond to various callbacks from the text field.
68
79
  */
@@ -138,7 +149,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
138
149
 
139
150
  highlightedItemIndex = -1;
140
151
 
141
- let limit = BM_TEXT_FIELD_MAX_SUGGESTIONS;
152
+ let limit = this.maxSuggestions;
142
153
  for (const option of suggestions) {
143
154
  options.push(this._menuItemWithLabel(option, {action: () => {
144
155
  menu = undefined;
@@ -201,7 +212,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
201
212
  highlightedItemIndex = Math.max((highlightedItemIndex || 0) - 1, 0);
202
213
  }
203
214
  else {
204
- highlightedItemIndex = Math.min((isNaN(highlightedItemIndex) ? -1 : highlightedItemIndex) + 1, filteredSuggestions.length - 1);
215
+ highlightedItemIndex = Math.min((isNaN(highlightedItemIndex) ? -1 : highlightedItemIndex) + 1, filteredSuggestions.length - 1, this.maxSuggestions - 1);
205
216
  }
206
217
 
207
218
  // Move the menu highlight
@@ -255,6 +266,9 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
255
266
  });
256
267
 
257
268
  inputNode.addEventListener('input', this._inputHandler = event => {
269
+ // Set to YES if the change event is fired due to autocompleting the text in the text field
270
+ let textFieldDidHighlightSuggestion = NO;
271
+
258
272
  // If suggestions are not enabled, no additional action is required
259
273
  if (!this._usesSuggestions) {
260
274
  if (!textFieldDidHighlightSuggestion) {
@@ -273,7 +287,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
273
287
  filteredSuggestions = suggestions.filter(e => e.toLowerCase().startsWith(inputNode.value.toLowerCase()));
274
288
 
275
289
  // Limit to the topmost 10 items
276
- filteredSuggestions.length = Math.min(filteredSuggestions.length, 10);
290
+ filteredSuggestions.length = Math.min(filteredSuggestions.length, this.maxSuggestions);
277
291
 
278
292
  // Empty the menu
279
293
  if (menu) menu.innerHTML = '';
@@ -282,7 +296,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
282
296
  highlightedItemIndex = 0;
283
297
 
284
298
  // Then add the filtered options
285
- let limit = BM_TEXT_FIELD_MAX_SUGGESTIONS;
299
+ let limit = this.maxSuggestions;
286
300
  if (menu) for (const option of filteredSuggestions) {
287
301
  menu.appendChild(this._menuItemWithLabel(option, {action: () => {
288
302
  menu = undefined;
@@ -298,9 +312,6 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
298
312
  if (!limit) break;
299
313
  }
300
314
 
301
- // Set to YES if the change event is fired due to autocompleting the text in the text field
302
- let textFieldDidHighlightSuggestion = NO;
303
-
304
315
  // Highlight the first item
305
316
  if (filteredSuggestions.length) {
306
317
  if (menu) {
@@ -311,7 +322,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
311
322
  // due to items being removed
312
323
  let frame = BMRectMakeWithNodeFrame(inputNode);
313
324
  let point = BMPointMake(frame.origin.x, frame.bottom);
314
- this._repositionMenuAtPoint(point);
325
+ this._repositionMenu(menu, {atPoint: point});
315
326
  }
316
327
 
317
328
  // Autocomplete the suggestion, if the caret is at the end of the text field
@@ -408,7 +419,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
408
419
  let constraintPopup = document.createElement('div');
409
420
  constraintPopup.className = 'BMLayoutEditorConstraintPopup BMTextFieldSuggestions';
410
421
 
411
- menu = constraintPopup;
422
+ let menu = constraintPopup;
412
423
 
413
424
  let constraintPopupContainer = document.createElement('div');
414
425
  constraintPopupContainer.className = 'BMLayoutEditorConstraintPopupContainer';
@@ -423,7 +434,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
423
434
 
424
435
  document.body.appendChild(constraintPopupContainer);
425
436
 
426
- this._repositionMenuAtPoint(point);
437
+ this._repositionMenu(menu, {atPoint: point});
427
438
 
428
439
  constraintPopup.addEventListener('click', event => event.preventDefault());
429
440
 
@@ -472,7 +483,7 @@ BMTextField.prototype = BMExtend(Object.create(BMView.prototype), {
472
483
  return constraintPopup;
473
484
  },
474
485
 
475
- _repositionMenuAtPoint(point) {
486
+ _repositionMenu(menu, {atPoint: point}) {
476
487
  let height = menu.offsetHeight;
477
488
 
478
489
  menu.style.transformOrigin = '50% 0%';
@@ -10,6 +10,7 @@ import {BMLayoutSizeClass, BMLayoutOrientation} from './BMLayoutSizeClass'
10
10
  import {BMViewport} from './BMViewport'
11
11
  import {BMLayoutAttribute, BMLayoutConstraintRelation, BMLayoutConstraint, BMLayoutConstraintPriorityRequired, BMLayoutConstraintKind} from './BMLayoutConstraint_v2.5'
12
12
  import * as kiwi from 'kiwi.js'
13
+ import { BMKeyboardShortcutModifier } from '../BMWindow/BMKeyboardShortcut'
13
14
 
14
15
 
15
16
  // When set to YES, this will cause view to use transforms instead of left/right for positioning
@@ -302,7 +303,6 @@ const _BMViewDequeueLayoutQueue = function (layoutQueue) {
302
303
  *
303
304
  * Views are usually not constructed using any constructor function. Instead, their lifecycle is managed by CoreUI.
304
305
  * You obtain views by invoking the static <code>viewForNode(_)</code> method on the <code>BMView</code> type.
305
- * It is also not necessary to invoke any destruction method when the DOM nodes managed by views are removed.
306
306
  *
307
307
  * To extend from BMView and create a view subtype, extend from the BMView prototype, then, when requesting views use
308
308
  * <code>BMView.viewForNode.call(MyCustomViewType, node)</code>. Note that if the node already had
@@ -317,6 +317,8 @@ export function BMView() {} // <constructor>
317
317
  // A private map maintaining the link between DOM nodes and their associated view objects.
318
318
  var _BMViewMap = new WeakMap; // <WeakMap<DOMNode, BMView>>
319
319
 
320
+ // #region Factory methods
321
+
320
322
  /**
321
323
  * Returns the view object associated with the given DOM node.
322
324
  * @param node <DOMNode> The DOM node for which to retrieve the view.
@@ -347,6 +349,10 @@ export function BMView() {} // <constructor>
347
349
  return view;
348
350
  }
349
351
 
352
+ // #endregion
353
+
354
+ // #region Layout variables
355
+
350
356
  // A set that contains the views that are root views.
351
357
  // These are the view that will handle updates to layout variables.
352
358
  const rootViews = new Set;
@@ -605,6 +611,86 @@ export function BMView() {} // <constructor>
605
611
  return values;
606
612
  }
607
613
 
614
+ // #endregion
615
+
616
+ // #region Keyboard shortcuts
617
+
618
+ // A private map containing a mapping between DOM nodes and view stubs that are used for implementing
619
+ // keyboard shortcuts
620
+ const _NodeKeyboardShortcutsMap = new WeakMap; // <WeakMap<DOMNode, Object>
621
+
622
+ // A class that is a subset of `BMView` and contains only the keyboard shortcuts related functionality.
623
+ const _BMViewKeyboardShortcutsStub = function (node) {
624
+ this.node = node;
625
+ this._keyboardShortcuts = {};
626
+ };
627
+
628
+ _BMViewKeyboardShortcutsStub.prototype = {
629
+
630
+ node: undefined, // <DOMNode>
631
+
632
+ _keyboardShortcuts: undefined, // <Dictionary<string, [BMKeyboardShortcut]>>
633
+
634
+ registerKeyboardShortcut(shortcut) {
635
+ return BMView.prototype.registerKeyboardShortcut.apply(this, arguments);
636
+ },
637
+
638
+ unregisterKeyboardShortcut(shortcut) {
639
+ return BMView.prototype.unregisterKeyboardShortcut.apply(this, arguments);
640
+ },
641
+
642
+ keyPressedWithEvent(event) {
643
+ return BMView.prototype.keyPressedWithEvent.apply(this, arguments);
644
+ },
645
+
646
+ _keyboardShortcutsEnabled: NO,
647
+
648
+ _enableKeyboardShortcuts() {
649
+ return BMView.prototype._enableKeyboardShortcuts.apply(this, arguments);
650
+ },
651
+
652
+ _disableKeyboardShortcuts() {
653
+ return BMView.prototype._disableKeyboardShortcuts.apply(this, arguments);
654
+ },
655
+ }
656
+
657
+ /**
658
+ * Registers a keyboard shortcut that can be activated when the given node has keyboard focus.
659
+ * @param shortcut <BMKeyboardShortcut> The shortcut to register.
660
+ * {
661
+ * @param forNode <DOMNode> The DOM node for which the shortcut should be registered.
662
+ * }
663
+ */
664
+ BMView.registerKeyboardShortcut = function (shortcut, {forNode: node}) {
665
+ let viewStub = _NodeKeyboardShortcutsMap.get(node);
666
+ if (!viewStub) {
667
+ viewStub = new _BMViewKeyboardShortcutsStub(node);
668
+ _NodeKeyboardShortcutsMap.set(node, viewStub);
669
+ }
670
+
671
+ viewStub.registerKeyboardShortcut(shortcut);
672
+ },
673
+
674
+ /**
675
+ * Unregisters a keyboard shortcut. If this keyboard shortcut had not been previously registered, this method does nothing.
676
+ * @param shortcut <BMKeyboardShortcut> The keyboard shortcut to unregister.
677
+ * {
678
+ * @param forNode <DOMNode> The DOM node for which the shortcut should be unregistered.
679
+ * }
680
+ */
681
+ BMView.unregisterKeyboardShortcut = function (shortcut, {forNode: node}) {
682
+ const viewStub = _NodeKeyboardShortcutsMap.get(node);
683
+
684
+ if (viewStub) {
685
+ viewStub.unregisterKeyboardShortcut(shortcut);
686
+ }
687
+ },
688
+
689
+
690
+ // #endregion
691
+
692
+ // #region Destructors
693
+
608
694
  /**
609
695
  * Used when releasing an entire view hierarchy.
610
696
  * Releases this view without affecting constraints or the DOM.
@@ -654,6 +740,10 @@ export function BMView() {} // <constructor>
654
740
  rootViews.delete(this);
655
741
  }
656
742
 
743
+ // #endregion
744
+
745
+ // #region Initializer
746
+
657
747
  /**
658
748
  * Designated initializer, invoked immediately after any view is created.
659
749
  * Subclasses must invoke this base initializer at some point during their initialization.
@@ -705,9 +795,13 @@ export function BMView() {} // <constructor>
705
795
  this._configuration = {opacity: this._opacity, isVisible: this._isVisible, contentInsets: this.__activeInsets};
706
796
  this._variableProperties = {};
707
797
 
798
+ this._keyboardShortcuts = {};
799
+
708
800
  return this;
709
801
  }
710
802
 
803
+ // #endregion
804
+
711
805
  })();
712
806
 
713
807
  BMView.prototype = BMExtend(BMView.prototype, {
@@ -819,16 +913,19 @@ BMView.prototype = BMExtend(BMView.prototype, {
819
913
 
820
914
  /**
821
915
  * The layout queue on which this view processes its layout passes.
822
- * If this property is modified while this view had already registered a layout pass with a different queue,
823
- * that layout pass will still run on that queue the next time it is drained.
824
916
  */
825
917
  _layoutQueue: _BMViewLayoutQueue, // <BMViewLayoutQueue, nullResettable>
826
918
  get layoutQueue() {
827
919
  return this._layoutQueue;
828
920
  },
829
921
  set layoutQueue(queue) {
922
+ const hasView = this._layoutQueue._views.has(this);
830
923
  this._layoutQueue._removeView(this);
831
924
  this._layoutQueue = queue || _BMViewLayoutQueue;
925
+
926
+ if (hasView) {
927
+ this._layoutQueue._enqueueView(this);
928
+ }
832
929
  },
833
930
 
834
931
  // #endregion
@@ -2108,6 +2205,111 @@ BMView.prototype = BMExtend(BMView.prototype, {
2108
2205
 
2109
2206
  // #endregion
2110
2207
 
2208
+ // #region Keyboard shortcuts
2209
+
2210
+ /**
2211
+ * The keyboard shortcuts that have been registered to this view.
2212
+ */
2213
+ _keyboardShortcuts: undefined, // <Dictionary<string, [BMKeyboardShortcut]>>
2214
+
2215
+ /**
2216
+ * Registers a keyboard shortcut that can be activated when this view has keyboard focus.
2217
+ * @param shortcut <BMKeyboardShortcut> The keyboard shortcut to register.
2218
+ */
2219
+ registerKeyboardShortcut(shortcut) {
2220
+ if (!Object.keys(this._keyboardShortcuts).length) this._enableKeyboardShortcuts();
2221
+
2222
+ if (!this._keyboardShortcuts[shortcut.keyCode]) {
2223
+ this._keyboardShortcuts[shortcut.keyCode] = [];
2224
+ }
2225
+
2226
+ this._keyboardShortcuts[shortcut.keyCode].push(shortcut);
2227
+ },
2228
+
2229
+ /**
2230
+ * Unregisters a keyboard shortcut. If this keyboard shortcut had not been previously registered, this method does nothing.
2231
+ * @param shortcut <BMKeyboardShortcut> The keyboard shortcut to unregister.
2232
+ */
2233
+ unregisterKeyboardShortcut(shortcut) {
2234
+ const shortcuts = this._keyboardShortcuts[shortcut.keyCode];
2235
+
2236
+ if (shortcuts) {
2237
+ for (let i = 0; i < shortcuts.length; i++) {
2238
+ if (shortcuts[i] == shortcut) {
2239
+ shortcuts.splice(i, 1);
2240
+ break;
2241
+ }
2242
+ }
2243
+
2244
+ // If this removes the last shortcut for this key code, remove the entry
2245
+ if (!shortcuts.length) {
2246
+ delete this._keyboardShortcuts[shortcut.keyCode];
2247
+ }
2248
+ }
2249
+
2250
+ if (!Object.keys(this._keyboardShortcuts).length) this._disableKeyboardShortcuts();
2251
+ },
2252
+
2253
+ /**
2254
+ * If keyboard shortcuts have been registered on this view, this method will be invoked to identify
2255
+ * and handle key presses. Subclasses that override this method should invoke the base implementation to allow
2256
+ * keyboard shortcuts to be handled correctly.
2257
+ * @param event <KeyboardEvent> The event that triggered this action.
2258
+ */
2259
+ keyPressedWithEvent(event) {
2260
+ if (!this._keyboardShortcuts[event.code]) return;
2261
+
2262
+ // Check if a shortcut key has been pressed.
2263
+ for (const shortcut of this._keyboardShortcuts[event.code]) {
2264
+ // Build the modifier bitmap for this event
2265
+ let bitmap = 0;
2266
+ for (const modifier in BMKeyboardShortcutModifier) {
2267
+ if (event[BMKeyboardShortcutModifier[modifier].key]) {
2268
+ bitmap = bitmap | BMKeyboardShortcutModifier[modifier].value;
2269
+ }
2270
+ }
2271
+
2272
+ if (bitmap == shortcut._modifierBitmap) {
2273
+ if (shortcut.preventsDefault) event.preventDefault();
2274
+ shortcut.target[shortcut.action](event, {forKeyboardShortcut: shortcut});
2275
+ }
2276
+ }
2277
+ },
2278
+
2279
+ /**
2280
+ * Set to `YES` if keyboard shortcuts are enabled.
2281
+ */
2282
+ _keyboardShortcutsEnabled: NO, // <Boolean>
2283
+
2284
+ /**
2285
+ * Enables keyboard shortcut handling. This makes the view's `node` focusable and attaches
2286
+ * the relevant event handlers to it.
2287
+ */
2288
+ _enableKeyboardShortcuts() {
2289
+ // Make this view focusable if it is not already
2290
+ if (!this.node.hasAttribute('tabIndex')) {
2291
+ this.node.tabIndex = -1;
2292
+ }
2293
+
2294
+ this._keypressHandler = (event) => {
2295
+ this.keyPressedWithEvent(event);
2296
+ };
2297
+
2298
+ this.node.addEventListener('keydown', this._keypressHandler);
2299
+ this._keyboardShortcutsEnabled = YES;
2300
+ },
2301
+
2302
+ /**
2303
+ * Disables keyboard shortcut handling. This detaches the relevant event handlers from this view's `node`.
2304
+ * The node's `tabIndex` property will not be cleared by this method.
2305
+ */
2306
+ _disableKeyboardShortcuts() {
2307
+ this.node.removeEventListener('keydown', this._keypressHandler);
2308
+ this._keyboardShortcutsEnabled = NO;
2309
+ },
2310
+
2311
+ // #endregion
2312
+
2111
2313
  // #region Constraints and Cassowary Variables
2112
2314
 
2113
2315