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.
- package/apps/ServiceWorker.mjs +2 -2
- package/examples/ServiceWorker.mjs +2 -2
- package/examples/dialog/MainContainer.mjs +2 -0
- package/package.json +2 -2
- package/resources/data/deck/learnneo/p/Earthquakes.md +1 -4
- package/resources/scss/src/apps/portal/learn/ContentView.scss +1 -0
- package/resources/scss/src/list/Base.scss +8 -0
- package/src/DefaultConfig.mjs +2 -2
- package/src/component/Base.mjs +32 -0
- package/src/data/RecordFactory.mjs +2 -2
- package/src/dialog/Base.mjs +14 -7
- package/src/form/field/Picker.mjs +9 -1
- package/src/form/field/Select.mjs +23 -4
- package/src/form/field/Text.mjs +2 -0
- package/src/main/DomAccess.mjs +11 -3
- package/src/main/DomUtils.mjs +26 -3
- package/src/main/addon/Navigator.mjs +133 -26
- package/src/manager/DomEvent.mjs +9 -3
- package/src/manager/Focus.mjs +3 -1
- package/test/components/files/form/field/Select.mjs +25 -4
package/apps/ServiceWorker.mjs
CHANGED
@@ -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.
|
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.
|
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 -->
|
@@ -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 {
|
package/src/DefaultConfig.mjs
CHANGED
@@ -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.
|
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.
|
244
|
+
version: '6.10.15'
|
245
245
|
};
|
246
246
|
|
247
247
|
Object.assign(DefaultConfig, {
|
package/src/component/Base.mjs
CHANGED
@@ -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?.
|
215
|
+
return record?.isRecord;
|
216
216
|
}
|
217
217
|
|
218
218
|
/**
|
package/src/dialog/Base.mjs
CHANGED
@@ -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
|
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
|
-
|
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
|
-
|
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
|
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:
|
230
|
-
{name:
|
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();
|
package/src/form/field/Text.mjs
CHANGED
package/src/main/DomAccess.mjs
CHANGED
@@ -360,10 +360,18 @@ class DomAccess extends Base {
|
|
360
360
|
let node = this.getElement(data.id);
|
361
361
|
|
362
362
|
if (node) {
|
363
|
-
|
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
|
-
|
366
|
-
|
372
|
+
if (Neo.isNumber(node.selectionStart)) {
|
373
|
+
node.selectionStart = node.selectionEnd = node.value.length;
|
374
|
+
}
|
367
375
|
}
|
368
376
|
}
|
369
377
|
|
package/src/main/DomUtils.mjs
CHANGED
@@ -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')
|
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
|
-
|
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.
|
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',
|
84
|
-
subject.addEventListener('click',
|
85
|
-
subject.addEventListener('focusin',
|
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
|
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
|
-
|
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 =
|
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) {
|
package/src/manager/DomEvent.mjs
CHANGED
@@ -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
|
-
|
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
|
-
|
161
|
+
// Honour the Event cancelBubble property
|
162
|
+
if (!bubble || data.cancelBubble) {
|
157
163
|
break;
|
158
164
|
}
|
159
165
|
}
|
package/src/manager/Focus.mjs
CHANGED
@@ -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.
|
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.
|
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.
|
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
|
});
|