neo.mjs 6.10.13 → 6.10.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.
@@ -20,9 +20,9 @@ class ServiceWorker extends ServiceBase {
20
20
  */
21
21
  singleton: true,
22
22
  /**
23
- * @member {String} version='6.10.13'
23
+ * @member {String} version='6.10.15'
24
24
  */
25
- version: '6.10.13'
25
+ version: '6.10.15'
26
26
  }
27
27
 
28
28
  /**
@@ -20,9 +20,9 @@ class ServiceWorker extends ServiceBase {
20
20
  */
21
21
  singleton: true,
22
22
  /**
23
- * @member {String} version='6.10.13'
23
+ * @member {String} version='6.10.15'
24
24
  */
25
- version: '6.10.13'
25
+ version: '6.10.15'
26
26
  }
27
27
 
28
28
  /**
@@ -36,6 +36,8 @@ class MainContainer extends Viewport {
36
36
  let me = this;
37
37
 
38
38
  me.items = [{
39
+ html : '<h3>The dialog is invoked from the "Create Dialog" button</h3><h1>Hide it by pressing the ESCAPE key. The button will be refocused</h1>'
40
+ }, {
39
41
  module: Toolbar,
40
42
  items :[{
41
43
  module : Button,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name" : "neo.mjs",
3
- "version" : "6.10.13",
3
+ "version" : "6.10.15",
4
4
  "description" : "The webworkers driven UI framework",
5
5
  "type" : "module",
6
6
  "repository" : {
@@ -60,7 +60,7 @@
60
60
  "siesta-lite" : "5.5.2",
61
61
  "showdown" : "^2.1.0",
62
62
  "url" : "^0.11.3",
63
- "webpack" : "^5.89.0",
63
+ "webpack" : "^5.90.0",
64
64
  "webpack-cli" : "^5.1.4",
65
65
  "webpack-dev-server" : "4.15.1",
66
66
  "webpack-hook-plugin" : "^1.0.7",
@@ -183,7 +183,6 @@ later in the lab.
183
183
 
184
184
  <img style="width:80%" src="https://s3.amazonaws.com/mjs.neo.learning.images/earthquakes/EmptyEarthquakes.png"></img>
185
185
 
186
-
187
186
  </details>
188
187
 
189
188
  <details>
@@ -349,6 +348,7 @@ will show the container hierarchy for the selected component.
349
348
  At this point the app is so simple there's not much to see, but in a more complex app you can see the hierarchy
350
349
  and inspect or update component.
351
350
 
351
+
352
352
  ##Lab. Debugging
353
353
 
354
354
  In this lab you'll get a little debugging practice by getting component references, changing properties,
@@ -1403,7 +1403,4 @@ Save, refresh, and confirm that you see the value logged when you click on a map
1403
1403
 
1404
1404
  </details>
1405
1405
 
1406
-
1407
-
1408
-
1409
1406
  <!-- /lab -->
@@ -76,4 +76,5 @@
76
76
  .lab:hover {
77
77
  box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
78
78
  }
79
+
79
80
  }
@@ -109,6 +109,14 @@
109
109
  background-color: var(--list-item-background-color-active);
110
110
  color : var(--list-container-list-color);
111
111
  }
112
+ &:active {
113
+ background-color: var(--list-item-background-color-active);
114
+ color : var(--list-container-list-color);
115
+ }
116
+ &:active {
117
+ background-color: var(--list-item-background-color-active);
118
+ color : var(--list-container-list-color);
119
+ }
112
120
  }
113
121
 
114
122
  &.neo-navigator-active-item {
@@ -236,12 +236,12 @@ const DefaultConfig = {
236
236
  useVdomWorker: true,
237
237
  /**
238
238
  * buildScripts/injectPackageVersion.mjs will update this value
239
- * @default '6.10.13'
239
+ * @default '6.10.15'
240
240
  * @memberOf! module:Neo
241
241
  * @name config.version
242
242
  * @type String
243
243
  */
244
- version: '6.10.13'
244
+ version: '6.10.15'
245
245
  };
246
246
 
