@lumino/widgets
Version:
Lumino Widgets
369 lines (320 loc) • 10.8 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { ArrayExt, find, max } from '@lumino/algorithm';
import { IDisposable } from '@lumino/disposable';
import { ISignal, Signal } from '@lumino/signaling';
import { Widget } from './widget';
/**
* A class which tracks focus among a set of widgets.
*
* This class is useful when code needs to keep track of the most
* recently focused widget(s) among a set of related widgets.
*/
export class FocusTracker<T extends Widget> implements IDisposable {
/**
* Dispose of the resources held by the tracker.
*/
dispose(): void {
// Do nothing if the tracker is already disposed.
if (this._counter < 0) {
return;
}
// Mark the tracker as disposed.
this._counter = -1;
// Clear the connections for the tracker.
Signal.clearData(this);
// Remove all event listeners.
for (const widget of this._widgets) {
widget.node.removeEventListener('focus', this, true);
widget.node.removeEventListener('blur', this, true);
}
// Clear the internal data structures.
this._activeWidget = null;
this._currentWidget = null;
this._nodes.clear();
this._numbers.clear();
this._widgets.length = 0;
}
/**
* A signal emitted when the current widget has changed.
*/
get currentChanged(): ISignal<this, FocusTracker.IChangedArgs<T>> {
return this._currentChanged;
}
/**
* A signal emitted when the active widget has changed.
*/
get activeChanged(): ISignal<this, FocusTracker.IChangedArgs<T>> {
return this._activeChanged;
}
/**
* A flag indicating whether the tracker is disposed.
*/
get isDisposed(): boolean {
return this._counter < 0;
}
/**
* The current widget in the tracker.
*
* #### Notes
* The current widget is the widget among the tracked widgets which
* has the *descendant node* which has most recently been focused.
*
* The current widget will not be updated if the node loses focus. It
* will only be updated when a different tracked widget gains focus.
*
* If the current widget is removed from the tracker, the previous
* current widget will be restored.
*
* This behavior is intended to follow a user's conceptual model of
* a semantically "current" widget, where the "last thing of type X"
* to be interacted with is the "current instance of X", regardless
* of whether that instance still has focus.
*/
get currentWidget(): T | null {
return this._currentWidget;
}
/**
* The active widget in the tracker.
*
* #### Notes
* The active widget is the widget among the tracked widgets which
* has the *descendant node* which is currently focused.
*/
get activeWidget(): T | null {
return this._activeWidget;
}
/**
* A read only array of the widgets being tracked.
*/
get widgets(): ReadonlyArray<T> {
return this._widgets;
}
/**
* Get the focus number for a particular widget in the tracker.
*
* @param widget - The widget of interest.
*
* @returns The focus number for the given widget, or `-1` if the
* widget has not had focus since being added to the tracker, or
* is not contained by the tracker.
*
* #### Notes
* The focus number indicates the relative order in which the widgets
* have gained focus. A widget with a larger number has gained focus
* more recently than a widget with a smaller number.
*
* The `currentWidget` will always have the largest focus number.
*
* All widgets start with a focus number of `-1`, which indicates that
* the widget has not been focused since being added to the tracker.
*/
focusNumber(widget: T): number {
let n = this._numbers.get(widget);
return n === undefined ? -1 : n;
}
/**
* Test whether the focus tracker contains a given widget.
*
* @param widget - The widget of interest.
*
* @returns `true` if the widget is tracked, `false` otherwise.
*/
has(widget: T): boolean {
return this._numbers.has(widget);
}
/**
* Add a widget to the focus tracker.
*
* @param widget - The widget of interest.
*
* #### Notes
* A widget will be automatically removed from the tracker if it
* is disposed after being added.
*
* If the widget is already tracked, this is a no-op.
*/
add(widget: T): void {
// Do nothing if the widget is already tracked.
if (this._numbers.has(widget)) {
return;
}
// Test whether the widget has focus.
let focused = widget.node.contains(document.activeElement);
// Set up the initial focus number.
let n = focused ? this._counter++ : -1;
// Add the widget to the internal data structures.
this._widgets.push(widget);
this._numbers.set(widget, n);
this._nodes.set(widget.node, widget);
// Set up the event listeners. The capturing phase must be used
// since the 'focus' and 'blur' events don't bubble and Firefox
// doesn't support the 'focusin' or 'focusout' events.
widget.node.addEventListener('focus', this, true);
widget.node.addEventListener('blur', this, true);
// Connect the disposed signal handler.
widget.disposed.connect(this._onWidgetDisposed, this);
// Set the current and active widgets if needed.
if (focused) {
this._setWidgets(widget, widget);
}
}
/**
* Remove a widget from the focus tracker.
*
* #### Notes
* If the widget is the `currentWidget`, the previous current widget
* will become the new `currentWidget`.
*
* A widget will be automatically removed from the tracker if it
* is disposed after being added.
*
* If the widget is not tracked, this is a no-op.
*/
remove(widget: T): void {
// Bail early if the widget is not tracked.
if (!this._numbers.has(widget)) {
return;
}
// Disconnect the disposed signal handler.
widget.disposed.disconnect(this._onWidgetDisposed, this);
// Remove the event listeners.
widget.node.removeEventListener('focus', this, true);
widget.node.removeEventListener('blur', this, true);
// Remove the widget from the internal data structures.
ArrayExt.removeFirstOf(this._widgets, widget);
this._nodes.delete(widget.node);
this._numbers.delete(widget);
// Bail early if the widget is not the current widget.
if (this._currentWidget !== widget) {
return;
}
// Filter the widgets for those which have had focus.
let valid = this._widgets.filter(w => this._numbers.get(w) !== -1);
// Get the valid widget with the max focus number.
let previous =
max(valid, (first, second) => {
let a = this._numbers.get(first)!;
let b = this._numbers.get(second)!;
return a - b;
}) || null;
// Set the current and active widgets.
this._setWidgets(previous, null);
}
/**
* Handle the DOM events for the focus tracker.
*
* @param event - The DOM event sent to the panel.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the tracked nodes. It should
* not be called directly by user code.
*/
handleEvent(event: Event): void {
switch (event.type) {
case 'focus':
this._evtFocus(event as FocusEvent);
break;
case 'blur':
this._evtBlur(event as FocusEvent);
break;
}
}
/**
* Set the current and active widgets for the tracker.
*/
private _setWidgets(current: T | null, active: T | null): void {
// Swap the current widget.
let oldCurrent = this._currentWidget;
this._currentWidget = current;
// Swap the active widget.
let oldActive = this._activeWidget;
this._activeWidget = active;
// Emit the `currentChanged` signal if needed.
if (oldCurrent !== current) {
this._currentChanged.emit({ oldValue: oldCurrent, newValue: current });
}
// Emit the `activeChanged` signal if needed.
if (oldActive !== active) {
this._activeChanged.emit({ oldValue: oldActive, newValue: active });
}
}
/**
* Handle the `'focus'` event for a tracked widget.
*/
private _evtFocus(event: FocusEvent): void {
// Find the widget which gained focus, which is known to exist.
let widget = this._nodes.get(event.currentTarget as HTMLElement)!;
// Update the focus number if necessary.
if (widget !== this._currentWidget) {
this._numbers.set(widget, this._counter++);
}
// Set the current and active widgets.
this._setWidgets(widget, widget);
}
/**
* Handle the `'blur'` event for a tracked widget.
*/
private _evtBlur(event: FocusEvent): void {
// Find the widget which lost focus, which is known to exist.
let widget = this._nodes.get(event.currentTarget as HTMLElement)!;
// Get the node which being focused after this blur.
let focusTarget = event.relatedTarget as HTMLElement;
// If no other node is being focused, clear the active widget.
if (!focusTarget) {
this._setWidgets(this._currentWidget, null);
return;
}
// Bail if the focus widget is not changing.
if (widget.node.contains(focusTarget)) {
return;
}
// If no tracked widget is being focused, clear the active widget.
if (!find(this._widgets, w => w.node.contains(focusTarget))) {
this._setWidgets(this._currentWidget, null);
return;
}
}
/**
* Handle the `disposed` signal for a tracked widget.
*/
private _onWidgetDisposed(sender: T): void {
this.remove(sender);
}
private _counter = 0;
private _widgets: T[] = [];
private _activeWidget: T | null = null;
private _currentWidget: T | null = null;
private _numbers = new Map<T, number>();
private _nodes = new Map<HTMLElement, T>();
private _activeChanged = new Signal<this, FocusTracker.IChangedArgs<T>>(this);
private _currentChanged = new Signal<this, FocusTracker.IChangedArgs<T>>(
this
);
}
/**
* The namespace for the `FocusTracker` class statics.
*/
export namespace FocusTracker {
/**
* An arguments object for the changed signals.
*/
export interface IChangedArgs<T extends Widget> {
/**
* The old value for the widget.
*/
oldValue: T | null;
/**
* The new value for the widget.
*/
newValue: T | null;
}
}