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 | |----------------------------------------------------------------------------*/
|
10 | import { ArrayExt, each, filter, find, max } from '@lumino/algorithm';
|
11 |
|
12 | import { IDisposable } from '@lumino/disposable';
|
13 |
|
14 | import { ISignal, Signal } from '@lumino/signaling';
|
15 |
|
16 | import { 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 | */
|
24 | export 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 | */
|
353 | export 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 | }
|