247
247
  Object.assign(DefaultConfig, {
@@ -643,6 +643,10 @@ class Base extends CoreBase {
643
643
  me[state]()
644
644
  }
645
645
 
646
+ if (!value) {
647
+ me.revertFocus();
648
+ }
649
+
646
650
  me.fire(state, {id: me.id});
647
651
  me.fire('hiddenChange', {id: me.id, oldValue, value})
648
652
  }
@@ -781,10 +785,25 @@ class Base extends CoreBase {
781
785
 
782
786
  if (me.floating) {
783
787
  me.alignTo();
788
+
789
+ // Focus will be pushed into the first input field or other focusable item
790
+ Neo.main.DomAccess.focus({
791
+ id : this.id,
792
+ children : true
793
+ });
784
794
  }
785
795
 
786
796
  me.fire('mounted', me.id)
787
797
  }
798
+ else {
799
+ me.revertFocus();
800
+ }
801
+ }
802
+ }
803
+
804
+ revertFocus() {
805
+ if (this.containsFocus && this.focusEnterData?.relatedTarget) {
806
+ Neo.getComponent(this.focusEnterData.relatedTarget.id)?.focus();
788
807
  }
789
808
  }
790
809
 
@@ -1131,6 +1150,7 @@ class Base extends CoreBase {
1131
1150
 
1132
1151
  if (value) {
1133
1152
  value = ClassSystemUtil.beforeSetInstance(value, KeyNavigation, {
1153
+ keyDownEventBubble : true,
1134
1154
  keys: value
1135
1155
  })
1136
1156
  }
@@ -1257,6 +1277,8 @@ class Base extends CoreBase {
1257
1277
  parentModel = parent?.getModel(),
1258
1278
  parentVdom;
1259
1279
 
1280
+ me.revertFocus();
1281
+
1260
1282
  me.domListeners = [];
1261
1283
 
1262
1284
  me.controller = null; // triggers destroy()
@@ -1816,6 +1838,16 @@ class Base extends CoreBase {
1816
1838
  this.keys?.register(this)
1817
1839
  }
1818
1840
 
1841
+ onFocusEnter(data) {
1842
+ // If we are hidden, or unmounted while we still contain focus, we have to revert
1843
+ // focus to where it came from if possible
1844
+ this.focusEnterData = data;
1845
+ }
1846
+
1847
+ onFocusLeave() {
1848
+ this.focusEnterData = null;
1849
+ }
1850
+
1819
1851
  /**
1820
1852
  * Triggered by manager.Focus
1821
1853
  * @name onFocusEnter
@@ -79,7 +79,7 @@ class RecordFactory extends Base {
79
79
 
80
80
  if (Array.isArray(model.fields)) {
81
81
  model.fields.forEach(field => {
82
- let value = config[field.name],
82
+ let value = config[field.mapping || field.name],
83
83
  symbol = Symbol.for(field.name),
84
84
  parsedValue;
85
85
 
@@ -212,7 +212,7 @@ class RecordFactory extends Base {
212
212
  * @returns {Boolean}
213
213
  */
214
214
  isRecord(record) {
215
- return record?.[Symbol.for('isRecord')] || false;
215
+ return record?.isRecord;
216
216
  }
217
217
 
218
218
  /**
@@ -100,6 +100,9 @@ class Base extends Panel {
100
100
  * @protected
101
101
  */
102
102
  isDragging: false,
103
+ keys: {
104
+ Escape: 'onKeyDownEscape'
105
+ },
103
106
  /**
104
107
  * @member {String} maximizeCls='far fa-window-maximize'
105
108
  */
@@ -216,8 +219,7 @@ class Base extends Panel {
216
219
  * @protected
217
220
  */
218
221
  afterSetDraggable(value, oldValue) {
219
- let me = this,
220
- domListeners = me.domListeners,
222
+ let me = this,
221
223
  cls;
222
224
 
223
225
  if (oldValue !== undefined && me.headerToolbar) {
@@ -230,21 +232,21 @@ class Base extends Panel {
230
232
  DragZone = module.default;
231
233
 
232
234
  if (!me.dragListenersAdded) {
233
- domListeners.push(
235
+ const dragListeners = [
234
236
  {'drag:end' : me.onDragEnd, scope: me, delegate: '.neo-header-toolbar'},
235
237
  {'drag:start': me.onDragStart, scope: me, delegate: '.neo-header-toolbar'}
236
- );
238
+ ];
237
239
 
238
240
  if (me.dragZoneConfig?.alwaysFireDragMove) {
239
- domListeners.push(
241
+ dragListeners.push(
240
242
  {'drag:move': me.onDragMove, scope: me, delegate: '.neo-header-toolbar'}
241
243
  )
242
244
  }
243
245
 
244
- me.domListeners = domListeners;
246
+ me.domListeners = [...me.domListeners, ...dragListeners];
245
247
  me.dragListenersAdded = true
246
248
  }
247
- })
249
+ });
248
250
  }
249
251
 
250
252
  /**
@@ -444,6 +446,7 @@ class Base extends Panel {
444
446
  close(animate=!!this.animateTargetId) {
445
447
  let me = this;
446
448
 
449
+ me.revertFocus();
447
450
  if (animate) {
448
451
  me.animateHide()
449
452
  } else {
@@ -694,6 +697,10 @@ class Base extends Panel {
694
697
  }
695
698
  }
696
699
 
700
+ onKeyDownEscape() {
701
+ this.hidden = true;
702
+ }
703
+
697
704
  /**
698
705
  * @param {Boolean} animate=!!this.animateTargetId
699
706
  */
@@ -282,7 +282,15 @@ class Picker extends Text {
282
282
  * @protected
283
283
  */
284
284
  onKeyDownEscape(data) {
285
- this.pickerIsMounted && this.hidePicker()
285
+ if (this.pickerIsMounted) {
286
+ this.hidePicker();
287
+
288
+ // We processed this event, and it should not proceed to ancestor components
289
+ data.cancelBubble = true;
290
+
291
+ // And no further listeers should be notified
292
+ return false;
293
+ }
286
294
  }
287
295
 
288
296
  /**
@@ -219,20 +219,40 @@ class Select extends Picker {
219
219
  * @protected
220
220
  */
221
221
  beforeSetStore(value, oldValue) {
222
+ const
223
+ me = this,
224
+ { valueField, displayField} = me;
225
+
222
226
  oldValue?.destroy();
223
227
 
228
+ // Promote an array of items to be a Store
229
+ if (Array.isArray(value)) {
230
+ value = {
231
+ data : value.map((v, i) => {
232
+ // Simplest case is just picking string values.
233
+ if (typeof v === 'string') {
234
+ v = {
235
+ [valueField] : v,
236
+ [displayField] : v
237
+ };
238
+ }
239
+ return v;
240
+ })
241
+ };
242
+ }
243
+
224
244
  // to reduce boilerplate code, a store config object without a defined model should default
225
245
  // to displayField & valueField defaults
226
246
  if (Neo.typeOf(value) === 'Object' && !value.model && !value.module && !value.ntype) {
227
247
  value.model = {
228
248
  fields: [
229
- {name: 'id', type: 'String'},
230
- {name: 'name', type: 'String'}
249
+ {name: valueField, type: 'String'},
250
+ {name: displayField, type: 'String'}
231
251
  ]
232
252
  }
233
253
  }
234
254
 
235
- return ClassSystemUtil.beforeSetInstance(value, Store)
255
+ return ClassSystemUtil.beforeSetInstance(value, Store);
236
256
  }
237
257
 
238
258
  /**
@@ -515,7 +535,6 @@ class Select extends Picker {
515
535
 
516
536
  me.activeRecord = store.getAt(activeIndex)
517
537
  me.activeRecordId = me.activeRecord[store.keyProperty || model.keyProperty]
518
- me.getInputEl()['aria-activedescendant'] = activeItem;
519
538
 
520
539
  // Update typeahead hint (which updates DOM), or update DOM
521
540
  me.typeAhead ? me.updateTypeAheadValue(me.lastManualInput) : me.update();
@@ -1266,6 +1266,8 @@ class Text extends Base {
1266
1266
  * @protected
1267
1267
  */
1268
1268
  onFocusEnter(data) {
1269
+ super.onFocusEnter(data);
1270
+
1269
1271
  let me = this,
1270
1272
  cls = me.cls;
1271
1273
 
@@ -360,10 +360,18 @@ class DomAccess extends Base {
360
360
  let node = this.getElement(data.id);
361
361
 
362
362
  if (node) {
363
- node.focus();
363
+ // The children property means focus inner elements if possible.
364
+ if (!DomUtils.isFocusable(node) && data.children) {
365
+ // Prefer to focus input fields over buttons.
366
+ // querySelector('input,textarea,button') returns buttons first, so use multiple calls.
367
+ node = node.querySelector('input:not(:disabled)') || node.querySelector('textarea:not(:disabled)') || node.querySelector('button:not(:disabled)') || [...node.querySelectorAll('*')].find(DomUtils.isFocusable);
368
+ }
369
+ if (node) {
370
+ node.focus();
364
371
 
365
- if (Neo.isNumber(node.selectionStart)) {
366
- node.selectionStart = node.selectionEnd = node.value.length;
372
+ if (Neo.isNumber(node.selectionStart)) {
373
+ node.selectionStart = node.selectionEnd = node.value.length;
374
+ }
367
375
  }
368
376
  }
369
377
 
@@ -42,9 +42,31 @@ export default class DomUtils extends Base {
42
42
  }
43
43
  }
44
44
 
45
+ /**
46
+ * Analogous to the `HTMLElement` `querySelectorAll` method. Searches the passed element
47
+ * and all descendants for all elements for which the passed `filterFn` returns `true`.
48
+ * @param {HTMLElement} el The element to start from.
49
+ * @param {Function} filterFn A function which returns `true` when a desired element is reached.
50
+ * @returns {HTMLElement[]} An array of matching elements
51
+ */
52
+ static queryAll(el, filterFn) {
53
+ return [el, ...el.querySelectorAll('*')].filter(filterFn);
54
+ }
55
+
56
+ /**
57
+ * Analogous to the `HTMLElement` `querySelector` method. Searches the passed element
58
+ * and all descendants for the first element for which the passed `filterFn` returns `true`.
59
+ * @param {HTMLElement} el The element to start from.
60
+ * @param {Function} filterFn A function which returns `true` when the desired element is reached.
61
+ * @returns {HTMLElement} The first matching element
62
+ */
63
+ static query(el, filterFn) {
64
+ return [el, ...el.querySelectorAll('*')].find(filterFn);
65
+ }
66
+
45
67
  static isFocusable(e) {
46
68
  // May be used as a scopeless callback, so use "DomUtils", not "this"
47
- return DomUtils.isTabbable(e) || e.getAttribute('tabIndex') == -1;
69
+ return DomUtils.isTabbable(e) || Number(e.getAttribute('tabIndex')) < 0;
48
70
  }
49
71
 
50
72
  static isTabbable(e) {
@@ -53,8 +75,9 @@ export default class DomUtils extends Base {
53
75
  style = getComputedStyle(e),
54
76
  tabIndex = e.getAttribute('tabIndex');
55
77
 
56
- // Hidden elements not tabbable
57
- if (!e.offsetParent || style.getPropertyValue('visibility') === 'hidden') {
78
+ // Hidden elements are not tabbable.
79
+ // Negative tabIndex also means not tabbable (Though still focusable)
80
+ if (!e.isConnected || !e.offsetParent || style.getPropertyValue('visibility') === 'hidden' || Number(tabIndex) < 0) {
58
81
  return false
59
82
  }
60
83
 
@@ -3,6 +3,13 @@ import DomAccess from '../DomAccess.mjs';
3
3
  import DomUtils from '../DomUtils.mjs';
4
4
  import DomEvents from '../DomEvents.mjs';
5
5
 
6
+ // We do not need to inject a synthesized "click" event when we detect an ENTER
7
+ // keypress on these element types.
8
+ const enterActivatedTags= {
9
+ A : 1,
10
+ BUTTON : 1
11
+ };
12
+
6
13
  /**
7
14
  * Addon for Navigator
8
15
  * @class Neo.main.addon.Navigator
@@ -40,13 +47,27 @@ class Navigator extends Base {
40
47
  *
41
48
  * When navigation occurs from one navigable element to another, the `navigate` event
42
49
  * will be fired.
50
+ *
51
+ * Note that if focus is expected to enter the subject, the navigable elements
52
+ * designated by the `selector` must be focusable in some way. So if not using natively
53
+ * focusable elements, they must have `tabIndex="-1"`.
54
+ *
55
+ * Upon navigation, the `aria-activedescendant` property is automatically updated
56
+ * on the `eventSource` element (which defaults to the subject element, but may be external)
57
+ *
58
+ * Pressing `Enter` when an item is active clicks that item.
59
+ *
60
+ * if `autoClick` is set to `true` in the data, simply navigating to an element will click it.
43
61
  * @param {*} data
44
62
  * @param {String} data.id The element id to navigate in.
45
63
  * @param {String} [data.eventSource] Optional - the element id to read keystrokes from.
46
- * defaults to the main element id.
64
+ * defaults to the main element id. Select field uses this. Focus remains in the field's
65
+ * `<input>` element while navigating its dropdown.
47
66
  * @param {String} data.selector A CSS selector which identifies the navigable elements.
48
67
  * @param {String} data.activeCls A CSS class to add to the currently active navigable element.
49
- * @param {Boolean} wrap Pass as `true` to have navigation wrap from first to last and vice versa.
68
+ * @param {Boolean} data.wrap Pass as `true` to have navigation wrap from first to last and vice versa.
69
+ * @param {Boolean} [data.autoClick=false] Pass as `true` to have navigation click the target navigated to.
70
+ * TabPanels will use this on their tab toolbar.
50
71
  */
51
72
  subscribe(data) {
52
73
  const
@@ -60,12 +81,16 @@ class Navigator extends Base {
60
81
  data.activeCls = 'neo-navigator-active-item'
61
82
  }
62
83
 
84
+ // Ensure that only *one* of the child focusables is actually tabbable.
85
+ // We use arrow keys for internal navigation. TAB must move out.
86
+ me.fixItemFocusability(data);
87
+
63
88
  // Finds a focusable item starting from a descendant el within one of our selector items
64
89
  data.findFocusable = el => DomUtils.closest(el, el =>
65
90
  // We're looking for an element that is focusable
66
91
  DomUtils.isFocusable(el) &&
67
92
  // And within our subject element
68
- (subject.compcompareDocumentPosition(el) & Node.DOCUMENT_POSITION_CONTAINED_BY) &&
93
+ (subject.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_CONTAINED_BY) &&
69
94
  // And within an element that matches our selector
70
95
  el.closest(data.selector)
71
96
  );
@@ -80,9 +105,39 @@ class Navigator extends Base {
80
105
  });
81
106
 
82
107
  eventSource.addEventListener('keydown', data.l1 = e => me.navigateKeyDownHandler(e, data));
83
- subject.addEventListener('mousedown', data.l2 = e => me.navigateMouseDownHandler(e, data));
84
- subject.addEventListener('click', data.l3 = e => me.navigateClickHandler(e, data));
85
- subject.addEventListener('focusin', data.l4 = e => me.navigateFocusInHandler(e, data));
108
+ subject.addEventListener('mousedown', data.l2 = e => me.navigateMouseDownHandler(e, data));
109
+ subject.addEventListener('click', data.l3 = e => me.navigateClickHandler(e, data));
110
+ subject.addEventListener('focusin', data.l4 = e => me.navigateFocusInHandler(e, data));
111
+ subject.addEventListener('focusout', data.l5 = e => me.navigateFocusOutHandler(e, data));
112
+ }
113
+
114
+ // The navigables we are dealing with, if they are focusable must *not* be tabbable.
115
+ // Only *one* must be tabbable, so that tabbing into the subject element goes to the
116
+ // one active element.
117
+ //
118
+ // Tabbing *from* that must exit the subject element.
119
+ //
120
+ // So we must ensure that all the focusable elements except the first are not tabbable.
121
+ fixItemFocusability(data) {
122
+ // If the key events are being read from an external element, then that will always contain
123
+ // focus, so we have nothing to do here. The navigable items wil be inert and not
124
+ // focusable. Navigation will be "virtual". Select field navigates its dropdowns like this.
125
+ if (!data.subject.contains(data.eventSource)) {
126
+ return;
127
+ }
128
+
129
+ const
130
+ focusables = DomUtils.queryAll(data.subject, DomUtils.isFocusable),
131
+ defaultActiveItem = focusables[0] || data.subject.querySelector(data.selector);
132
+
133
+ // Ensure the items are not tabbable.
134
+ // TAB navigates out of the subject.
135
+ focusables.forEach(e => e !== defaultActiveItem && (e.tabIndex = -1));
136
+
137
+ // Make at least one thing tabbable so focus can move into the subject element
138
+ if (defaultActiveItem) {
139
+ defaultActiveItem.tabIndex = 0;
140
+ }
86
141
  }
87
142
 
88
143
  unsubscribe(data) {
@@ -96,10 +151,15 @@ class Navigator extends Base {
96
151
  target.removeEventListener('mousedown', data.l2);
97
152
  target.removeEventListener('click', data.l3);
98
153
  target.removeEventListener('focusin', data.l4);
154
+ target.removeEventListener('focusout', data.l5);
99
155
  }
100
156
  }
101
157
 
158
+ // This is called if mutations take place within the subject element.
159
+ // We have to keep things in order if the list items change.
102
160
  navigateTargetChildListChange(mutations, data) {
161
+ this.fixItemFocusability(data);
162
+
103
163
  // Active item gone.
104
164
  // Try to activate the item at the same index;
105
165
  if (data.activeItem && !data.subject.contains(data.activeItem)) {
@@ -110,11 +170,33 @@ class Navigator extends Base {
110
170
  }
111
171
 
112
172
  navigateFocusInHandler(e, data) {
113
- const target = e.target.closest(data.selector);
173
+ const
174
+ target = e.target.closest(data.selector),
175
+ { relatedTarget } = e,
176
+ { subject } = data;
114
177
 
115
178
  // If our targets are focusable and recieve focus, that is a navigation.
116
179
  if (target) {
117
180
  this.setActiveItem(target, data);
181
+
182
+ // This was internal navigation.
183
+ // The items must be focusable, but *not* tabbable.
184
+ // So remove tabbability on the last active item
185
+ if (subject.contains(relatedTarget)) {
186
+ relatedTarget.tabIndex = -1;
187
+ }
188
+ }
189
+ }
190
+
191
+ navigateFocusOutHandler(e, data) {
192
+ const { target } = e;
193
+
194
+ // Clear active class from the item we are leaving from.
195
+ target.closest(data.selector).classList.remove(data.activeCls);
196
+
197
+ // On focusout, leave the last active item as tabbable so user can TAB back in here
198
+ if (!DomUtils.isTabbable(target)) {
199
+ target.tabIndex = 0;
118
200
  }
119
201
  }
120
202
 
@@ -168,44 +250,36 @@ class Navigator extends Base {
168
250
  }
169
251
  }
170
252
 
171
- let { key } = keyEvent,
253
+ let { key, target } = keyEvent,
172
254
  newActiveElement;
173
255
 
174
256
  switch(key) {
257
+ // Move to the previous navigable item
175
258
  case data.previousKey:
176
259
  newActiveElement = me.navigateGetAdjacent(-1, data);
177
260
  if (!newActiveElement && wrap) {
178
261
  newActiveElement = subject.querySelector(`${data.selector}:last-of-type`);
179
262
  }
180
263
  break;
264
+ // Move to the next navigable item
181
265
  case data.nextKey:
182
266
  newActiveElement = me.navigateGetAdjacent(1, data);
183
267
  if (!newActiveElement && wrap) {
184
268
  newActiveElement = subject.querySelector(data.selector);
185
269
  }
186
270
  break;
271
+ // Move to the first navigable item
187
272
  case 'Home':
188
273
  newActiveElement = subject.querySelector(data.selector);
189
274
  break;
275
+ // Move to the last navigable item
190
276
  case 'End':
191
277
  newActiveElement = subject.querySelector(`${data.selector}:last-of-type`);
192
278
  break;
279
+ // Click the currently active item if necessary
193
280
  case 'Enter':
194
- if (data.activeItem) {
195
- const
196
- rect = data.activeItem.getBoundingClientRect(),
197
- clientX = rect.x + (rect.width / 2),
198
- clientY = rect.y + (rect.height / 2);
199
-
200
- data.activeItem.dispatchEvent(new MouseEvent('click', {
201
- bubbles : true,
202
- altKey : Neo.altKeyDown,
203
- ctrlKey : Neo.controlKeyDown,
204
- metaKey : Neo.metaKeyDown,
205
- shiftKey : Neo.shiftKeyDown,
206
- clientX,
207
- clientY
208
- }))
281
+ if (data.activeItem && !enterActivatedTags[target.tagName]) {
282
+ this.clickItem(data.activeItem);
209
283
  }
210
284
  }
211
285
 
@@ -215,6 +289,30 @@ class Navigator extends Base {
215
289
  }
216
290
  }
217
291
 
292
+ clickItem(el) {
293
+ // The element knows how to click itself.
294
+ if (typeof el.click === 'function') {
295
+ el.click();
296
+ }
297
+ // It operates through a listenert, so needs an event firing into it.
298
+ else {
299
+ const
300
+ rect = el.getBoundingClientRect(),
301
+ clientX = rect.x + (rect.width / 2),
302
+ clientY = rect.y + (rect.height / 2);
303
+
304
+ el.dispatchEvent(new MouseEvent('click', {
305
+ bubbles : true,
306
+ altKey : Neo.altKeyDown,
307
+ ctrlKey : Neo.controlKeyDown,
308
+ metaKey : Neo.metaKeyDown,
309
+ shiftKey : Neo.shiftKeyDown,
310
+ clientX,
311
+ clientY
312
+ }));
313
+ }
314
+ }
315
+
218
316
  /**
219
317
  * Navigates to the passed
220
318
  * @param {String|Number} newActiveElement The id of the new active element in the subject
@@ -245,7 +343,7 @@ class Navigator extends Base {
245
343
 
246
344
  // Find a focusable element which may be the item, or inside the item to draw focus to.
247
345
  // For example a Chip list in which .neo-list-items contain focusable Chips.
248
- const focusTarget = [newActiveElement, ...newActiveElement.querySelectorAll('*')].find(DomUtils.isFocusable);
346
+ const focusTarget = DomUtils.query(newActiveElement, DomUtils.isFocusable);
249
347
 
250
348
  // If the item contains a focusable, we focus it and then react in navigateFocusInHandler
251
349
  if (focusTarget) {
@@ -275,7 +373,10 @@ class Navigator extends Base {
275
373
  block : 'nearest',
276
374
  inline : 'nearest',
277
375
  nehavior : 'smooth'
278
- })
376
+ });
377
+
378
+ // Link the event source or the encapsulating element to the active item for A11Y
379
+ (data.eventSource || data.subject).setAttribute('aria-activedescendant', data.activeItem.id);
279
380
 
280
381
  DomEvents.sendMessageToApp({
281
382
  type : 'neonavigate',
@@ -291,7 +392,13 @@ class Navigator extends Base {
291
392
  ctrlKey : Neo.controlKeyDown,
292
393
  metaKey : Neo.metaKeyDown,
293
394
  shiftKey : Neo.shiftKeyDown
294
- })
395
+ });
396
+
397
+ // Navigation causes click if autoClick set.
398
+ // TabPanels work like this.
399
+ if (data.autoClick) {
400
+ this.clickItem(newActiveElement);
401
+ }
295
402
  }
296
403
 
297
404
  navigateGetAdjacent(direction = 1, data) {
@@ -111,7 +111,10 @@ class DomEvent extends Base {
111
111
  // console.log('fire', eventName, data, listeners, path);
112
112
 
113
113
  if (Array.isArray(listeners)) {
114
- listeners.forEach(listener => {
114
+ // Stop iteration if a handler returns false
115
+ listeners.every(listener => {
116
+ let result;
117
+
115
118
  if (listener && listener.fn) {
116
119
  delegationTargetId = me.verifyDelegationPath(listener, data.path);
117
120
 
@@ -131,7 +134,7 @@ class DomEvent extends Base {
131
134
 
132
135
  // Handler needs to know which actual target matched the delegate
133
136
  data.currentTarget = delegationTargetId;
134
- listener.fn.apply(listener.scope || globalThis, [data]);
137
+ result = listener.fn.apply(listener.scope || globalThis, [data]);
135
138
 
136
139
  if (!listener.bubble) {
137
140
  bubble = false;
@@ -139,6 +142,8 @@ class DomEvent extends Base {
139
142
  }
140
143
  }
141
144
  }
145
+ // If a listener returns false, we stop iterating the listeners
146
+ return result !== false
142
147
  });
143
148
  }
144
149
  }
@@ -153,7 +158,8 @@ class DomEvent extends Base {
153
158
  break;
154
159
  }
155
160
 
156
- if (!bubble) {
161
+ // Honour the Event cancelBubble property
162
+ if (!bubble || data.cancelBubble) {
157
163
  break;
158
164
  }
159
165
  }
@@ -168,7 +168,9 @@ class Focus extends CoreBase {
168
168
  * @protected
169
169
  */
170
170
  setComponentFocus(opts, containsFocus) {
171
- let data = {},
171
+ let data = {
172
+ relatedTarget : opts.data.relatedTarget
173
+ },
172
174
  components = opts.componentPath.map(id => Neo.getComponent(id)),
173
175
  handler;
174
176
 
@@ -63,7 +63,7 @@ StartTest(t => {
63
63
 
64
64
  await t.waitForSelector('.neo-picker-container');
65
65
 
66
- t.hasAttributeValue(inputField, 'aria-expanded', 'true');
66
+ await t.waitFor(() => inputField.getAttribute('aria-expanded') === 'true');
67
67
 
68
68
  // Roles correct
69
69
  t.hasAttributeValue('.neo-picker-container .neo-list', 'role', 'listbox');
@@ -80,6 +80,8 @@ StartTest(t => {
80
80
 
81
81
  t.hasAttributeValue(inputField, 'aria-activedescendant', 'neo-list-1__AL');
82
82
 
83
+ await t.waitFor(100);
84
+
83
85
  // Select that first item.
84
86
  await t.type(null, '[ENTER]');
85
87
 
@@ -100,7 +102,7 @@ StartTest(t => {
100
102
  t.is(blurCount, 1);
101
103
  });
102
104
 
103
- t.iit('Keyboard navigation', async t => {
105
+ t.it('Keyboard navigation', async t => {
104
106
  await setup();
105
107
  const blurEl = document.createElement('input');
106
108
  document.body.appendChild(blurEl);
@@ -128,6 +130,8 @@ StartTest(t => {
128
130
 
129
131
  await t.waitForSelectorNotFound('.neo-picker-container:visible');
130
132
 
133
+ await t.waitFor(100);
134
+
131
135
  t.is(inputField.value, 'Wyoming');
132
136
 
133
137
  await t.type(null, '[DOWN]');
@@ -139,11 +143,13 @@ StartTest(t => {
139
143
 
140
144
  await t.waitForSelector('.neo-list-item.neo-navigator-active-item:contains("Wisconsin")');
141
145
 
146
+ await t.waitFor(100);
147
+
142
148
  await t.type(null, '[ENTER]');
143
149
 
144
150
  await t.waitForSelectorNotFound('.neo-picker-container:visible');
145
151
 
146
- t.is(inputField.value, 'Wisconsin');
152
+ await t.waitFor(() => inputField.value === 'Wisconsin');
147
153
 
148
154
  await t.type(null, '[DOWN]');
149
155
 
@@ -183,6 +189,8 @@ StartTest(t => {
183
189
  // Picker Must show with Maryland activated
184
190
  await t.waitForSelector('.neo-list-item.neo-navigator-active-item:contains("Maryland")');
185
191
 
192
+ await t.waitFor(100);
193
+
186
194
  // Matches three states
187
195
  t.selectorCountIs('.neo-picker-container .neo-list-item', 3);
188
196
 
@@ -192,11 +200,24 @@ StartTest(t => {
192
200
  // Blur without selecting a value
193
201
  await t.type(null, '[TAB]');
194
202
 
195
- await t.waitFor(100)
203
+ await t.waitFor(100);
196
204
 
197
205
  // Inputs must have been cleared. Both typeahead and filter.
198
206
  t.isDeeply(t.query(`#${testId} input`).map(i => i.value), ['', '']);
199
207
 
200
208
  blurEl.remove();
201
209
  });
210
+
211
+ t.it('With store as data', async t => {
212
+ await setup({
213
+ labelText : 'Foo',
214
+ store : ['Foo', 'Bar', 'Bletch']
215
+ });
216
+ await t.click('.neo-field-trigger.fa-caret-down');
217
+
218
+ await t.waitForSelector('.neo-list-item:contains(Foo)');
219
+
220
+ // All data ityems represented
221
+ t.selectorCountIs('.neo-list-item', 3);
222
+ });
202
223
  });