UNPKG

10.8 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3/*-----------------------------------------------------------------------------
4| Copyright (c) 2014-2017, PhosphorJS Contributors
5|
6| Distributed under the terms of the BSD 3-Clause License.
7|
8| The full license is in the file LICENSE, distributed with this software.
9|----------------------------------------------------------------------------*/
10import { ArrayExt, each, filter, find, max } from '@lumino/algorithm';
11
12import { IDisposable } from '@lumino/disposable';
13
14import { ISignal, Signal } from '@lumino/signaling';
15
16import { Widget } from './widget';
17
18/**
19 * A class which tracks focus among a set of widgets.
20 *
21 * This class is useful when code needs to keep track of the most
22 * recently focused widget(s) among a set of related widgets.
23 */
24export class FocusTracker<T extends Widget> implements IDisposable {
25 /**
26 * Dispose of the resources held by the tracker.
27 */
28 dispose(): void {
29 // Do nothing if the tracker is already disposed.
30 if (this._counter < 0) {
31 return;
32 }
33
34 // Mark the tracker as disposed.
35 this._counter = -1;
36
37 // Clear the connections for the tracker.
38 Signal.clearData(this);
39
40 // Remove all event listeners.
41 each(this._widgets, w => {
42 w.node.removeEventListener('focus', this, true);
43 w.node.removeEventListener('blur', this, true);
44 });
45
46 // Clear the internal data structures.
47 this._activeWidget = null;
48 this._currentWidget = null;
49 this._nodes.clear();
50 this._numbers.clear();
51 this._widgets.length = 0;
52 }
53
54 /**
55 * A signal emitted when the current widget has changed.
56 */
57 get currentChanged(): ISignal<this, FocusTracker.IChangedArgs<T>> {
58 return this._currentChanged;
59 }
60
61 /**
62 * A signal emitted when the active widget has changed.
63 */
64 get activeChanged(): ISignal<this, FocusTracker.IChangedArgs<T>> {
65 return this._activeChanged;
66 }
67
68 /**
69 * A flag indicating whether the tracker is disposed.
70 */
71 get isDisposed(): boolean {
72 return this._counter < 0;
73 }
74
75 /**
76 * The current widget in the tracker.
77 *
78 * #### Notes
79 * The current widget is the widget among the tracked widgets which
80 * has the *descendant node* which has most recently been focused.
81 *
82 * The current widget will not be updated if the node loses focus. It
83 * will only be updated when a different tracked widget gains focus.
84 *
85 * If the current widget is removed from the tracker, the previous
86 * current widget will be restored.
87 *
88 * This behavior is intended to follow a user's conceptual model of
89 * a semantically "current" widget, where the "last thing of type X"
90 * to be interacted with is the "current instance of X", regardless
91 * of whether that instance still has focus.
92 */
93 get currentWidget(): T | null {
94 return this._currentWidget;
95 }
96
97 /**
98 * The active widget in the tracker.
99 *
100 * #### Notes
101 * The active widget is the widget among the tracked widgets which
102 * has the *descendant node* which is currently focused.
103 */
104 get activeWidget(): T | null {
105 return this._activeWidget;
106 }
107
108 /**
109 * A read only array of the widgets being tracked.
110 */
111 get widgets(): ReadonlyArray<T> {
112 return this._widgets;
113 }
114
115 /**
116 * Get the focus number for a particular widget in the tracker.
117 *
118 * @param widget - The widget of interest.
119 *
120 * @returns The focus number for the given widget, or `-1` if the
121 * widget has not had focus since being added to the tracker, or
122 * is not contained by the tracker.
123 *
124 * #### Notes
125 * The focus number indicates the relative order in which the widgets
126 * have gained focus. A widget with a larger number has gained focus
127 * more recently than a widget with a smaller number.
128 *
129 * The `currentWidget` will always have the largest focus number.
130 *
131 * All widgets start with a focus number of `-1`, which indicates that
132 * the widget has not been focused since being added to the tracker.
133 */
134 focusNumber(widget: T): number {
135 let n = this._numbers.get(widget);
136 return n === undefined ? -1 : n;
137 }
138
139 /**
140 * Test whether the focus tracker contains a given widget.
141 *
142 * @param widget - The widget of interest.
143 *
144 * @returns `true` if the widget is tracked, `false` otherwise.
145 */
146 has(widget: T): boolean {
147 return this._numbers.has(widget);
148 }
149
150 /**
151 * Add a widget to the focus tracker.
152 *
153 * @param widget - The widget of interest.
154 *
155 * #### Notes
156 * A widget will be automatically removed from the tracker if it
157 * is disposed after being added.
158 *
159 * If the widget is already tracked, this is a no-op.
160 */
161 add(widget: T): void {
162 // Do nothing if the widget is already tracked.
163 if (this._numbers.has(widget)) {
164 return;
165 }
166
167 // Test whether the widget has focus.
168 let focused = widget.node.contains(document.activeElement);
169
170 // Set up the initial focus number.
171 let n = focused ? this._counter++ : -1;
172
173 // Add the widget to the internal data structures.
174 this._widgets.push(widget);
175 this._numbers.set(widget, n);
176 this._nodes.set(widget.node, widget);
177
178 // Set up the event listeners. The capturing phase must be used
179 // since the 'focus' and 'blur' events don't bubble and Firefox
180 // doesn't support the 'focusin' or 'focusout' events.
181 widget.node.addEventListener('focus', this, true);
182 widget.node.addEventListener('blur', this, true);
183
184 // Connect the disposed signal handler.
185 widget.disposed.connect(this._onWidgetDisposed, this);
186
187 // Set the current and active widgets if needed.
188 if (focused) {
189 this._setWidgets(widget, widget);
190 }
191 }
192
193 /**
194 * Remove a widget from the focus tracker.
195 *
196 * #### Notes
197 * If the widget is the `currentWidget`, the previous current widget
198 * will become the new `currentWidget`.
199 *
200 * A widget will be automatically removed from the tracker if it
201 * is disposed after being added.
202 *
203 * If the widget is not tracked, this is a no-op.
204 */
205 remove(widget: T): void {
206 // Bail early if the widget is not tracked.
207 if (!this._numbers.has(widget)) {
208 return;
209 }
210
211 // Disconnect the disposed signal handler.
212 widget.disposed.disconnect(this._onWidgetDisposed, this);
213
214 // Remove the event listeners.
215 widget.node.removeEventListener('focus', this, true);
216 widget.node.removeEventListener('blur', this, true);
217
218 // Remove the widget from the internal data structures.
219 ArrayExt.removeFirstOf(this._widgets, widget);
220 this._nodes.delete(widget.node);
221 this._numbers.delete(widget);
222
223 // Bail early if the widget is not the current widget.
224 if (this._currentWidget !== widget) {
225 return;
226 }
227
228 // Filter the widgets for those which have had focus.
229 let valid = filter(this._widgets, w => this._numbers.get(w) !== -1);
230
231 // Get the valid widget with the max focus number.
232 let previous =
233 max(valid, (first, second) => {
234 let a = this._numbers.get(first)!;
235 let b = this._numbers.get(second)!;
236 return a - b;
237 }) || null;
238
239 // Set the current and active widgets.
240 this._setWidgets(previous, null);
241 }
242
243 /**
244 * Handle the DOM events for the focus tracker.
245 *
246 * @param event - The DOM event sent to the panel.
247 *
248 * #### Notes
249 * This method implements the DOM `EventListener` interface and is
250 * called in response to events on the tracked nodes. It should
251 * not be called directly by user code.
252 */
253 handleEvent(event: Event): void {
254 switch (event.type) {
255 case 'focus':
256 this._evtFocus(event as FocusEvent);
257 break;
258 case 'blur':
259 this._evtBlur(event as FocusEvent);
260 break;
261 }
262 }
263
264 /**
265 * Set the current and active widgets for the tracker.
266 */
267 private _setWidgets(current: T | null, active: T | null): void {
268 // Swap the current widget.
269 let oldCurrent = this._currentWidget;
270 this._currentWidget = current;
271
272 // Swap the active widget.
273 let oldActive = this._activeWidget;
274 this._activeWidget = active;
275
276 // Emit the `currentChanged` signal if needed.
277 if (oldCurrent !== current) {
278 this._currentChanged.emit({ oldValue: oldCurrent, newValue: current });
279 }
280
281 // Emit the `activeChanged` signal if needed.
282 if (oldActive !== active) {
283 this._activeChanged.emit({ oldValue: oldActive, newValue: active });
284 }
285 }
286
287 /**
288 * Handle the `'focus'` event for a tracked widget.
289 */
290 private _evtFocus(event: FocusEvent): void {
291 // Find the widget which gained focus, which is known to exist.
292 let widget = this._nodes.get(event.currentTarget as HTMLElement)!;
293
294 // Update the focus number if necessary.
295 if (widget !== this._currentWidget) {
296 this._numbers.set(widget, this._counter++);
297 }
298
299 // Set the current and active widgets.
300 this._setWidgets(widget, widget);
301 }
302
303 /**
304 * Handle the `'blur'` event for a tracked widget.
305 */
306 private _evtBlur(event: FocusEvent): void {
307 // Find the widget which lost focus, which is known to exist.
308 let widget = this._nodes.get(event.currentTarget as HTMLElement)!;
309
310 // Get the node which being focused after this blur.
311 let focusTarget = event.relatedTarget as HTMLElement;
312
313 // If no other node is being focused, clear the active widget.
314 if (!focusTarget) {
315 this._setWidgets(this._currentWidget, null);
316 return;
317 }
318
319 // Bail if the focus widget is not changing.
320 if (widget.node.contains(focusTarget)) {
321 return;
322 }
323
324 // If no tracked widget is being focused, clear the active widget.
325 if (!find(this._widgets, w => w.node.contains(focusTarget))) {
326 this._setWidgets(this._currentWidget, null);
327 return;
328 }
329 }
330
331 /**
332 * Handle the `disposed` signal for a tracked widget.
333 */
334 private _onWidgetDisposed(sender: T): void {
335 this.remove(sender);
336 }
337
338 private _counter = 0;
339 private _widgets: T[] = [];
340 private _activeWidget: T | null = null;
341 private _currentWidget: T | null = null;
342 private _numbers = new Map<T, number>();
343 private _nodes = new Map<HTMLElement, T>();
344 private _activeChanged = new Signal<this, FocusTracker.IChangedArgs<T>>(this);
345 private _currentChanged = new Signal<this, FocusTracker.IChangedArgs<T>>(
346 this
347 );
348}
349
350/**
351 * The namespace for the `FocusTracker` class statics.
352 */
353export namespace FocusTracker {
354 /**
355 * An arguments object for the changed signals.
356 */
357 export interface IChangedArgs<T extends Widget> {
358 /**
359 * The old value for the widget.
360 */
361 oldValue: T | null;
362
363 /**
364 * The new value for the widget.
365 */
366 newValue: T | null;
367 }
368}