/
focuscycler.js
292 lines (258 loc) · 7.57 KB
/
focuscycler.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
/**
* @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module ui/focuscycler
*/
import global from '@ckeditor/ckeditor5-utils/src/dom/global';
/**
* A utility class that helps cycling over focusable {@link module:ui/view~View views} in a
* {@link module:ui/viewcollection~ViewCollection} when the focus is tracked by the
* {@link module:utils/focustracker~FocusTracker} instance. It helps implementing keyboard
* navigation in HTML forms, toolbars, lists and the like.
*
* To work properly it requires:
* * a collection of focusable (HTML `tabindex` attribute) views that implement the `focus()` method,
* * an associated focus tracker to determine which view is focused.
*
* A simple cycler setup can look like this:
*
* const focusables = new ViewCollection();
* const focusTracker = new FocusTracker();
*
* // Add focusable views to the focus tracker.
* focusTracker.add( ... );
*
* Then, the cycler can be used manually:
*
* const cycler = new FocusCycler( { focusables, focusTracker } );
*
* // Will focus the first focusable view in #focusables.
* cycler.focusFirst();
*
* // Will log the next focusable item in #focusables.
* console.log( cycler.next );
*
* Alternatively, it can work side by side with the {@link module:utils/keystrokehandler~KeystrokeHandler}:
*
* const keystrokeHandler = new KeystrokeHandler();
*
* // Activate the keystroke handler.
* keystrokeHandler.listenTo( sourceOfEvents );
*
* const cycler = new FocusCycler( {
* focusables, focusTracker, keystrokeHandler,
* actions: {
* // When arrowup of arrowleft is detected by the #keystrokeHandler,
* // focusPrevious() will be called on the cycler.
* focusPrevious: [ 'arrowup', 'arrowleft' ],
* }
* } );
*
* Check out the {@glink framework/guides/deep-dive/ui/focus-tracking "Deep dive into focus tracking" guide} to learn more.
*/
export default class FocusCycler {
/**
* Creates an instance of the focus cycler utility.
*
* @param {Object} options Configuration options.
* @param {module:utils/collection~Collection|Object} options.focusables
* @param {module:utils/focustracker~FocusTracker} options.focusTracker
* @param {module:utils/keystrokehandler~KeystrokeHandler} [options.keystrokeHandler]
* @param {Object} [options.actions]
*/
constructor( options ) {
Object.assign( this, options );
/**
* A {@link module:ui/view~View view} collection that the cycler operates on.
*
* @readonly
* @member {module:utils/collection~Collection} #focusables
*/
/**
* A focus tracker instance that the cycler uses to determine the current focus
* state in {@link #focusables}.
*
* @readonly
* @member {module:utils/focustracker~FocusTracker} #focusTracker
*/
/**
* An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}
* which can respond to certain keystrokes and cycle the focus.
*
* @readonly
* @member {module:utils/keystrokehandler~KeystrokeHandler} #keystrokeHandler
*/
/**
* Actions that the cycler can take when a keystroke is pressed. Requires
* `options.keystrokeHandler` to be passed and working. When an action is
* performed, `preventDefault` and `stopPropagation` will be called on the event
* the keystroke fired in the DOM.
*
* actions: {
* // Will call #focusPrevious() when arrowleft or arrowup is pressed.
* focusPrevious: [ 'arrowleft', 'arrowup' ],
*
* // Will call #focusNext() when arrowdown is pressed.
* focusNext: 'arrowdown'
* }
*
* @readonly
* @member {Object} #actions
*/
if ( options.actions && options.keystrokeHandler ) {
for ( const methodName in options.actions ) {
let actions = options.actions[ methodName ];
if ( typeof actions == 'string' ) {
actions = [ actions ];
}
for ( const keystroke of actions ) {
options.keystrokeHandler.set( keystroke, ( data, cancel ) => {
this[ methodName ]();
cancel();
} );
}
}
}
}
/**
* Returns the first focusable view in {@link #focusables}.
* Returns `null` if there is none.
*
* @readonly
* @member {module:ui/view~View|null} #first
*/
get first() {
return this.focusables.find( isFocusable ) || null;
}
/**
* Returns the last focusable view in {@link #focusables}.
* Returns `null` if there is none.
*
* @readonly
* @member {module:ui/view~View|null} #last
*/
get last() {
return this.focusables.filter( isFocusable ).slice( -1 )[ 0 ] || null;
}
/**
* Returns the next focusable view in {@link #focusables} based on {@link #current}.
* Returns `null` if there is none.
*
* @readonly
* @member {module:ui/view~View|null} #next
*/
get next() {
return this._getFocusableItem( 1 );
}
/**
* Returns the previous focusable view in {@link #focusables} based on {@link #current}.
* Returns `null` if there is none.
*
* @readonly
* @member {module:ui/view~View|null} #previous
*/
get previous() {
return this._getFocusableItem( -1 );
}
/**
* An index of the view in the {@link #focusables} which is focused according
* to {@link #focusTracker}. Returns `null` when there is no such view.
*
* @readonly
* @member {Number|null} #current
*/
get current() {
let index = null;
// There's no focused view in the focusables.
if ( this.focusTracker.focusedElement === null ) {
return null;
}
this.focusables.find( ( view, viewIndex ) => {
const focused = view.element === this.focusTracker.focusedElement;
if ( focused ) {
index = viewIndex;
}
return focused;
} );
return index;
}
/**
* Focuses the {@link #first} item in {@link #focusables}.
*/
focusFirst() {
this._focus( this.first );
}
/**
* Focuses the {@link #last} item in {@link #focusables}.
*/
focusLast() {
this._focus( this.last );
}
/**
* Focuses the {@link #next} item in {@link #focusables}.
*/
focusNext() {
this._focus( this.next );
}
/**
* Focuses the {@link #previous} item in {@link #focusables}.
*/
focusPrevious() {
this._focus( this.previous );
}
/**
* Focuses the given view if it exists.
*
* @protected
* @param {module:ui/view~View} view
*/
_focus( view ) {
if ( view ) {
view.focus();
}
}
/**
* Returns the next or previous focusable view in {@link #focusables} with respect
* to {@link #current}.
*
* @protected
* @param {Number} step Either `1` for checking forward from {@link #current} or
* `-1` for checking backwards.
* @returns {module:ui/view~View|null}
*/
_getFocusableItem( step ) {
// Cache for speed.
const current = this.current;
const collectionLength = this.focusables.length;
if ( !collectionLength ) {
return null;
}
// Start from the beginning if no view is focused.
// https://github.com/ckeditor/ckeditor5-ui/issues/206
if ( current === null ) {
return this[ step === 1 ? 'first' : 'last' ];
}
// Cycle in both directions.
let index = ( current + collectionLength + step ) % collectionLength;
do {
const view = this.focusables.get( index );
// TODO: Check if view is visible.
if ( isFocusable( view ) ) {
return view;
}
// Cycle in both directions.
index = ( index + collectionLength + step ) % collectionLength;
} while ( index !== current );
return null;
}
}
// Checks whether a view is focusable.
//
// @private
// @param {module:ui/view~View} view A view to be checked.
// @returns {Boolean}
function isFocusable( view ) {
return !!( view.focus && global.window.getComputedStyle( view.element ).display != 'none' );
}