From 93d3b09400c9b24723fb38f8c4f3d79f483a66a9 Mon Sep 17 00:00:00 2001 From: Raanan Weber Date: Thu, 27 Jun 2024 11:23:00 +0200 Subject: [PATCH] Focus on tab (#15232) --- .../dev/gui/src/2D/advancedDynamicTexture.ts | 53 ++++++++- packages/dev/gui/src/2D/controls/control.ts | 105 +++++++++++++++++- .../gui/src/2D/controls/focusableButton.ts | 78 ------------- .../gui/src/2D/controls/focusableControl.ts | 11 ++ packages/dev/gui/src/2D/controls/inputText.ts | 45 ++------ .../gui/src/2D/controls/virtualKeyboard.ts | 9 +- 6 files changed, 176 insertions(+), 125 deletions(-) diff --git a/packages/dev/gui/src/2D/advancedDynamicTexture.ts b/packages/dev/gui/src/2D/advancedDynamicTexture.ts index ac592b75e21..0c6676a3ecb 100644 --- a/packages/dev/gui/src/2D/advancedDynamicTexture.ts +++ b/packages/dev/gui/src/2D/advancedDynamicTexture.ts @@ -18,7 +18,6 @@ import type { Scene } from "core/scene"; import { Container } from "./controls/container"; import { Control } from "./controls/control"; -import type { IFocusableControl } from "./controls/focusableControl"; import { Style } from "./style"; import { Measure } from "./measure"; import { Constants } from "core/Engines/constants"; @@ -85,7 +84,7 @@ export class AdvancedDynamicTexture extends DynamicTexture { private _idealHeight = 0; private _useSmallestIdeal: boolean = false; private _renderAtIdealSize = false; - private _focusedControl: Nullable; + private _focusedControl: Nullable; private _blockNextFocusCheck = false; private _renderScale = 1; private _rootElement: Nullable; @@ -147,6 +146,12 @@ export class AdvancedDynamicTexture extends DynamicTexture { * Gets or sets a boolean indicating that the canvas must be reverted on Y when updating the texture */ public applyYInversionOnUpdate = true; + + /** + * A boolean indicating whether or not the elements can be navigated to using the tab key. + * Defaults to false. + */ + public disableTabNavigation = false; /** * Gets or sets a number used to scale rendering size (2 means that the texture will be twice bigger). * Useful when you want more antialiasing @@ -322,10 +327,10 @@ export class AdvancedDynamicTexture extends DynamicTexture { /** * Gets or sets the current focused control */ - public get focusedControl(): Nullable { + public get focusedControl(): Nullable { return this._focusedControl; } - public set focusedControl(control: Nullable) { + public set focusedControl(control: Nullable) { if (this._focusedControl == control) { return; } @@ -411,6 +416,12 @@ export class AdvancedDynamicTexture extends DynamicTexture { } }); this._preKeyboardObserver = scene.onPreKeyboardObservable.add((info) => { + // check if tab is pressed + if (!this.disableTabNavigation && info.type === KeyboardEventTypes.KEYDOWN && info.event.code === "Tab") { + this._focusNextElement(!info.event.shiftKey); + info.event.preventDefault(); + return; + } if (!this._focusedControl) { return; } @@ -984,6 +995,38 @@ export class AdvancedDynamicTexture extends DynamicTexture { this._attachToOnBlur(scene); } + private _focusNextElement(forward: boolean = true): void { + // generate the order of tab-able controls + const sortedTabbableControls: Control[] = []; + this.executeOnAllControls((control) => { + if (control.isFocusInvisible || !control.isVisible || control.tabIndex < 0) { + return; + } + sortedTabbableControls.push(control); + }); + // if no control is tab-able, return + if (sortedTabbableControls.length === 0) { + return; + } + sortedTabbableControls.sort((a, b) => { + // if tabIndex is 0, put it in the end of the list, otherwise sort by tabIndex + return a.tabIndex === 0 ? 1 : b.tabIndex === 0 ? -1 : a.tabIndex - b.tabIndex; + }); + // if no control is focused, focus the first one + if (!this._focusedControl) { + sortedTabbableControls[0].focus(); + } else { + const currentIndex = sortedTabbableControls.indexOf(this._focusedControl); + let nextIndex = currentIndex + (forward ? 1 : -1); + if (nextIndex < 0) { + nextIndex = sortedTabbableControls.length - 1; + } else if (nextIndex >= sortedTabbableControls.length) { + nextIndex = 0; + } + sortedTabbableControls[nextIndex].focus(); + } + } + /** * @internal */ @@ -1189,7 +1232,7 @@ export class AdvancedDynamicTexture extends DynamicTexture { * Move the focus to a specific control * @param control defines the control which will receive the focus */ - public moveFocusToControl(control: IFocusableControl): void { + public moveFocusToControl(control: Control): void { this.focusedControl = control; this._lastPickedControl = control; this._blockNextFocusCheck = true; diff --git a/packages/dev/gui/src/2D/controls/control.ts b/packages/dev/gui/src/2D/controls/control.ts index 85686e76b64..edbbdccfebc 100644 --- a/packages/dev/gui/src/2D/controls/control.ts +++ b/packages/dev/gui/src/2D/controls/control.ts @@ -23,17 +23,18 @@ import { SerializationHelper } from "core/Misc/decorators.serialization"; import type { ICanvasGradient, ICanvasRenderingContext } from "core/Engines/ICanvas"; import { EngineStore } from "core/Engines/engineStore"; import type { IAccessibilityTag } from "core/IAccessibilityTag"; -import type { IPointerEvent } from "core/Events/deviceInputEvents"; +import type { IKeyboardEvent, IPointerEvent } from "core/Events/deviceInputEvents"; import type { IAnimatable } from "core/Animations/animatable.interface"; import type { Animation } from "core/Animations/animation"; import type { BaseGradient } from "./gradient/BaseGradient"; import type { AbstractEngine } from "core/Engines/abstractEngine"; +import type { IFocusableControl } from "./focusableControl"; /** * Root class used for all 2D controls * @see https://doc.babylonjs.com/features/featuresDeepDive/gui/gui#controls */ -export class Control implements IAnimatable { +export class Control implements IAnimatable, IFocusableControl { /** * Gets or sets a boolean indicating if alpha must be an inherited value (false by default) */ @@ -365,6 +366,11 @@ export class Control implements IAnimatable { */ public onPointerClickObservable = new Observable(); + /** + * An event triggered when a control receives an ENTER key down event + */ + public onEnterPressedObservable = new Observable(); + /** * An event triggered when pointer enters the control */ @@ -1304,6 +1310,96 @@ export class Control implements IAnimatable { */ animations: Nullable = null; + // Focus functionality + + protected _focusedColor: Nullable = null; + /** + * Border color when control is focused + * When not defined the ADT color will be used. If no ADT color is defined, focused state won't have any border + */ + public get focusedColor(): Nullable { + return this._focusedColor; + } + public set focusedColor(value: Nullable) { + this._focusedColor = value; + } + /** + * The tab index of this control. -1 indicates this control is not part of the tab navigation. + * A positive value indicates the order of the control in the tab navigation. + * A value of 0 indicated the control will be focused after all controls with a positive index. + * More than one control can have the same tab index and the navigation would then go through all controls with the same value in an order defined by the layout or the hierarchy. + * The value can be changed at any time. + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex + */ + public tabIndex: number = -1; + protected _isFocused = false; + protected _unfocusedColor: Nullable = null; + + /** Observable raised when the control gets the focus */ + public onFocusObservable = new Observable(); + /** Observable raised when the control loses the focus */ + public onBlurObservable = new Observable(); + /** Observable raised when a key event was processed */ + public onKeyboardEventProcessedObservable = new Observable(); + + /** @internal */ + public onBlur(): void { + if (this._isFocused) { + this._isFocused = false; + if (this.focusedColor && this._unfocusedColor != null) { + // Set color back to saved unfocused color + this.color = this._unfocusedColor; + } + this.onBlurObservable.notifyObservers(this); + } + } + + /** @internal */ + public onFocus(): void { + this._isFocused = true; + + if (this.focusedColor) { + // Save the unfocused color + this._unfocusedColor = this.color; + this.color = this.focusedColor; + } + this.onFocusObservable.notifyObservers(this); + } + + /** + * Function called to get the list of controls that should not steal the focus from this control + * @returns an array of controls + */ + public keepsFocusWith(): Nullable { + return null; + } + + /** + * Function to focus a button programmatically + */ + public focus() { + this._host.moveFocusToControl(this); + } + + /** + * Function to unfocus a button programmatically + */ + public blur() { + this._host.focusedControl = null; + } + + /** + * Handles the keyboard event + * @param evt Defines the KeyboardEvent + */ + public processKeyboard(evt: IKeyboardEvent): void { + // if enter, trigger the new observable + if (evt.key === "Enter") { + this.onEnterPressedObservable.notifyObservers(this); + } + this.onKeyboardEventProcessedObservable.notifyObservers(evt, -1, this); + } + // Functions /** @@ -2602,6 +2698,11 @@ export class Control implements IAnimatable { this.onPointerClickObservable.clear(); this.onWheelObservable.clear(); + // focus + this.onBlurObservable.clear(); + this.onFocusObservable.clear(); + this.onKeyboardEventProcessedObservable.clear(); + if (this._styleObserver && this._style) { this._style.onChangedObservable.remove(this._styleObserver); this._styleObserver = null; diff --git a/packages/dev/gui/src/2D/controls/focusableButton.ts b/packages/dev/gui/src/2D/controls/focusableButton.ts index f77a9d68e51..1e5e8bac3c7 100644 --- a/packages/dev/gui/src/2D/controls/focusableButton.ts +++ b/packages/dev/gui/src/2D/controls/focusableButton.ts @@ -1,4 +1,3 @@ -import type { Nullable } from "core/types"; import type { Vector2 } from "core/Maths/math.vector"; import { Button } from "./button"; @@ -6,86 +5,18 @@ import type { Control } from "./control"; import { RegisterClass } from "core/Misc/typeStore"; import type { PointerInfoBase } from "core/Events/pointerEvents"; import type { IFocusableControl } from "./focusableControl"; -import { Observable } from "core/Misc/observable"; -import type { IKeyboardEvent } from "core/Events/deviceInputEvents"; /** * Class used to create a focusable button that can easily handle keyboard events * @since 5.0.0 */ export class FocusableButton extends Button implements IFocusableControl { - /** Highlight color when button is focused */ - public focusedColor: Nullable = null; - private _isFocused = false; - private _unfocusedColor: Nullable = null; - - /** Observable raised when the control gets the focus */ - public onFocusObservable = new Observable