@eclipse-scout/core
Version:
Eclipse Scout runtime
328 lines (290 loc) • 13.7 kB
text/typescript
/*
* Copyright (c) 2010, 2024 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {filters, FocusManager, focusUtils, graphics, keys, objects, Point, scout, scrollbars} from '../index';
import $ from 'jquery';
import KeyDownEvent = JQuery.KeyDownEvent;
import FocusInEvent = JQuery.FocusInEvent;
import FocusOutEvent = JQuery.FocusOutEvent;
import TriggeredEvent = JQuery.TriggeredEvent;
/**
* A focus context is associated with a $container, and controls how to focus elements within that $container.
*/
export class FocusContext {
focusManager: FocusManager;
/** variable to store the last valid focus position; used to restore focus once being re-activated. */
lastValidFocusedElement: HTMLElement;
focusedElement: HTMLElement;
prepared: boolean;
$container: JQuery;
/** Notice: every listener is installed on $container and not on $field level, except 'remove' listener because it does not bubble. */
protected _keyDownListener: (e: KeyDownEvent) => void;
protected _focusInListener: (e: FocusInEvent) => void;
protected _focusOutListener: (e: FocusOutEvent) => void;
protected _unfocusableListener: (e: TriggeredEvent) => void;
protected _removeListener: (e: TriggeredEvent) => void;
constructor($container: JQuery, focusManager: FocusManager) {
this.$container = $container;
this.focusManager = focusManager;
this.lastValidFocusedElement = null;
this.focusedElement = null;
this.prepared = false;
this._keyDownListener = this._onKeyDown.bind(this);
this._focusInListener = this._onFocusIn.bind(this);
this._focusOutListener = this._onFocusOut.bind(this);
this._unfocusableListener = this._onUnfocusable.bind(this);
this._removeListener = this._onRemove.bind(this);
}
ready() {
if (this.prepared) {
return;
}
this.$container
.on('keydown', this._keyDownListener)
.on('focusin', this._focusInListener)
.on('focusout', this._focusOutListener)
.on('hide disable', this._unfocusableListener);
this.prepared = true;
if (this.lastValidFocusedElement) {
// If a widget requested the focus while focus context was not ready, lastValidFocusedElement is set to that widget but the widget itself is not focused.
// -> Ensure that widget is focused
this.restoreFocus();
}
}
dispose() {
if (!this.prepared) {
return;
}
this.$container
.off('keydown', this._keyDownListener)
.off('focusin', this._focusInListener)
.off('focusout', this._focusOutListener)
.off('hide disable', this._unfocusableListener);
$(this.focusedElement).off('remove', this._removeListener);
}
/**
* Method invoked once a 'keydown' event is fired to control proper tab cycle.
*/
protected _onKeyDown(event: KeyDownEvent) {
if (event.which === keys.TAB) {
this.focusNextTabbable(!event.shiftKey, event);
}
}
/**
* Starting from the current `activeElement`, focuses the next or previous valid element within this focus context.
* If the context does not contain tabbable elements or the `activeElement` is not part of this context, nothing happens.
*
* If a TAB key event is given and the target element is the next element in the natural DOM order, the focus is not
* changed by this method. Instead, the focus is expected to be automatically changed by the browser (default tabbing
* behavior).
*/
focusNextTabbable(forward = true, event?: KeyDownEvent) {
let $allFocusableElements = this.$container.find(':tabbable');
let $focusableElements = $allFocusableElements.filter((index, elem) => !this.focusManager.isElementCovertByGlassPane(elem));
if ($focusableElements.length === 0) {
return; // no focusable elements -> nothing to do
}
let activeElement = this.$container.activeElement(true);
let activeElementIndex = $focusableElements.index(activeElement);
let firstFocusableElement = $focusableElements.first()[0];
let lastFocusableElement = $focusableElements.last()[0];
let elementToFocus = null;
let explicitFocus = false;
if (forward) {
// --- FORWARD ---
// If the last focusable element is currently focused, or the focus is on the container, set the focus to the first focusable element
if (activeElement === lastFocusableElement || activeElement === this.$container[0]) {
elementToFocus = firstFocusableElement;
explicitFocus = true;
} else if (activeElementIndex < $focusableElements.length - 1) {
elementToFocus = $focusableElements.get(activeElementIndex + 1);
// Check if next element that would be focused by the browser is covered by a glass pane. If yes, don't let the browser focus it
explicitFocus = $allFocusableElements.get($allFocusableElements.index(activeElement) + 1) !== elementToFocus;
}
} else {
// --- BACKWARD ---
// If the first focusable element is currently focused, or the focus is on the container, set the focus to the last focusable element
if (activeElement === firstFocusableElement || activeElement === this.$container[0]) {
elementToFocus = lastFocusableElement;
explicitFocus = true;
} else if (activeElementIndex > 0) {
elementToFocus = $focusableElements.get(activeElementIndex - 1);
// Check if next element that would be focused by the browser is covered by a glass pane. If yes, don't let the browser focus it
explicitFocus = $allFocusableElements.get($allFocusableElements.index(activeElement) - 1) !== elementToFocus;
}
}
if (!elementToFocus) {
return; // no valid element found
}
// Set focus manually if the target element does not correspond to the next element according to the
// DOM order, or if there is currently no keyboard event in progress that will
if (event && !explicitFocus) {
// Don't change the focus here --> will be handled by browser
} else {
// Set focus manually to the target element
this.validateAndSetFocus(elementToFocus, null, {
selectText: elementToFocus.tagName === 'INPUT'
});
$.suppressEvent(event);
}
let $focusableElement = $(elementToFocus);
$focusableElement.addClass('keyboard-navigation');
// Check if new focused element is currently visible, otherwise scroll the container
let containerBounds = graphics.offsetBounds($focusableElement);
let $scrollable = $focusableElement.scrollParent();
if (!scrollbars.isLocationInView(new Point(containerBounds.x, containerBounds.y), $scrollable)) {
scrollbars.scrollTo($scrollable, $focusableElement);
}
}
/**
* Method invoked once a 'focusin' event is fired by this context's $container or one of its child controls.
*/
protected _onFocusIn(event: FocusInEvent) {
let $target = $(event.target);
$target.on('remove', this._removeListener);
let target = $target[0];
this.focusedElement = target;
// Do not update current focus context nor validate focus if target is $entryPoint.
// That is because focusing the $entryPoint is done whenever no control is currently focusable, e.g. due to glass panes.
if (target === this.$container.entryPoint(true)) {
return;
}
// Make this context the active context (nothing done if already active) and validate the focus event.
this.focusManager._pushIfAbsentElseMoveTop(this);
this.validateAndSetFocus(target);
event.stopPropagation(); // Prevent a possible 'parent' focus context to consume this event. Otherwise, that 'parent context' would be activated as well.
}
/**
* Method invoked once a 'focusout' event is fired by this context's $container or one of its child controls.
*/
protected _onFocusOut(event: FocusOutEvent) {
$(event.target).off('remove', this._removeListener);
$(this.focusedElement).removeClass('keyboard-navigation');
this.focusedElement = null;
event.stopPropagation(); // Prevent a possible 'parent' focus context to consume this event. Otherwise, that 'parent context' would be activated as well.
}
/**
* Method invoked once a child element of this context's $container is removed.
*/
protected _onRemove(event: TriggeredEvent) {
// This listener is installed on the focused element only.
this.validateAndSetFocus(null, filters.notSameFilter(event.target));
event.stopPropagation(); // Prevent a possible 'parent' focus context to consume this event.
}
/**
* Function invoked once a child element of this context's $container is hidden or disabled
* and it cannot have the focus anymore. In that case we need to look for a new focusable
* element.
*/
protected _onUnfocusable(event: TriggeredEvent) {
if ($(event.target).isOrHas(this.lastValidFocusedElement)) {
this.validateAndSetFocus(null, filters.notSameFilter(event.target));
event.stopPropagation(); // Prevent a possible 'parent' focus context to consume this event.
}
}
/**
* Focuses the given element if being a child of this context's container and matches the given filter (if provided).
*
* @param element
* the element to focus, or null to focus the context's first focusable element matching the given filter.
* @param filter
* filter that controls which element should be focused, or null to accept all focusable candidates.
* @param options options to customize to focus
*/
validateAndSetFocus(element?: HTMLElement, filter?: () => boolean, options?: FocusContextFocusOptions) {
// Ensure the element to be a child element, or set it to null otherwise.
if (element && !$.contains(this.$container[0], element)) {
element = null;
}
let elementToFocus = null;
if (!element) {
elementToFocus = this.focusManager.findFirstFocusableElement(this.$container, filter);
} else if (!filter || filter.call(element)) {
elementToFocus = element;
} else {
elementToFocus = this.focusManager.findFirstFocusableElement(this.$container, filter);
}
// Store the element to be focused, and regardless of whether currently covert by a glass pane or the focus manager is not active. That is for later focus restore.
this.lastValidFocusedElement = elementToFocus;
// Focus the element.
this._focus(elementToFocus, options);
}
/**
* Calls {@link #validateAndSetFocus} with {@link #lastValidFocusedElement}.
*/
validateFocus(filter?: () => boolean) {
this.validateAndSetFocus(this.lastValidFocusedElement, filter);
}
/**
* Restores the focus on the last valid focused element. Does nothing, if there is no last valid focused element.
*/
restoreFocus() {
if (this.lastValidFocusedElement) {
this._focus(this.lastValidFocusedElement);
}
}
/**
* Focuses the requested element.
*
* @param element the element to focus, or null to focus the context's first focusable element matching the given filter.
* @param options options to customize to focus
*/
protected _focus(elementToFocus: HTMLElement, options?: FocusContextFocusOptions) {
options = options || {};
// Only focus element if focus manager is active
if (!this.focusManager.active) {
return;
}
if (!this.prepared) {
return;
}
// Check whether the element is covert by a glasspane
if (this.focusManager.isElementCovertByGlassPane(elementToFocus)) {
let activeElement = this.$container.activeElement(true);
if (elementToFocus && (!activeElement || !this.focusManager.isElementCovertByGlassPane(activeElement))) {
// If focus should be removed (blur), don't break here and try to focus the root element
// Otherwise, if desired element cannot be focused then break and leave the focus where it is, unless the currently focused element is covered by a glass pane
return;
}
elementToFocus = null;
}
// Focus $entryPoint if current focus is to be blurred.
// Otherwise, the HTML body would be focused which makes global keystrokes (like backspace) not to work anymore.
elementToFocus = elementToFocus || this.$container.entryPoint(true);
// If element may not be focused (example SVG element in IE) -> use the entryPoint as fallback
// $elementToFocus.focus() would trigger a focus event even the element won't be focused -> loop
// In that case the focus function does not exist on the svg element
if (!elementToFocus.focus) {
elementToFocus = this.$container.entryPoint(true);
}
// Only focus element if different to current focused element
if (focusUtils.isActiveElement(elementToFocus)) {
return;
}
// Focus the requested element
elementToFocus.focus({
preventScroll: scout.nvl(options.preventScroll, false)
});
// If requested and possible, select the text content
if (options.selectText && 'select' in elementToFocus && objects.isFunction(elementToFocus.select)) {
elementToFocus.select();
}
$.log.isDebugEnabled() && $.log.debug('Focus set to ' + graphics.debugOutput(elementToFocus));
}
}
export interface FocusContextFocusOptions {
/**
* prevents scrolling to new focused element (defaults to false)
*/
preventScroll?: boolean;
/**
* automatically selects the text content of the element if supported (defaults to false)
*/
selectText?: boolean;
}